المشروع: نظام المدونة - الجزء الثاني
في الدرس السابق، قمنا ببناء الهيكل الأساسي للمدونة. ويضيف هذا الدرس اللمسات النهائية —التحرير، والحذف، والتعليقات، وترقيم الصفحات، وتعزيز الأمان— ليحولها إلى مشروع جاهز بالفعل للنشر والعرض.
1. التحرير اللاحق
(1) إضافة طرق إلى نموذج «Post»
PHP
<?php
// Add these methods to app/Models/Post.php
public function findByUser(int $id, int $userId): ?array
{
$stmt = $this->db->prepare(
"SELECT * FROM posts WHERE id = :id AND user_id = :userId"
);
$stmt->execute(['id' => $id, 'userId' => $userId]);
$post = $stmt->fetch();
return $post ?: null;
}
public function update(int $id, int $userId, string $title, string $content): bool
{
$stmt = $this->db->prepare(
"UPDATE posts SET title = :title, content = :content
WHERE id = :id AND user_id = :userId"
);
$stmt->execute([
'id' => $id,
'userId' => $userId,
'title' => $title,
'content' => $content,
]);
return $stmt->rowCount() > 0;
}
public function delete(int $id, int $userId): bool
{
$stmt = $this->db->prepare(
"DELETE FROM posts WHERE id = :id AND user_id = :userId"
);
$stmt->execute(['id' => $id, 'userId' => $userId]);
return $stmt->rowCount() > 0;
}
public function getByUser(int $userId): array
{
$stmt = $this->db->prepare(
"SELECT * FROM posts WHERE user_id = :userId ORDER BY created_at DESC"
);
$stmt->execute(['userId' => $userId]);
return $stmt->fetchAll();
}
(2) طرق وحدة التحكم المقابلة
PHP
<?php
// Add these methods to PostController
/** Edit form */
public function editForm(array $params): string
{
$this->requireLogin();
$post = $this->post->findByUser((int)$params['id'], $_SESSION['user']['id']);
if (!$post) {
http_response_code(404);
return '<h2>Post not found or you do not have permission to edit it</h2>';
}
ob_start();
require __DIR__ . '/../../views/posts/edit.php';
return ob_get_clean();
}
/** Handle editing */
public function edit(array $params): string
{
$this->requireLogin();
$userId = $_SESSION['user']['id'];
$id = (int)$params['id'];
$title = trim($_POST['title'] ?? '');
$content = trim($_POST['content'] ?? '');
if ($title === '' || $content === '') {
return '<p style="color:red">Title and content cannot be empty</p><a href="javascript:history.back()">Go back</a>';
}
$success = $this->post->update($id, $userId, $title, $content);
if (!$success) {
return '<p style="color:red">Post not found or you do not have permission to edit it</p>';
}
header("Location: /posts/{$id}");
exit;
}
/** Delete a post */
public function delete(array $params): void
{
$this->requireLogin();
$userId = $_SESSION['user']['id'];
$id = (int)$params['id'];
$this->post->delete($id, $userId);
header("Location: /posts/my");
exit;
}
/** My posts */
public function myPosts(): string
{
$this->requireLogin();
$posts = $this->post->getByUser($_SESSION['user']['id']);
ob_start();
require __DIR__ . '/../../views/posts/my.php';
return ob_get_clean();
}
2. وظيفة التعليقات
(1) إنشاء جدول «التعليقات»
▶ مثال: تنفيذ التعليقات
SQL
CREATE TABLE comments (
id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL,
user_id INT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
(2) نموذج التعليق
PHP
<?php
// app/Models/Comment.php
namespace App\Models;
use App\Core\Database;
use PDO;
class Comment
{
private PDO $db;
public function __construct()
{
$this->db = Database::getInstance();
}
public function getByPost(int $postId): array
{
$stmt = $this->db->prepare(
"SELECT c.*, u.username
FROM comments c
JOIN users u ON c.user_id = u.id
WHERE c.post_id = :postId
ORDER BY c.created_at ASC"
);
$stmt->execute(['postId' => $postId]);
return $stmt->fetchAll();
}
public function create(int $postId, int $userId, string $content): int
{
$stmt = $this->db->prepare(
"INSERT INTO comments (post_id, user_id, content)
VALUES (:post_id, :user_id, :content)"
);
$stmt->execute([
'post_id' => $postId,
'user_id' => $userId,
'content' => $content,
]);
return (int)$this->db->lastInsertId();
}
}
(3) صفحة تفاصيل المنشور — مع عرض التعليقات
PHP
<?php
// views/posts/show.php (improved version)
$commentModel = new \App\Models\Comment();
$comments = $commentModel->getByPost($post['id']);
$title = $post['title'];
ob_start();
?>
<article>
<h2><?= htmlspecialchars($post['title']) ?></h2>
<div class="post-meta">
Author: <?= htmlspecialchars($post['username']) ?> |
<?= $post['created_at'] ?>
</div>
<div style="line-height:1.8;margin:20px 0">
<?= nl2br(htmlspecialchars($post['content'])) ?>
</div>
<?php if (isset($_SESSION['user']) && $_SESSION['user']['id'] === (int)$post['user_id']): ?>
<p>
<a href="/posts/<?= $post['id'] ?>/edit">Edit</a> |
<a href="/posts/<?= $post['id'] ?>/delete" onclick="return confirm('Confirm deletion?')">Delete</a>
</p>
<?php endif; ?>
</article>
<!-- Comment List -->
<h3>Comments (<?= count($comments) ?>)</h3>
<?php foreach ($comments as $c): ?>
<div style="border-bottom:1px solid #eee;padding:10px 0">
<strong><?= htmlspecialchars($c['username']) ?></strong>
<span style="color:#999;font-size:12px">
<?= date('Y-m-d H:i', strtotime($c['created_at'])) ?>
</span>
<p><?= nl2br(htmlspecialchars($c['content'])) ?></p>
</div>
<?php endforeach; ?>
<!-- Post a Comment -->
<?php if (isset($_SESSION['user'])): ?>
<h4>Leave a Comment</h4>
<form method="POST" action="/posts/<?= $post['id'] ?>/comment">
<textarea name="content" rows="3" required placeholder="Write your comment..."></textarea>
<button type="submit" style="margin-top:8px">Submit</button>
</form>
<?php else: ?>
<p><a href="/login">Log in</a> to leave a comment</p>
<?php endif; ?>
<p style="margin-top:20px"><a href="/posts">← Back to posts</a></p>
<?php $content = ob_get_clean(); require __DIR__ . '/../layout.php'; ?>
3. ترقيم الصفحات
PHP
<?php
// Post model: paginated queries
public function getPaginated(int $page = 1, int $perPage = 10): array
{
$offset = ($page - 1) * $perPage;
$stmt = $this->db->prepare(
"SELECT p.*, u.username
FROM posts p
JOIN users u ON p.user_id = u.id
WHERE p.status = 'published'
ORDER BY p.created_at DESC
LIMIT :limit OFFSET :offset"
);
// PDO LIMIT/OFFSET must use bindValue with explicit types
$stmt->bindValue(':limit', $perPage, \PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, \PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
public function count(): int
{
return (int)$this->db->query(
"SELECT COUNT(*) FROM posts WHERE status = 'published'"
)->fetchColumn();
}
▶ مثال: مكون ترقيم الصفحات
PHP
<?php
// Pagination HTML component
function renderPagination(int $currentPage, int $totalPages, string $baseUrl): string
{
if ($totalPages <= 1) return '';
$html = '<div style="display:flex;gap:8px;margin-top:20px">';
for ($i = 1; $i <= $totalPages; $i++) {
$active = $i === $currentPage ? 'background:#4a90d9;color:white' : '';
$html .= "<a href='{$baseUrl}?page={$i}'
style='padding:6px 12px;border:1px solid #ddd;border-radius:4px;
text-decoration:none;{$active}'>{$i}</a>";
}
$html .= '</div>';
return $html;
}
4. قائمة مراجعة لتعزيز الأمان
PHP
// Security hardening summary
// 1. Escape all output with htmlspecialchars
echo htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8');
// 2. Use prepared statements for all database operations
$stmt = $pdo->prepare("UPDATE posts SET title = :t WHERE id = :id AND user_id = :uid");
$stmt->execute(['t' => $title, 'id' => $id, 'uid' => $_SESSION['user']['id']]);
// 3. Verify permissions on sensitive operations (not just hiding buttons—validate on the backend)
public function delete(array $params): void
{
$this->requireLogin();
// ✅ Verify the current user is the post author
$this->post->delete((int)$params['id'], $_SESSION['user']['id']);
}
// 4. Session security configuration
ini_set('session.cookie_httponly', '1');
ini_set('session.cookie_secure', '1'); // Enable when using HTTPS
ini_set('session.cookie_samesite', 'Lax');
// 5. Never store plain-text passwords
$hash = password_hash($password, PASSWORD_DEFAULT);
// 6. CSRF Token (simplified version)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($_POST['_token'] !== $_SESSION['_token']) {
die('CSRF attack blocked');
}
}
// In forms: <input type="hidden" name="_token" value="<?= $_SESSION['_token'] ?>">
5. قائمة مراجعة النشر
TEXT
Pre-deployment checklist:
□ display_errors = Off (production)
□ error_log path is writable
□ Database password is not root/empty
□ session.cookie_secure = 1 (if using HTTPS)
□ Upload directory permissions are correct (PHP execution disallowed)
□ .env file is not web-accessible (store outside the web root)
□ SSL certificate configured (Let's Encrypt is free)
□ robots.txt and sitemap.xml generated
❓ أسئلة شائعة
س مع ازدياد الميزات في نظام المدونة، أصبح الكود غير منظم. ماذا نفعل الآن؟
ج هذه هي بالضبط المشكلة التي تحلها أطر العمل (Laravel/Symfony). أنت الآن تفهم مفاهيم الموجه (Router) والمُحكِّم (Controller) والنموذج (Model) والعرض (View). عندما تتعلم Laravel في الدرس التالي، ستجد نفسك تفكر: «أليس هذا مجرد نسخة أكثر أناقة مما قمت ببنائه يدويًّا؟»
س كيف يمكنني منع التعليقات غير المرغوب فيها؟
ج حلول تدريجية: (1) اشتراط تسجيل الدخول للتعليق (الحل الأبسط)؛ (2) إضافة التحقق عبر reCAPTCHA (مجاني)؛ (3) مراقبة المحتوى (تصفية الكلمات الحساسة + المراجعة اليدوية)؛ (4) تحديد معدل التعليقات (لا يمكن للمستخدم نفسه التعليق مرة أخرى خلال 60 ثانية).
❓ أسئلة شائعة
س لماذا أحتاج إلى حماية CSRF في لوحة إدارة مدونتي؟
ج بدون حماية CSRF (تزوير الطلبات عبر المواقع)، يمكن للمهاجم إنشاء رابط أو نموذج ضار يقوم بتنفيذ إجراءات إدارية باستخدام جلسة تسجيل الدخول الخاصة بك. الحل: إنشاء رمز عشوائي يتم تخزينه في الجلسة، وإدراجه كحقل مخفي في كل نموذج إداري، والتحقق منه من جانب الخادم قبل معالجة الطلب.
📖 ملخص
- يجب أن تتضمن عمليات التحرير/الحذف التحقق من الأذونات (
WHERE user_id = :uid) - وظيفة التعليقات: طبقة النموذج تتولى عمليات CRUD → وحدة التحكم تنسق العمليات → وحدة العرض تعرض النتائج
- ترقيم الصفحات:
LIMIT :limit OFFSET :offset، استخدمbindValueمعPARAM_INT - تعزيز الأمان: تصفية المخرجات + العبارات المُعدة مسبقًا + التحقق من أذونات الخادم الخلفي + تكوين أمان الجلسة
- قائمة التحقق من النشر: تعطيل خيار display_errors، وتمكين HTTPS، والتحقق من أذونات الملفات
📝 تمارين
- إضافة وظيفتي «تحرير المنشور» و«حذف المنشور» إلى نظام المدونة (لا يمكن إلا للمؤلف تنفيذ هذين الإجراءين).
- تفعيل ميزة التعليقات: تعرض صفحة تفاصيل المنشور قائمة بالتعليقات، كما يمكن للمستخدمين المسجلين نشر التعليقات.
- إضافة ترقيم الصفحات إلى قائمة المنشورات (5 منشورات في كل صفحة)، مع دعم ترقيم الصفحات في كل من الصفحة الرئيسية وصفحة قائمة المنشورات.



