EzCode # Bài 16 — Trang cá nhân và trang Vào học
Học xong bài này bạn sẽ:
- Viết chức năng sửa hồ sơ và đổi mật khẩu an toàn
- Hiểu mẫu PRG và vì sao phải chuyển hướng sau khi xử lý POST
- Có trang "Khóa học của tôi" và trang Vào học có video
Cần học trước: Bài 15
1. Vấn đề đặt ra
Người dùng đã đăng nhập được. Giờ họ cần một khu vực riêng để:
- Xem và sửa thông tin cá nhân
- Đổi mật khẩu
- Xem những khóa học đã mua
- Vào học
Bốn trang này có hai điểm chung: đều yêu cầu đăng nhập, và đều chỉ thao tác trên dữ liệu của chính người đó.
Điểm thứ hai quan trọng hơn bạn tưởng. Nếu code lấy id từ URL thay vì từ session, người dùng chỉ cần đổi ?id=35 là xem được hồ sơ người khác. Bài này cho bạn thấy cách làm đúng.
2. Kiến thức mới
2.1. Chặn quyền ở đầu mỗi phương thức
Bốn phương thức mới đều bắt đầu bằng ba dòng giống hệt nhau:
if (!isset($_SESSION['user_id'])) {
header("Location: ?ctrl=user&act=login");
exit();
}
Đây là mẫu bạn đã làm ở Bài 5 bài tập 2, và đã bàn cách gom lại ở Bài 15 bài tập 2.
EzCode lặp lại nó bốn lần. Tác giả chưa dùng middleware cho UserController — khác với TeacherController ở Bài 17, nơi kiểm tra được đặt trong __construct().
Vì sao không dùng __construct() cho UserController? Vì hai phương thức register() và login() phải cho phép người chưa đăng nhập vào. Constructor chạy trước mọi phương thức nên sẽ chặn cả hai.
Muốn dùng constructor, bạn phải tách thành hai Controller: một cho khách và một cho người đã đăng nhập. Đó là cách các framework làm.
2.2. Lấy id từ session, không bao giờ từ URL
$user = $userModel->getById($_SESSION['user_id']);
So sánh với cách làm sai:
$user = $userModel->getById($_GET['id']); // NGUY HIỂM
Cách sai cho phép bất kỳ ai đăng nhập cũng xem được hồ sơ của mọi người, chỉ bằng cách đổi số trên thanh địa chỉ. Lỗ hổng này có tên: truy cập trực tiếp đối tượng không kiểm soát.
$_SESSION['user_id'] thì người dùng không sửa được — nó nằm trên máy chủ, như Bài 5 mục 2.6 đã giải thích.
Nguyên tắc: dữ liệu riêng tư luôn truy vấn bằng id từ session.
EzCode tuân thủ đúng ở cả bốn phương thức của bài này. Đây là điểm mạnh.
2.3. Mẫu PRG — Post, Redirect, Get
Hãy so sánh hai phương thức trong cùng file.
updateProfile() — luôn chuyển hướng:
public function updateProfile()
{
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// ...xử lý...
}
header("Location: ?ctrl=user&act=profile");
exit();
}
changePassword() — nạp View trực tiếp:
public function changePassword()
{
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// ...xử lý...
}
include_once "./Views/user_password.php";
}
Hai kiểu khác nhau, và kiểu thứ nhất tốt hơn. Đây gọi là mẫu PRG: Post rồi Redirect rồi Get.
Vì sao PRG tốt hơn? Hãy thử tình huống này với changePassword():
- Người dùng điền form và bấm nút. Trình duyệt gửi POST.
- Máy chủ xử lý xong và trả HTML về ngay trong phản hồi của POST đó.
- Người dùng bấm F5.
- Trình duyệt hỏi: "Bạn có muốn gửi lại biểu mẫu không?"
- Nếu họ bấm Có, toàn bộ việc xử lý chạy lại lần nữa.
Với đổi mật khẩu thì chỉ đổi lại cùng giá trị, không sao. Nhưng với thanh toán hay tạo đơn hàng thì đó là thảm họa — người dùng bị trừ tiền hai lần.
Với PRG, bước 2 khác đi: máy chủ trả về lệnh chuyển hướng, trình duyệt tự mở trang mới bằng GET. Bấm F5 lúc này chỉ tải lại trang GET đó, không gửi lại gì cả.
Đây là lý do tồn tại của flash message. Vì chuyển hướng sang trang khác nên biến thường mất hết, và session là cách duy nhất mang thông báo đi cùng.
2.4. Tách phương thức hiện form và phương thức xử lý
Trang hồ sơ dùng hai phương thức:
profile()— chỉ hiện form, không xử lý gìupdateProfile()— chỉ xử lý, không hiện gì, luôn chuyển hướng vềprofile()
Form trong View trỏ thẳng tới phương thức xử lý:
<form method="POST" action="?ctrl=user&act=updateProfile">
Thuộc tính action chỉ định nơi gửi form. Không có nó, form gửi về chính trang hiện tại.
Cách tách này rõ ràng: mỗi phương thức một việc. Còn changePassword() gộp cả hai vào một, ngắn hơn nhưng lẫn lộn trách nhiệm.
Cả hai kiểu cùng tồn tại trong EzCode. Đây là sự thiếu nhất quán, nhưng cũng là cơ hội tốt để bạn thấy hai cách và tự chọn.
2.5. Cập nhật session sau khi đổi thông tin
if ($result) {
$_SESSION['user_name'] = $name;
$_SESSION['success'] = "Cập nhật thông tin thành công!";
}
Dòng $_SESSION['user_name'] = $name; rất quan trọng.
Nhớ lại Bài 15 mục 2.4: user_name được ghi vào session một lần lúc đăng nhập. Nếu người dùng đổi tên mà bạn không cập nhật session, menu trên đầu trang vẫn hiện tên cũ cho tới khi họ đăng xuất và đăng nhập lại.
Nguyên tắc chung: bất cứ khi nào sửa dữ liệu đang được lưu bản sao trong session, phải cập nhật cả hai nơi.
Đây cũng chính là vấn đề mà Bài 15 bài tập 3 đã bàn với cột role.
2.6. Đổi mật khẩu phải xác minh mật khẩu cũ
$user = $userModel->getById($_SESSION['user_id']);
if (!password_verify($current_password, $user['password'])) {
$errors[] = "Mật khẩu hiện tại không đúng";
}
Vì sao cần bước này khi người dùng đã đăng nhập rồi?
Vì phiên đăng nhập có thể không thuộc về chủ tài khoản. Ba tình huống thật:
- Người dùng để máy tính mở rồi đi ra ngoài. Ai đó ngồi vào đổi mật khẩu và chiếm tài khoản vĩnh viễn.
- Máy tính dùng chung ở quán net hay thư viện, người trước quên đăng xuất.
- Kẻ tấn công lấy được cookie phiên bằng cách nào đó.
Bắt nhập lại mật khẩu cũ là hàng rào cuối cùng. Kẻ chiếm phiên có thể xem hồ sơ, nhưng không thể khóa chủ tài khoản ra ngoài.
Nguyên tắc này áp dụng cho mọi thao tác nhạy cảm: đổi mật khẩu, đổi email, xóa tài khoản, rút tiền.
2.7. Câu UPDATE
UPDATE users SET name=?, email=?, phone=? WHERE id=?
Ba phần:
UPDATE users— bảng cần sửaSET cot=giá_trị, ...— các cột cần đổi, cách nhau bằng dấu phẩyWHERE id=?— điều kiện chọn dòng
WHERElà phần nguy hiểm nhất của câuUPDATE.Quên nó đi:
UPDATE users SET name='Hoàng'MySQL sẽ đổi tên toàn bộ 35 người dùng thành "Hoàng". Không có cảnh báo, không hoàn tác được.
Cùng nguyên tắc với
DELETE:DELETE FROM userskhông cóWHEREsẽ xóa sạch bảng.Thói quen an toàn: khi viết câu
UPDATEhayDELETEtrong phpMyAdmin, hãy viếtWHEREtrước, rồi mới viết phần đầu.
Thứ tự tham số: bốn dấu ? theo thứ tự name, email, phone, id. Lời gọi phải khớp:
return $this->db->update($sql, $name, $email, $phone, $id);
Chú ý $id đứng cuối vì nó ở mệnh đề WHERE, đứng sau trong câu SQL. Đây là chỗ rất dễ nhầm.
2.8. Nhúng video YouTube
<iframe src="https://www.youtube.com/embed/MA_VIDEO" frameborder="0" allowfullscreen></iframe>
Thẻ <iframe> nhúng một trang web khác vào trang của bạn.
YouTube có hai dạng địa chỉ:
| Loại | Dạng | Nhúng được |
|---|---|---|
| Xem thường | https://www.youtube.com/watch?v=alDeWlUHYSA |
Không |
| Nhúng | https://www.youtube.com/embed/alDeWlUHYSA |
Có |
Phần alDeWlUHYSA là mã video. Muốn nhúng, bạn phải ghép nó vào sau /embed/.
Trong dữ liệu mẫu của EzCode, cột video lưu lộn xộn: bài 1 lưu mã trần alDeWlUHYSA, các bài khác lưu địa chỉ giả https://youtu.be/css1. Đây là dữ liệu mẫu không nhất quán, nên chỉ bài đầu tiên của khóa 1 mới có video thật.
3. Áp dụng vào EzCode
Bài này tạo 6 file và sửa 3 file. Làm theo thứ tự.
3.1. Thêm hai phương thức cập nhật vào Model User
Chèn vào Models/User.php sau create().
📁
Models/User.php— sửa
function updateProfile($id, $name, $email, $phone)
{
$sql = "UPDATE users SET name=?, email=?, phone=? WHERE id=?";
return $this->db->update($sql, $name, $email, $phone, $id);
}
function updatePassword($id, $password)
{
$sql = "UPDATE users SET password=? WHERE id=?";
return $this->db->update($sql, $password, $id);
}
Cả hai gọi $this->db->update() — phương thức trả về true hoặc false từ execute(), khác với insert() trả về id.
Một điều cần biết: update() trả về true kể cả khi không dòng nào thực sự thay đổi. Nếu người dùng bấm "Cập nhật" mà không sửa gì, MySQL vẫn báo thành công. Muốn biết có bao nhiêu dòng bị đổi thật thì phải dùng $stmt->rowCount(), nhưng class Database không mở ra khả năng đó.
updatePassword() nhận $password là chuỗi băm, không phải mật khẩu gốc. Controller băm trước khi gọi.
3.2. Thêm phương thức lấy khóa học đã mua
Chèn vào Models/User.php — vị trí trong repo là sau enrollCourse() mà Bài 21 sẽ viết, nhưng bây giờ bạn cứ đặt nó vào cuối, trước dấu } đóng class.
📁
Models/User.php— sửa
function getEnrolledCourses($user_id)
{
$sql = "SELECT c.*, e.enrolled_at FROM courses c
INNER JOIN enrollments e ON c.id = e.course_id
WHERE e.user_id = ? AND e.role = 'student'
ORDER BY e.enrolled_at DESC";
return $this->db->query($sql, $user_id);
}
Đây là INNER JOIN thứ hai bạn gặp, và nó dùng bảng trung gian enrollments mà Bài 7 mục 2.7 đã giới thiệu.
Cách đọc câu này:
- Bắt đầu từ bảng
coursesvới bí danhc - Nối với
enrollmentsở chỗc.id = e.course_id - Chỉ giữ những dòng có
e.user_idbằng người đang đăng nhập - Và
e.role = 'student'— lọc theo vai trò trong khóa học đó - Sắp xếp theo ngày mua, mới nhất lên đầu
Kết quả gồm 8 cột của courses cộng thêm enrolled_at từ enrollments.
SELECT c.*, e.enrolled_at là ví dụ tốt về việc lấy dữ liệu từ cả hai bảng trong một câu: thông tin khóa học từ bảng này, ngày mua từ bảng kia.
Chú ý điều kiện e.role = 'student' được viết cứng chứ không dùng ?. Cùng lý do như Bài 11 mục 2.3 — giá trị do lập trình viên quyết định. Nhưng ở đây tác giả nối thẳng vào SQL thay vì dùng placeholder, khác với getAllTeacher(). Lại một sự thiếu nhất quán nhỏ.
3.3. Thêm phương thức tương tự vào Model Courses
📁
Models/Courses.php— sửa
function getEnrolledCourses($user_id)
{
$sql = "SELECT c.*, e.enrolled_at FROM courses c
INNER JOIN enrollments e ON c.id = e.course_id
WHERE e.user_id = ? AND e.role = 'student' AND c.status = 1
ORDER BY e.enrolled_at DESC";
return $this->db->query($sql, $user_id);
}
Đây là code trùng lặp. Phương thức này gần như giống hệt phương thức cùng tên trong Models/User.php, chỉ thêm điều kiện AND c.status = 1.
Và điều đáng nói: UserController::course() gọi bản trong User, còn bản trong Courses không được file nào gọi tới.
Bạn có thể tự kiểm chứng bằng cách tìm chuỗi getEnrolledCourses trong toàn project — chỉ có một lời gọi, và nó dùng $userModel.
Vậy vì sao vẫn viết? Có lẽ tác giả viết ở Courses trước, rồi sau đó viết lại ở User cho tiện, và quên xóa bản cũ.
Hệ quả thực tế: vì bản đang dùng không lọc status = 1, nên khóa học đã bị giảng viên ẩn đi vẫn hiện trong danh sách "Khóa học của tôi" của học viên. Đây là hành vi đúng hay sai thì tùy quan điểm — học viên đã trả tiền thì vẫn nên học được. Nhưng nó là ngoài ý muốn chứ không phải quyết định có chủ đích.
Bạn cứ gõ cả hai để khớp repo. Bài 22 sẽ bàn về code trùng lặp và code chết.
3.4. Thêm bốn phương thức vào UserController
Chèn vào Controllers/UserController.php sau logout().
📁
Controllers/UserController.php— sửa
public function profile()
{
if (!isset($_SESSION['user_id'])) {
header("Location: ?ctrl=user&act=login");
exit();
}
include_once "./Models/User.php";
$userModel = new User();
$user = $userModel->getById($_SESSION['user_id']);
include_once "./Views/user_profile.php";
}
public function updateProfile()
{
if (!isset($_SESSION['user_id'])) {
header("Location: ?ctrl=user&act=login");
exit();
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$name = $_POST['name'] ?? '';
$email = $_POST['email'] ?? '';
$phone = $_POST['phone'] ?? '';
$errors = [];
if (empty($name)) $errors[] = "Tên không được để trống";
if (empty($email)) $errors[] = "Email không được để trống";
if (empty($phone)) $errors[] = "Số điện thoại không được để trống";
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = "Email không hợp lệ";
if (empty($errors)) {
include_once "./Models/User.php";
$userModel = new User();
$result = $userModel->updateProfile($_SESSION['user_id'], $name, $email, $phone);
if ($result) {
$_SESSION['user_name'] = $name;
$_SESSION['success'] = "Cập nhật thông tin thành công!";
} else {
$_SESSION['error'] = "Có lỗi xảy ra khi cập nhật thông tin";
}
} else {
$_SESSION['error'] = implode(", ", $errors);
}
}
header("Location: ?ctrl=user&act=profile");
exit();
}
public function changePassword()
{
if (!isset($_SESSION['user_id'])) {
header("Location: ?ctrl=user&act=login");
exit();
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$current_password = $_POST['current_password'] ?? '';
$new_password = $_POST['new_password'] ?? '';
$confirm_password = $_POST['confirm_password'] ?? '';
$errors = [];
if (empty($current_password)) $errors[] = "Mật khẩu hiện tại không được để trống";
if (empty($new_password)) $errors[] = "Mật khẩu mới không được để trống";
if ($new_password !== $confirm_password) $errors[] = "Mật khẩu xác nhận không khớp";
if (strlen($new_password) < 6) $errors[] = "Mật khẩu phải có ít nhất 6 ký tự";
if (empty($errors)) {
include_once "./Models/User.php";
$userModel = new User();
// Kiểm tra mật khẩu hiện tại
$user = $userModel->getById($_SESSION['user_id']);
if (!password_verify($current_password, $user['password'])) {
$errors[] = "Mật khẩu hiện tại không đúng";
} else {
// Cập nhật mật khẩu mới
$hashed_password = password_hash($new_password, PASSWORD_DEFAULT);
$result = $userModel->updatePassword($_SESSION['user_id'], $hashed_password);
if ($result) {
$_SESSION['success'] = "Đổi mật khẩu thành công!";
} else {
$_SESSION['error'] = "Có lỗi xảy ra khi đổi mật khẩu";
}
}
}
if (!empty($errors)) {
$_SESSION['error'] = implode(", ", $errors);
}
}
include_once "./Views/user_password.php";
}
public function course()
{
if (!isset($_SESSION['user_id'])) {
header("Location: ?ctrl=user&act=login");
exit();
}
include_once "./Models/User.php";
$userModel = new User();
$enrolledCourses = $userModel->getEnrolledCourses($_SESSION['user_id']);
include_once "./Views/user_course.php";
}
}
Khối này kết thúc bằng } đóng class — UserController đã hoàn chỉnh với 7 phương thức.
implode(", ", $errors) ghép mảng lỗi thành một chuỗi ngăn bởi dấu phẩy, vì $_SESSION['error'] chỉ chứa được một chuỗi. Ngược với explode() tách chuỗi thành mảng.
Cách này làm mất định dạng — thay vì mỗi lỗi một dòng, chúng dính thành một câu dài. Đánh đổi để dùng chung được một khóa session.
Một điểm chưa hoàn thiện trong changePassword(): dòng
if (strlen($new_password) < 6) $errors[] = "Mật khẩu phải có ít nhất 6 ký tự";
không có điều kiện !empty() phía trước. Nên khi để trống ô mật khẩu mới, người dùng nhận hai thông báo cùng lúc. Đúng vấn đề mà Bài 14 đáp án 1 đã bàn.
3.5. Tạo Controller cho trang học
📁
Controllers/LearnController.php— tạo mới
<?php
class LearnController
{
public function detail($id)
{
// Kiểm tra xem user đã đăng nhập chưa
if (!isset($_SESSION['user_id'])) {
header("Location: ?ctrl=user&act=login");
exit();
}
include_once "./Models/Courses.php";
$coursesModel = new Courses();
$courseDetail = $coursesModel->getById($id);
if (!$courseDetail) {
echo "Khóa học không tồn tại!";
exit();
}
include_once "./Models/Lession.php";
$lessionModel = new Lession();
$lessionsList = $lessionModel->getAllByCourse($id);
// Include view cho trang học
include_once "./Views/learn_detail.php";
}
}
Controller ngắn nhất sau PageController.
Nó có kiểm tra $courseDetail khác null — tốt hơn CourseController::detail() mà Bài 13 đã phê bình. Nhưng cách xử lý thì thô: echo một dòng chữ trần rồi exit(), không có giao diện, không có nút quay lại. Người dùng thấy một trang gần như trắng với header ở trên.
⚠️ Lưu ý — đây là lỗ hổng nghiêm trọng nhất của cả project.
Phương thức này chỉ kiểm tra đã đăng nhập, không kiểm tra đã mua khóa học.
Nghĩa là bất kỳ ai tạo một tài khoản miễn phí ở Bài 14 đều gõ được
?ctrl=learn&act=detail&id=1và học toàn bộ nội dung mà không trả một đồng.Toàn bộ chức năng thanh toán ở Bài 21 trở thành trang trí. Mô hình kinh doanh của website bị phá vỡ hoàn toàn.
Đây là lỗi số 11 trong Bài 22. Bài tập 3 dưới đây chính là bản vá.
3.6. Bốn View của khu vực cá nhân
📁
Views/user_profile.php— tạo mới
<main class="profile-page">
<div class="profile-sidebar">
<ul class="profile-menu">
<li class="active"><a href="?ctrl=user&act=profile">Thông tin cá nhân</a></li>
<li><a href="?ctrl=user&act=changePassword">Đổi mật khẩu</a></li>
<li><a href="?ctrl=user&act=course">Khóa học của tôi</a></li>
<li><a href="?ctrl=payment&act=history">Lịch sử thanh toán</a></li>
</ul>
</div>
<div class="profile-content">
<h2>Thông tin cá nhân</h2>
<?php if (isset($_SESSION['success'])): ?>
<div style="background-color: #d4edda; color: #155724; padding: 10px; border-radius: 5px; margin-bottom: 15px;">
<?= $_SESSION['success'] ?>
</div>
<?php unset($_SESSION['success']); ?>
<?php endif; ?>
<?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" action="?ctrl=user&act=updateProfile" class="personal-form">
<div>
<label for="name">Họ và tên *</label>
<input type="text" id="name" name="name" value="<?= htmlspecialchars($user['name']) ?>" required>
</div>
<div>
<label for="email">Email *</label>
<input type="email" id="email" name="email" value="<?= htmlspecialchars($user['email']) ?>" required>
</div>
<div>
<label for="phone">Số điện thoại *</label>
<input type="tel" id="phone" name="phone" value="<?= htmlspecialchars($user['phone']) ?>" required>
</div>
<div>
<label for="role">Vai trò</label>
<input type="text" id="role" value="<?= $user['role'] === 'teacher' ? 'Giảng viên' : 'Học viên' ?>" readonly>
</div>
<button type="submit">Cập nhật thông tin</button>
</form>
</div>
</main>
Ô "Vai trò" có thuộc tính readonly và không có name. Cả hai đều quan trọng:
readonlykhiến người dùng không sửa được trên giao diện- Không có
namenghĩa là ô này không được gửi lên máy chủ khi bấm nút
Đây là cách làm đúng. Nếu ô có name="role", kẻ xấu chỉ cần bỏ readonly bằng F12 rồi gõ teacher. Nhưng vì updateProfile() không hề đọc $_POST['role'] nên dù họ có gửi lên cũng vô ích. Hai lớp bảo vệ.
Form có action="?ctrl=user&act=updateProfile" — gửi tới phương thức khác, đúng mẫu tách ở mục 2.4.
📁
Views/user_password.php— tạo mới
<main class="profile-page">
<div class="profile-sidebar">
<ul class="profile-menu">
<li><a href="?ctrl=user&act=profile">Thông tin cá nhân</a></li>
<li class="active"><a href="?ctrl=user&act=changePassword">Đổi mật khẩu</a></li>
<li><a href="?ctrl=user&act=course">Khóa học của tôi</a></li>
<li><a href="?ctrl=payment&act=history">Lịch sử thanh toán</a></li>
</ul>
</div>
<div class="profile-content">
<h2>Đổi mật khẩu</h2>
<?php if (isset($_SESSION['success'])): ?>
<div style="background-color: #d4edda; color: #155724; padding: 10px; border-radius: 5px; margin-bottom: 15px;">
<?= $_SESSION['success'] ?>
</div>
<?php unset($_SESSION['success']); ?>
<?php endif; ?>
<?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="change-password-form">
<div>
<label for="current_password">Mật khẩu hiện tại *</label>
<input type="password" id="current_password" name="current_password" required>
</div>
<div>
<label for="new_password">Mật khẩu mới *</label>
<input type="password" id="new_password" name="new_password" required>
</div>
<div>
<label for="confirm_password">Xác nhận mật khẩu mới *</label>
<input type="password" id="confirm_password" name="confirm_password" required>
</div>
<button type="submit">Đổi mật khẩu</button>
</form>
</div>
</main>
Form này không có action, nên nó gửi về chính ?ctrl=user&act=changePassword — đúng kiểu gộp ở mục 2.4.
Chú ý thụt lề của khối sidebar bị lệch so với file trước. Không ảnh hưởng gì, chỉ là tác giả gõ vội. Gõ y hệt.
📁
Views/user_course.php— tạo mới
<main class="profile-page">
<div class="profile-sidebar">
<ul class="profile-menu">
<li><a href="?ctrl=user&act=profile">Thông tin cá nhân</a></li>
<li><a href="?ctrl=user&act=changePassword">Đổi mật khẩu</a></li>
<li class="active"><a href="?ctrl=user&act=course">Khóa học của tôi</a></li>
<li><a href="?ctrl=payment&act=history">Lịch sử thanh toán</a></li>
</ul>
</div>
<div class="profile-content">
<h2>Khóa học của tôi</h2>
<?php if (empty($enrolledCourses)): ?>
<div style="text-align: center; padding: 50px; color: #666;">
<h3>Bạn chưa đăng ký khóa học nào</h3>
<p>Hãy <a href="?ctrl=course&act=list" style="color: #0868b4;">khám phá các khóa học</a> và đăng ký ngay!</p>
</div>
<?php else: ?>
<div class="courses">
<?php foreach ($enrolledCourses as $course): ?>
<div class="course">
<img src="public/img/courses/<?= $course['image'] ?>" alt="<?= $course['title'] ?>" />
<h3><?= $course['title'] ?></h3>
<p>Đăng ký: <?= date('d/m/Y', strtotime($course['enrolled_at'])) ?></p>
<a href="?ctrl=learn&act=detail&id=<?= $course['id'] ?>" class="btn">Vào học</a>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</main>
Dòng date('d/m/Y', strtotime($course['enrolled_at'])) dùng cặp hàm bạn học ở Bài 2 mục 2.11. Cột enrolled_at đến từ INNER JOIN với bảng enrollments.
📁
Views/layout_user_sidebar.php— tạo mới
<aside class="profile-sidebar">
<ul class="profile-menu">
<li class="<?= $_GET['act'] == 'course' ? 'active' : '' ?>">
<a href="?ctrl=user&act=course">Khóa học của tôi</a></li>
<li class="<?= $_GET['act'] == 'profile' ? 'active' : '' ?>">
<a href="?ctrl=user&act=profile">Thông tin cá nhân</a>
</li>
<li class="<?= $_GET['act'] == 'password' ? 'active' : '' ?>><a href="?ctrl=user&act=password">Đổi mật khẩu</a></li>
</ul>
</aside>
File này không được View nào include. Ba View trên đều tự viết sidebar của mình thay vì dùng file chung này.
Nghĩa là đây là code chết — tác giả định làm sidebar dùng chung, viết ra rồi bỏ dở.
⚠️ Lưu ý: File này có hai lỗi rõ ràng.
Lỗi thứ nhất — thiếu dấu nháy kép. Dòng cuối viết:
<li class="<?= ... ?>><a href="...">Sau
?>là dấu>chứ không phải">. Thiếu dấu nháy đóng thuộc tínhclass. Nếu file này được dùng, trình duyệt sẽ hiểu sai cấu trúc HTML và mục cuối cùng biến mất khỏi giao diện. Đây là lỗi số 9 trong Bài 22.Lỗi thứ hai — trỏ tới phương thức không tồn tại. Liên kết
?ctrl=user&act=passwordgọiUserController::password(), nhưng phương thức đó không có — tên thật làchangePassword(). Đây là lỗi số 4 trong Bài 22.Cả hai lỗi hiện chưa gây hậu quả vì file không được dùng. Nhưng lỗi thứ hai thì có, vì
Views/payment_history.phpở Bài 21 cũng trỏ tới cùng địa chỉ sai đó.
3.7. Tạo View trang học
📁
Views/learn_detail.php— tạo mới
<main class="learning-page">
<div class="learning-sidebar">
<div class="course-info">
<img src="public/img/courses/<?= $courseDetail['image'] ?>" alt="<?= $courseDetail['title'] ?>" />
<h3><?= $courseDetail['title'] ?></h3>
</div>
<ul class="lesson-nav">
<?php foreach ($lessionsList as $index => $lesson): ?>
<li class="<?= $index === 0 ? 'active' : '' ?>" onclick="showLesson(<?= $index ?>)">
<?= $lesson['title'] ?>
</li>
<?php endforeach; ?>
</ul>
</div>
<div class="lesson-content">
<?php if (!empty($lessionsList)): ?>
<h2><?= $lessionsList[0]['title'] ?></h2>
<div class="video-wrapper">
<iframe
src="https://www.youtube.com/embed/<?= str_replace('https://www.youtube.com/watch?v=', '', $lesson['video']) ?>"
frameborder="0"
allowfullscreen>
</iframe>
</div>
<div class="lesson-description">
<p><?= $lessionsList[0]['content'] ?></p>
</div>
<?php else: ?>
<h2>Chưa có bài học nào</h2>
<p>Khóa học này chưa có bài học nào được thêm vào.</p>
<?php endif; ?>
</div>
</main>
<script>
function showLesson(index) {
// Ẩn tất cả lesson content
const lessonContents = document.querySelectorAll('.lesson-content h2, .lesson-content .video-wrapper, .lesson-content .lesson-description');
lessonContents.forEach(content => content.style.display = 'none');
// Hiển thị lesson được chọn
const lessons = <?= json_encode($lessionsList) ?>;
if (lessons[index]) {
document.querySelector('.lesson-content h2').textContent = lessons[index].title;
document.querySelector('.lesson-content h2').style.display = 'block';
if (lessons[index].video_url) {
document.querySelector('.lesson-content .video-wrapper iframe').src = lessons[index].video_url;
document.querySelector('.lesson-content .video-wrapper').style.display = 'block';
}
document.querySelector('.lesson-content .lesson-description p').textContent = lessons[index].content;
document.querySelector('.lesson-content .lesson-description').style.display = 'block';
}
// Cập nhật active state
document.querySelectorAll('.lesson-nav li').forEach(li => li.classList.remove('active'));
event.target.classList.add('active');
}
</script>
Chú ý thẻ <script> nằm ngoài </main> và không thụt lề — đúng như repo.
json_encode($lessionsList) biến mảng PHP thành chuỗi JSON để JavaScript đọc được. Đây là cầu nối phổ biến nhất giữa PHP chạy ở máy chủ và JavaScript chạy ở trình duyệt.
⚠️ Lưu ý — file này có hai lỗi.
Lỗi thứ nhất, biến
$lessonrò rỉ. Thẻ<iframe>dùng$lesson['video'], nhưng$lessonlà biến tạm của vòngforeachphía trên.Trong PHP, biến tạm của
foreachvẫn tồn tại sau khi vòng lặp kết thúc, và giữ giá trị của phần tử cuối cùng. Nên video hiện ra luôn là video của bài học cuối, dù tiêu đề và nội dung hiển thị là của bài đầu tiên ($lessionsList[0]).Đúng ra phải là
$lessionsList[0]['video']. Đây là lỗi số 7 trong Bài 22.Lỗi thứ hai, sai tên cột trong JavaScript. Hàm
showLessonkiểm tralessons[index].video_url, nhưng cột thật trong bảnglessionstên làvideo, không phảivideo_url.Nên điều kiện đó luôn sai, và video không bao giờ đổi khi bấm sang bài khác. Tiêu đề và nội dung thì đổi được.
Cả hai lỗi cùng nằm ở mục 7 của Bài 22.
4. Chạy thử
Đăng nhập bằng tài khoản học viên hhoang02052004@gmail.com mật khẩu 12345abcd.
Thử 1 — Trang hồ sơ. Mở ?ctrl=user&act=profile hoặc bấm "Xin chào, ..." trên menu.
Bạn thấy sidebar bốn mục bên trái, form thông tin bên phải với dữ liệu đã điền sẵn, và ô "Vai trò" hiện "Học viên" ở dạng chỉ đọc.
Thử 2 — Sửa hồ sơ. Đổi họ tên thành tên khác, bấm "Cập nhật thông tin".
Kết quả: khung xanh "Cập nhật thông tin thành công!", và menu trên đầu trang đổi tên ngay lập tức — đó là dòng $_SESSION['user_name'] = $name; ở mục 2.5.
Thử 3 — Kiểm chứng PRG. Ngay sau khi cập nhật, bấm F5.
Trình duyệt không hỏi "gửi lại biểu mẫu". Trang chỉ tải lại bình thường, và thông báo xanh biến mất vì unset(). Đó là mẫu PRG đang làm việc.
Để thấy sự khác biệt, sang trang đổi mật khẩu, đổi mật khẩu thành công rồi bấm F5. Lần này trình duyệt có hỏi, vì changePassword() không dùng PRG.
Thử 4 — Đổi mật khẩu. Mở ?ctrl=user&act=changePassword.
Thử nhập sai mật khẩu hiện tại: Mật khẩu hiện tại không đúng.
Thử hai ô mật khẩu mới khác nhau: Mật khẩu xác nhận không khớp.
Thử mật khẩu mới chỉ 3 ký tự: Mật khẩu phải có ít nhất 6 ký tự.
Đổi đúng cách rồi đăng xuất, đăng nhập lại bằng mật khẩu mới. Thành công.
Thử 5 — Khóa học của tôi. Mở ?ctrl=user&act=course.
Với tài khoản hhoang02052004@gmail.com (id 34), danh sách trống — vì bảng enrollments chỉ có 2 dòng cho user 2 và 3.
Để thấy dữ liệu, vào phpMyAdmin, bảng enrollments, thêm một dòng:
| user_id | course_id | role | price |
|---|---|---|---|
| 34 | 1 | student | 300000 |
Tải lại trang, bạn thấy khóa "Khóa học HTML CSS cơ bản" cùng ngày đăng ký và nút "Vào học".
Thử 6 — Trang học. Bấm "Vào học".
Bạn thấy sidebar có ảnh khóa học và hai bài học, khu vực chính hiện tiêu đề "Giới thiệu về HTML", một khung video, và nội dung.
Bấm sang bài "CSS cơ bản" — tiêu đề và nội dung đổi, nhưng video không đổi. Đúng như hộp cảnh báo ở mục 3.7.
Thử 7 — Kiểm chứng lỗ hổng số 11. Đăng xuất, đăng ký một tài khoản hoàn toàn mới ở ?ctrl=user&act=register, đăng nhập bằng nó.
Tài khoản này chưa mua gì cả. Giờ gõ thẳng vào thanh địa chỉ:
?ctrl=learn&act=detail&id=1
Bạn vào học được bình thường. Toàn bộ nội dung khóa học 300.000 đồng hiện ra miễn phí.
Đây là lỗ hổng số 11, và bạn vừa tự tay khai thác nó. Bài tập 3 sẽ vá.
5. Bài tập
Bài tập 1
Thêm vào Views/user_profile.php một dòng hiển thị số khóa học đã đăng ký:
Bạn đã đăng ký 3 khóa học
Con số lấy từ getEnrolledCourses().
Bài tập 2
Sửa UserController::changePassword() để chặn trường hợp mật khẩu mới trùng mật khẩu cũ.
Thông báo: Mật khẩu mới phải khác mật khẩu hiện tại.
Gợi ý: bạn đã có chuỗi băm cũ trong
$user['password']. Dùngpassword_verify()để so.
Bài tập 3
Vá lỗ hổng số 11.
Sửa LearnController::detail() để chặn người chưa mua khóa học.
Vì User::checkEnrollment() tới Bài 21 mới có, hãy tạm viết truy vấn kiểm tra ngay trong Controller bằng cách gọi trực tiếp Models/Database.php, hoặc tự thêm phương thức checkEnrollment() vào Models/User.php luôn.
Nếu chưa mua: ghi $_SESSION['error'] rồi chuyển hướng về trang chi tiết khóa học.
Kiểm chứng lại bằng đúng cách bạn đã khai thác ở Thử 7.
6. Đáp án
Đáp án bài tập 1
📄 Controllers/UserController.php — thêm vào profile()
$enrolledCourses = $userModel->getEnrolledCourses($_SESSION['user_id']);
📄 Views/user_profile.php — thêm dưới tiêu đề
<h2>Thông tin cá nhân</h2>
<p>Bạn đã đăng ký <?= count($enrolledCourses) ?> khóa học</p>
Dùng count() trên mảng có sẵn thay vì viết thêm truy vấn COUNT(*) — đúng nguyên tắc ở Bài 11 đáp án 3.
Nhưng ở đây có một điểm đáng cân nhắc: trang hồ sơ không cần danh sách khóa học, chỉ cần con số. Lấy về cả 8 cột của mọi khóa học rồi chỉ đếm là lãng phí.
Nếu người dùng có 50 khóa học, bạn kéo về 50 dòng dữ liệu đầy đủ chỉ để in ra số 50. Trường hợp này COUNT(*) mới đúng:
function countEnrolledCourses($user_id)
{
$sql = "SELECT COUNT(*) as total FROM enrollments WHERE user_id=? AND role='student'";
$result = $this->db->queryOne($sql, $user_id);
return $result['total'] ?? 0;
}
Nguyên tắc đầy đủ hơn Bài 11: đã có mảng trong tay thì dùng count(); nếu phải truy vấn thêm chỉ để đếm thì dùng COUNT(*).
Đáp án bài tập 2
📄 Controllers/UserController.php — sửa khối kiểm tra trong changePassword()
$user = $userModel->getById($_SESSION['user_id']);
if (!password_verify($current_password, $user['password'])) {
$errors[] = "Mật khẩu hiện tại không đúng";
} elseif (password_verify($new_password, $user['password'])) {
$errors[] = "Mật khẩu mới phải khác mật khẩu hiện tại";
} else {
// ...phần cập nhật giữ nguyên...
}
Vì sao dùng password_verify chứ không so trực tiếp $new_password === $current_password?
Cách so trực tiếp cũng chạy trong trường hợp thông thường. Nhưng password_verify mạnh hơn ở chỗ nó so mật khẩu mới với chuỗi băm đang lưu, chứ không phải với thứ người dùng vừa gõ vào ô "mật khẩu hiện tại".
Sự khác biệt xuất hiện nếu sau này bạn cho phép đổi mật khẩu qua đường khác — ví dụ khôi phục bằng email, nơi không có ô "mật khẩu hiện tại". Khi đó chỉ password_verify mới kiểm tra được.
Nguyên tắc: luôn so với nguồn sự thật, không so với dữ liệu người dùng vừa gửi.
Đáp án bài tập 3
📄 Models/User.php — thêm phương thức (đây chính là phương thức có thật ở Bài 21)
function checkEnrollment($user_id, $course_id)
{
$sql = "SELECT * FROM enrollments WHERE user_id = ? AND course_id = ?";
$result = $this->db->queryOne($sql, $user_id, $course_id);
return $result ? true : false;
}
📄 Controllers/LearnController.php — bản đã vá
<?php
class LearnController
{
public function detail($id)
{
// Kiểm tra xem user đã đăng nhập chưa
if (!isset($_SESSION['user_id'])) {
header("Location: ?ctrl=user&act=login");
exit();
}
include_once "./Models/Courses.php";
$coursesModel = new Courses();
$courseDetail = $coursesModel->getById($id);
if (!$courseDetail) {
$_SESSION['error'] = "Khóa học không tồn tại!";
header("Location: ?ctrl=course&act=list");
exit();
}
// BẢN VÁ LỖI SỐ 11: kiểm tra đã mua khóa học chưa
include_once "./Models/User.php";
$userModel = new User();
if (!$userModel->checkEnrollment($_SESSION['user_id'], $id)) {
$_SESSION['error'] = "Bạn chưa đăng ký khóa học này!";
header("Location: ?ctrl=course&act=detail&id=" . $id);
exit();
}
include_once "./Models/Lession.php";
$lessionModel = new Lession();
$lessionsList = $lessionModel->getAllByCourse($id);
include_once "./Views/learn_detail.php";
}
}
Kiểm chứng: đăng nhập bằng tài khoản mới chưa mua gì, gõ ?ctrl=learn&act=detail&id=1. Bạn bị đá về trang chi tiết khóa học.
Đăng nhập bằng tài khoản đã có dòng trong enrollments, vào lại — học được bình thường.
Hai điều đáng học từ bản vá này:
Thứ nhất, thứ tự kiểm tra rất quan trọng. Ta kiểm tra theo trình tự:
- Đã đăng nhập chưa
- Khóa học có tồn tại không
- Đã mua chưa
Không thể đảo. Kiểm tra "đã mua chưa" trước khi biết khóa học có tồn tại không là vô nghĩa.
Đây là nguyên tắc chung: kiểm tra từ điều kiện rộng nhất tới hẹp nhất.
Thứ hai, đây là ví dụ về việc lỗ hổng nằm ở chỗ "thiếu", không phải chỗ "sai".
Code gốc không có dòng nào viết sai. Mọi thứ đều chạy đúng như nó được viết. Vấn đề là thiếu một lớp kiểm tra mà lẽ ra phải có.
Loại lỗi này khó phát hiện nhất, vì không có thông báo lỗi nào, không có test nào thất bại, và tính năng "hoạt động bình thường". Chỉ khi bạn tự hỏi "ai được phép làm việc này?" thì mới lộ ra.
Một câu hỏi để bạn tự trả lời: giảng viên có nên vào học được khóa học của chính mình không? Với bản vá trên thì không — vì họ không có dòng trong enrollments. Bạn nghĩ đó là đúng hay sai, và nếu sai thì sửa thế nào?
➡️ Bài tiếp theo: Bài 17 — Khu vực giảng viên
⬅️ Về mục lục · Bài trước
All rights reserved