From 2a5bdcbc4ed46ca6467d47b920fe7435607d582a Mon Sep 17 00:00:00 2001
From: zhang-pu <4738@163.com>
Date: Mon, 1 Jun 2026 10:08:11 +0800
Subject: [PATCH] =?UTF-8?q?=E9=A6=96=E6=AC=A1=E4=B8=8A=E4=BC=A0=20tblog=20?=
=?UTF-8?q?=E5=8D=9A=E5=AE=A2=E9=A1=B9=E7=9B=AE=E5=AE=8C=E6=95=B4=E4=BB=A3?=
=?UTF-8?q?=E7=A0=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
包含后台管理、前端页面和数据库文件
---
admin/assets/admin.css | 365 +++++++++++++++++
admin/category.php | 169 ++++++++
admin/comment.php | 158 ++++++++
admin/index.php | 99 +++++
admin/login.php | 135 +++++++
admin/logout.php | 9 +
admin/post.php | 379 +++++++++++++++++
admin/settings.php | 153 +++++++
include/HTMLPurifier.simple.php | 121 ++++++
include/db.php | 16 +
include/function.category.php | 56 +++
include/function.comment.php | 103 +++++
include/function.common.php | 251 ++++++++++++
include/function.post.php | 139 +++++++
include/router.php | 70 ++++
static/css/style.css | 21 +
static/js/script.js | 26 ++
templates/flow/archive.php | 94 +++++
templates/flow/category.php | 110 +++++
templates/flow/footer.php | 35 ++
templates/flow/header.php | 42 ++
templates/flow/index.php | 89 ++++
templates/flow/post.php | 108 +++++
templates/flow/static/css/style.css | 543 +++++++++++++++++++++++++
templates/magine/category.php | 110 +++++
templates/magine/footer.php | 35 ++
templates/magine/header.php | 40 ++
templates/magine/index.php | 89 ++++
templates/magine/post.php | 107 +++++
templates/magine/static/css/style.css | 561 ++++++++++++++++++++++++++
30 files changed, 4233 insertions(+)
create mode 100644 admin/assets/admin.css
create mode 100644 admin/category.php
create mode 100644 admin/comment.php
create mode 100644 admin/index.php
create mode 100644 admin/login.php
create mode 100644 admin/logout.php
create mode 100644 admin/post.php
create mode 100644 admin/settings.php
create mode 100644 include/HTMLPurifier.simple.php
create mode 100644 include/db.php
create mode 100644 include/function.category.php
create mode 100644 include/function.comment.php
create mode 100644 include/function.common.php
create mode 100644 include/function.post.php
create mode 100644 include/router.php
create mode 100644 static/css/style.css
create mode 100644 static/js/script.js
create mode 100644 templates/flow/archive.php
create mode 100644 templates/flow/category.php
create mode 100644 templates/flow/footer.php
create mode 100644 templates/flow/header.php
create mode 100644 templates/flow/index.php
create mode 100644 templates/flow/post.php
create mode 100644 templates/flow/static/css/style.css
create mode 100644 templates/magine/category.php
create mode 100644 templates/magine/footer.php
create mode 100644 templates/magine/header.php
create mode 100644 templates/magine/index.php
create mode 100644 templates/magine/post.php
create mode 100644 templates/magine/static/css/style.css
diff --git a/admin/assets/admin.css b/admin/assets/admin.css
new file mode 100644
index 0000000..28e7eb8
--- /dev/null
+++ b/admin/assets/admin.css
@@ -0,0 +1,365 @@
+* { margin: 0; padding: 0; box-sizing: border-box; }
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ background: #f5f7fa;
+ color: #333;
+}
+
+.admin-container {
+ display: flex;
+ min-height: 100vh;
+}
+
+.sidebar {
+ width: 240px;
+ background: #1a1a2e;
+ color: #fff;
+ padding: 20px 0;
+ position: fixed;
+ height: 100vh;
+ overflow-y: auto;
+}
+
+.logo {
+ font-size: 20px;
+ font-weight: 700;
+ padding: 0 20px 20px;
+ border-bottom: 1px solid rgba(255,255,255,0.1);
+ margin-bottom: 20px;
+}
+
+.sidebar nav a {
+ display: block;
+ padding: 12px 20px;
+ color: rgba(255,255,255,0.7);
+ text-decoration: none;
+ transition: all 0.3s;
+}
+
+.sidebar nav a:hover,
+.sidebar nav a.active {
+ background: rgba(34, 197, 94, 0.2);
+ color: #22c55e;
+}
+
+.content {
+ flex: 1;
+ margin-left: 240px;
+ padding: 30px;
+}
+
+h1 {
+ font-size: 28px;
+ margin-bottom: 30px;
+ color: #1a1a2e;
+}
+
+h2 {
+ font-size: 20px;
+ margin: 30px 0 15px;
+ color: #333;
+}
+
+.stats {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 20px;
+ margin-bottom: 30px;
+}
+
+.stat-card {
+ background: #fff;
+ padding: 24px;
+ border-radius: 12px;
+ display: flex;
+ align-items: center;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.05);
+}
+
+.stat-icon {
+ font-size: 40px;
+ margin-right: 20px;
+}
+
+.stat-value {
+ font-size: 32px;
+ font-weight: 700;
+ color: #22c55e;
+}
+
+.stat-label {
+ color: #666;
+ font-size: 14px;
+}
+
+.table {
+ width: 100%;
+ background: #fff;
+ border-radius: 12px;
+ overflow: hidden;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.05);
+ border-collapse: collapse;
+}
+
+.table th,
+.table td {
+ padding: 16px 20px;
+ text-align: left;
+ border-bottom: 1px solid #eee;
+}
+
+.table th {
+ background: #f8f9fa;
+ font-weight: 600;
+ color: #555;
+}
+
+.table tr:last-child td {
+ border-bottom: none;
+}
+
+.badge {
+ display: inline-block;
+ padding: 4px 10px;
+ border-radius: 20px;
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.badge-success {
+ background: #dcfce7;
+ color: #16a34a;
+}
+
+.badge-draft {
+ background: #fef3c7;
+ color: #d97706;
+}
+
+.badge-pending {
+ background: #fef3c7;
+ color: #d97706;
+}
+
+.badge-approved {
+ background: #dcfce7;
+ color: #16a34a;
+}
+
+.badge-rejected {
+ background: #fee2e2;
+ color: #dc2626;
+}
+
+.btn {
+ display: inline-block;
+ padding: 8px 16px;
+ background: #22c55e;
+ color: #fff;
+ text-decoration: none;
+ border-radius: 6px;
+ font-size: 14px;
+ transition: background 0.3s;
+ border: none;
+ cursor: pointer;
+}
+
+.btn:hover {
+ background: #16a34a;
+}
+
+.btn-sm {
+ padding: 6px 12px;
+ font-size: 12px;
+}
+
+.btn-primary {
+ background: #22c55e;
+}
+
+.btn-danger {
+ background: #dc2626;
+}
+
+.btn-danger:hover {
+ background: #b91c1c;
+}
+
+.btn-link {
+ background: transparent;
+ color: #22c55e;
+}
+
+.btn-link:hover {
+ background: #f0fdf4;
+}
+
+.actions {
+ margin-top: 20px;
+}
+
+.form-group {
+ margin-bottom: 20px;
+}
+
+label {
+ display: block;
+ margin-bottom: 8px;
+ font-weight: 500;
+ color: #333;
+}
+
+input[type="text"],
+input[type="url"],
+input[type="number"],
+input[type="password"],
+textarea,
+select {
+ width: 100%;
+ padding: 12px 16px;
+ border: 2px solid #e5e5e5;
+ border-radius: 8px;
+ font-size: 14px;
+ transition: border-color 0.3s;
+ font-family: inherit;
+}
+
+input:focus,
+textarea:focus,
+select:focus {
+ outline: none;
+ border-color: #22c55e;
+}
+
+textarea {
+ min-height: 120px;
+ resize: vertical;
+}
+
+.form-row {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 20px;
+}
+
+.form-actions {
+ display: flex;
+ gap: 10px;
+ margin-top: 20px;
+}
+
+.back-link {
+ margin-bottom: 20px;
+}
+
+.back-link a {
+ color: #666;
+ text-decoration: none;
+ font-size: 14px;
+}
+
+.back-link a:hover {
+ color: #22c55e;
+}
+
+.success {
+ background: #dcfce7;
+ color: #16a34a;
+ padding: 12px 16px;
+ border-radius: 8px;
+ margin-bottom: 20px;
+}
+
+.error {
+ background: #fee2e2;
+ color: #dc2626;
+ padding: 12px 16px;
+ border-radius: 8px;
+ margin-bottom: 20px;
+}
+
+.search-box {
+ margin-bottom: 20px;
+}
+
+.search-box input {
+ max-width: 300px;
+}
+
+.status-toggle {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 20px;
+}
+
+.status-toggle label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ cursor: pointer;
+}
+
+.switch {
+ position: relative;
+ width: 48px;
+ height: 26px;
+}
+
+.switch input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.slider {
+ position: absolute;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: #ccc;
+ transition: 0.3s;
+ border-radius: 26px;
+}
+
+.slider:before {
+ position: absolute;
+ content: "";
+ height: 20px;
+ width: 20px;
+ left: 3px;
+ bottom: 3px;
+ background: white;
+ transition: 0.3s;
+ border-radius: 50%;
+}
+
+input:checked + .slider {
+ background: #22c55e;
+}
+
+input:checked + .slider:before {
+ transform: translateX(22px);
+}
+
+@media (max-width: 768px) {
+ .sidebar {
+ width: 60px;
+ }
+ .logo {
+ font-size: 14px;
+ padding: 0 10px 10px;
+ }
+ .sidebar nav a {
+ padding: 12px 10px;
+ text-align: center;
+ }
+ .sidebar nav a span {
+ display: none;
+ }
+ .content {
+ margin-left: 60px;
+ }
+}
diff --git a/admin/category.php b/admin/category.php
new file mode 100644
index 0000000..17c3c3c
--- /dev/null
+++ b/admin/category.php
@@ -0,0 +1,169 @@
+ trim($_POST['name'] ?? ''),
+ 'slug' => trim($_POST['slug'] ?? ''),
+ 'description' => trim($_POST['description'] ?? ''),
+ 'parent_id' => intval($_POST['parent_id'] ?? 0),
+ 'order_num' => intval($_POST['order_num'] ?? 0)
+ ];
+
+ if (empty($data['name']) || empty($data['slug'])) {
+ $message = '
名称和别名不能为空
';
+ } else {
+ if (!empty($_POST['id'])) {
+ update_category(intval($_POST['id']), $data);
+ $message = '分类已更新
';
+ } else {
+ create_category($data);
+ $message = '分类已创建
';
+ }
+ }
+}
+
+// Load category for editing
+if (!empty($_GET['edit'])) {
+ $edit_category = get_category_by_id(intval($_GET['edit']));
+}
+
+// Handle delete
+if (!empty($_GET['delete'])) {
+ $count = get_category_count(intval($_GET['delete']));
+ if ($count > 0) {
+ $message = '该分类下有 ' . $count . ' 篇文章,无法删除
';
+ } else {
+ delete_category(intval($_GET['delete']));
+ $message = '分类已删除
';
+ }
+}
+
+// Get all categories
+$categories = get_categories();
+?>
+
+
+
+
+
+ 分类管理 - ZhangPu Blog
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 分类管理
+
+
+
+ | 名称 |
+ 别名 |
+ 描述 |
+ 文章数 |
+ 排序 |
+ 操作 |
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+
+ 编辑
+ 删除
+ |
+
+
+
+
+ | 暂无分类 |
+
+
+
+
+
+
+
+
+
diff --git a/admin/comment.php b/admin/comment.php
new file mode 100644
index 0000000..8c09c2b
--- /dev/null
+++ b/admin/comment.php
@@ -0,0 +1,158 @@
+ 0) {
+ if ($_POST['action'] === 'approve') {
+ update_comment_status($id, 'approved');
+ $message = '评论已通过
';
+ } elseif ($_POST['action'] === 'reject') {
+ update_comment_status($id, 'rejected');
+ $message = '评论已拒绝
';
+ } elseif ($_POST['action'] === 'delete') {
+ delete_comment($id);
+ $message = '评论已删除
';
+ }
+ }
+}
+
+// Filter
+$status_filter = $_GET['status'] ?? null;
+$comments = get_all_comments($status_filter);
+?>
+
+
+
+
+
+ 评论管理 - ZhangPu Blog
+
+
+
+
+
+
+
+
+
+
+ 评论管理
+
+
+
+
+
+
+ | 文章 |
+ 作者 |
+ 内容 |
+ 状态 |
+ 时间 |
+ 操作 |
+
+
+
+
+
+ |
+
+
+
+
+
+ |
+
+
+ |
+ |
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+ | 暂无评论 |
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/admin/index.php b/admin/index.php
new file mode 100644
index 0000000..cabd3e7
--- /dev/null
+++ b/admin/index.php
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+ 管理后台 - ZhangPu Blog
+
+
+
+
+
+
+ 管理后台
+
+
+
+ 最新文章
+
+
+
+
+ | 标题 |
+ 分类 |
+ 状态 |
+ 发布时间 |
+ 操作 |
+
+
+
+
+
+ |
+ |
+ |
+ |
+
+ 编辑
+ 查看
+ |
+
+
+
+
+
+
+
+
diff --git a/admin/login.php b/admin/login.php
new file mode 100644
index 0000000..5483199
--- /dev/null
+++ b/admin/login.php
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+ 登录 - ZhangPu Blog 管理后台
+
+
+
+
+
🌿 ZhangPu Blog 管理后台
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/admin/logout.php b/admin/logout.php
new file mode 100644
index 0000000..a03a4ba
--- /dev/null
+++ b/admin/logout.php
@@ -0,0 +1,9 @@
+ trim($_POST['title'] ?? ''),
+ 'content' => trim($_POST['content'] ?? ''),
+ 'excerpt' => trim($_POST['excerpt'] ?? ''),
+ 'slug' => trim($_POST['slug'] ?? '') ?: generate_slug($_POST['title'] ?? ''),
+ 'category_id' => intval($_POST['category_id'] ?? 1),
+ 'template' => $_POST['template'] ?? 'flow',
+ 'status' => $_POST['status'] ?? 'published'
+ ];
+
+ // Sanitize rich-text content to prevent XSS
+ $data['content'] = sanitize_html($data['content']);
+
+ if (empty($data['title']) || empty($data['content'])) {
+ $message = '标题和内容不能为空
';
+ } else {
+ // Check slug uniqueness (only if slug changed or new post)
+ $existing = null;
+ if (!empty($_POST['id'])) {
+ $existing = get_post_by_id(intval($_POST['id']));
+ }
+ $slug_check = $data['slug'];
+ $db = get_db();
+ $stmt = $db->prepare("SELECT id FROM posts WHERE slug = ? AND deleted_at IS NULL AND id != ?");
+ $stmt->bind_param('si', $slug_check, $existing['id'] ?? 0);
+ $stmt->execute();
+ $res = $stmt->get_result();
+ if ($res->num_rows > 0) {
+ $data['slug'] = $data['slug'] . '-' . time();
+ }
+
+ if (!empty($_POST['id'])) {
+ update_post(intval($_POST['id']), $data);
+ $message = '文章已更新
';
+ } else {
+ create_post($data);
+ $message = '文章已创建
';
+ }
+ }
+}
+
+if (!empty($_GET['edit'])) {
+ $edit_post = get_post_by_id(intval($_GET['edit']));
+}
+
+if (!empty($_GET['delete'])) {
+ csrf_verify('BOTH');
+ delete_post(intval($_GET['delete']));
+ $message = '文章已删除
';
+}
+
+$categories = get_categories();
+?>
+
+
+
+
+
+ 文章管理 - ZhangPu Blog
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/admin/settings.php b/admin/settings.php
new file mode 100644
index 0000000..6d81227
--- /dev/null
+++ b/admin/settings.php
@@ -0,0 +1,153 @@
+当前密码错误';
+ } else {
+ if (!empty($new_username)) {
+ update_setting('admin_user', $new_username);
+ }
+ if (!empty($new_password)) {
+ set_admin_password($new_password);
+ }
+ $message = '账号信息已更新(下次登录生效)
';
+ }
+ } else {
+ // Site settings
+ update_setting('site_name', trim($_POST['site_name'] ?? ''));
+ update_setting('site_description', trim($_POST['site_description'] ?? ''));
+ update_setting('template', $_POST['template'] ?? 'flow');
+ update_setting('posts_per_page', max(1, intval($_POST['posts_per_page'] ?? 10)));
+ $message = '设置已保存
';
+ }
+}
+
+// Load settings
+$site_name = get_setting('site_name', '张璞博客');
+$site_description = get_setting('site_description', '一个简洁优雅的博客');
+$template = get_setting('template', 'flow');
+$posts_per_page = get_setting('posts_per_page', '10');
+$admin_user = get_admin_user();
+?>
+
+
+
+
+
+ 设置 - ZhangPu Blog
+
+
+
+
+
+
\ No newline at end of file
diff --git a/include/HTMLPurifier.simple.php b/include/HTMLPurifier.simple.php
new file mode 100644
index 0000000..a76469d
--- /dev/null
+++ b/include/HTMLPurifier.simple.php
@@ -0,0 +1,121 @@
+/**
+ * Lightweight HTML sanitizer using DOMDocument + blacklist
+ * For production use, consider installing HTMLPurifier via Composer.
+ */
+function sanitize_html($html) {
+ if (empty($html)) return '';
+
+ // Step 1: Remove script, style, and dangerous tags via regex pre-clean
+ $html = preg_replace('#<(script|style|iframe|object|embed|form|input|button)[^>]*>.*?\1>#is', '', $html);
+ $html = preg_replace('/<[^>]+\s+on\w+\s*=\s*["\'][^"\']*["\']/i', '', $html); // remove on* attributes
+ $html = preg_replace('/<[^>]+\s+style\s*=\s*["\'][^"\']*["\']/i', '', $html); // remove style attrs
+
+ // Step 2: Use DOMDocument to re-serialize (normalizes malformed HTML)
+ libxml_use_internal_errors(true);
+ $dom = new DOMDocument();
+ $dom->loadHTML('' . "\n" . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
+ libxml_clear_errors();
+
+ // Step 3: Walk all elements and enforce whitelist
+ $allowed_tags = [
+ 'h1','h2','h3','h4','h5','h6',
+ 'p','br','strong','em','u','s','del','sup','sub',
+ 'blockquote','pre','code',
+ 'ul','ol','li',
+ 'a','img',
+ 'table','thead','tbody','tr','th','td',
+ 'hr','span',
+ 'div','section','article','header','footer',
+ ];
+ $allowed_attrs = [
+ 'href', 'target', 'src', 'alt', 'width', 'height', 'class', 'id',
+ ];
+
+ $xpath = new DOMXPath($dom);
+ $all_elements = $xpath->query('//*');
+ $to_remove = [];
+
+ foreach ($all_elements as $el) {
+ $tag = strtolower($el->nodeName);
+ // Remove disallowed tags entirely
+ if (!in_array($tag, $allowed_tags)) {
+ // Keep children, remove tag wrapper
+ foreach ($el->childNodes as $child) {
+ $el->parentNode->insertBefore($child->cloneNode(true), $el);
+ }
+ $to_remove[] = $el;
+ continue;
+ }
+ // Remove disallowed attributes
+ $attrs_to_remove = [];
+ if ($el->attributes) {
+ foreach ($el->attributes as $attr) {
+ if (!in_array(strtolower($attr->nodeName), $allowed_attrs)) {
+ $attrs_to_remove[] = $attr;
+ }
+ }
+ foreach ($attrs_to_remove as $attr) {
+ $el->removeAttributeNode($attr);
+ }
+ }
+ // Sanitize href/src URLs (allow only http/https/data)
+ if ($el->hasAttribute('href')) {
+ $href = $el->getAttribute('href');
+ if (!preg_match('#^https?://|mailto:|tel:|data:#i', $href)) {
+ $el->removeAttribute('href');
+ }
+ }
+ if ($el->hasAttribute('src')) {
+ $src = $el->getAttribute('src');
+ if (!preg_match('#^https?://|data:#i', $src)) {
+ $el->removeAttribute('src');
+ }
+ }
+ }
+
+ foreach ($to_remove as $el) {
+ $el->parentNode->removeChild($el);
+ }
+
+ $body = $dom->getElementsByTagName('body')->item(0);
+ $output = '';
+ if ($body) {
+ foreach ($body->childNodes as $child) {
+ $output .= $dom->saveHTML($child);
+ }
+ }
+
+ return trim($output);
+}
+
+/**
+ * Auto-detect base URL from request
+ */
+function get_site_url() {
+ $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
+ $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
+ return $scheme . '://' . $host;
+}
+
+/**
+ * Rate limiting for comment submissions
+ */
+function rate_limit($key, $max_requests = 5, $window_seconds = 60) {
+ $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
+ $cache_file = '/tmp/rate_limit_' . md5($key . '_' . $ip) . '.json';
+ $now = time();
+ $data = [];
+ if (file_exists($cache_file)) {
+ $data = json_decode(file_get_contents($cache_file), true) ?: [];
+ }
+ // Remove expired entries
+ $data = array_filter($data, function($ts) use ($now, $window_seconds) {
+ return ($now - $ts) < $window_seconds;
+ });
+ if (count($data) >= $max_requests) {
+ return false;
+ }
+ $data[] = $now;
+ file_put_contents($cache_file, json_encode($data));
+ return true;
+}
\ No newline at end of file
diff --git a/include/db.php b/include/db.php
new file mode 100644
index 0000000..2dcec8b
--- /dev/null
+++ b/include/db.php
@@ -0,0 +1,16 @@
+connect_error) {
+ die('数据库连接失败: ' . $db->connect_error);
+ }
+ $db->set_charset('utf8mb4');
+ }
+ return $db;
+}
diff --git a/include/function.category.php b/include/function.category.php
new file mode 100644
index 0000000..e394710
--- /dev/null
+++ b/include/function.category.php
@@ -0,0 +1,56 @@
+query("SELECT * FROM categories ORDER BY order_num ASC, id ASC");
+ return $result->fetch_all(MYSQLI_ASSOC);
+}
+
+function get_category_by_slug($slug) {
+ $db = get_db();
+ $stmt = $db->prepare("SELECT * FROM categories WHERE slug = ?");
+ $stmt->bind_param('s', $slug);
+ $stmt->execute();
+ return $stmt->get_result()->fetch_assoc();
+}
+
+function get_category_by_id($id) {
+ $db = get_db();
+ $stmt = $db->prepare("SELECT * FROM categories WHERE id = ?");
+ $stmt->bind_param('i', $id);
+ $stmt->execute();
+ return $stmt->get_result()->fetch_assoc();
+}
+
+function create_category($data) {
+ $db = get_db();
+ $stmt = $db->prepare("INSERT INTO categories (name, slug, description, parent_id, order_num) VALUES (?, ?, ?, ?, ?)");
+ $stmt->bind_param('sssii', $data['name'], $data['slug'], $data['description'], $data['parent_id'], $data['order_num']);
+ return $stmt->execute();
+}
+
+function update_category($id, $data) {
+ $db = get_db();
+ $stmt = $db->prepare("UPDATE categories SET name = ?, slug = ?, description = ?, parent_id = ?, order_num = ? WHERE id = ?");
+ $stmt->bind_param('sssiii', $data['name'], $data['slug'], $data['description'], $data['parent_id'], $data['order_num'], $id);
+ return $stmt->execute();
+}
+
+function delete_category($id) {
+ $db = get_db();
+ $stmt = $db->prepare("DELETE FROM categories WHERE id = ?");
+ $stmt->bind_param('i', $id);
+ return $stmt->execute();
+}
+
+function get_category_count($category_id) {
+ $db = get_db();
+ $stmt = $db->prepare("SELECT COUNT(*) FROM posts WHERE category_id = ? AND status = 'published' AND deleted_at IS NULL");
+ $stmt->bind_param('i', $category_id);
+ $stmt->execute();
+ $result = $stmt->get_result();
+ return $result->fetch_row()[0];
+}
diff --git a/include/function.comment.php b/include/function.comment.php
new file mode 100644
index 0000000..bbb330b
--- /dev/null
+++ b/include/function.comment.php
@@ -0,0 +1,103 @@
+prepare("SELECT * FROM comments WHERE post_id = ? AND status = 'approved' ORDER BY created_at ASC");
+ $stmt->bind_param('i', $post_id);
+ $stmt->execute();
+ return $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
+}
+
+function get_all_comments($status = null) {
+ $db = get_db();
+ if ($status) {
+ $stmt = $db->prepare("SELECT c.*, p.title as post_title, p.slug as post_slug
+ FROM comments c
+ LEFT JOIN posts p ON c.post_id = p.id
+ WHERE c.status = ?
+ ORDER BY c.created_at DESC");
+ $stmt->bind_param('s', $status);
+ $stmt->execute();
+ return $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
+ } else {
+ $result = $db->query("SELECT c.*, p.title as post_title, p.slug as post_slug
+ FROM comments c
+ LEFT JOIN posts p ON c.post_id = p.id
+ ORDER BY c.created_at DESC");
+ return $result->fetch_all(MYSQLI_ASSOC);
+ }
+}
+
+// Spam keywords (case-insensitive)
+function is_spam_content($content, $author = '', $url = '') {
+ $spam_keywords = [
+ 'casino', 'viagra', 'cialis', 'loan', 'mortgage', 'bitcoin',
+ 'cheap jerseys', 'nfl jerseys', 'michael kors outlet',
+ 'louis vuitton', 'ugg boots', 'pandora', 'tiffany',
+ 'viagra', 'cialis', 'levitra', 'cialis',
+ 'adult', 'porn', 'nude',
+ 'essay', 'dissertation', 'thesis',
+ 'backlink', 'sexxx', 'fuck',
+ ];
+
+ $check = strtolower($author . ' ' . $content . ' ' . $url);
+ foreach ($spam_keywords as $keyword) {
+ if (strpos($check, strtolower($keyword)) !== false) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function create_comment($data) {
+ // Rate limiting: max 5 comments per minute per IP
+ if (!rate_limit('comment', 5, 60)) {
+ return false;
+ }
+
+ // Honeypot check
+ if (!empty($data['website'])) {
+ return false; // Bot detected
+ }
+
+ // Keyword spam check
+ if (is_spam_content($data['content'], $data['author'], $data['url'])) {
+ return false;
+ }
+
+ $db = get_db();
+ $ip = $_SERVER['REMOTE_ADDR'] ?? '';
+ $stmt = $db->prepare("INSERT INTO comments (post_id, author, email, url, content, ip, status) VALUES (?, ?, ?, ?, ?, ?, 'pending')");
+ $stmt->bind_param('isssss', $data['post_id'], $data['author'], $data['email'], $data['url'], $data['content'], $ip);
+ return $stmt->execute();
+}
+
+function update_comment_status($id, $status) {
+ $db = get_db();
+ $stmt = $db->prepare("UPDATE comments SET status = ? WHERE id = ?");
+ $stmt->bind_param('si', $status, $id);
+ return $stmt->execute();
+}
+
+function delete_comment($id) {
+ $db = get_db();
+ $stmt = $db->prepare("DELETE FROM comments WHERE id = ?");
+ $stmt->bind_param('i', $id);
+ return $stmt->execute();
+}
+
+function get_comment_count($status = null) {
+ $db = get_db();
+ if ($status) {
+ $stmt = $db->prepare("SELECT COUNT(*) FROM comments WHERE status = ?");
+ $stmt->bind_param('s', $status);
+ $stmt->execute();
+ return $stmt->get_result()->fetch_row()[0];
+ } else {
+ $result = $db->query("SELECT COUNT(*) FROM comments");
+ return $result->fetch_row()[0];
+ }
+}
\ No newline at end of file
diff --git a/include/function.common.php b/include/function.common.php
new file mode 100644
index 0000000..dcf0995
--- /dev/null
+++ b/include/function.common.php
@@ -0,0 +1,251 @@
+connect_error) {
+ die('数据库连接失败: ' . $db->connect_error);
+ }
+ $db->set_charset('utf8mb4');
+ }
+ return $db;
+}
+
+function e($str) {
+ return htmlspecialchars($str ?? '', ENT_QUOTES, 'UTF-8');
+}
+
+function redirect($url) {
+ header('Location: ' . $url);
+ exit;
+}
+
+function json_response($data, $code = 200) {
+ http_response_code($code);
+ header('Content-Type: application/json; charset=utf-8');
+ echo json_encode($data, JSON_UNESCAPED_UNICODE);
+ exit;
+}
+
+/**
+ * CSRF Protection
+ */
+function csrf_token() {
+ if (empty($_SESSION['csrf_token'])) {
+ $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
+ }
+ return $_SESSION['csrf_token'];
+}
+
+function csrf_field() {
+ return '';
+}
+
+function csrf_verify($method = 'POST') {
+ if ($method === 'POST' || $method === 'BOTH') {
+ $token = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
+ if (empty($token) || !hash_equals($_SESSION['csrf_token'] ?? '', $token)) {
+ http_response_code(403);
+ echo 'CSRF token mismatch';
+ exit;
+ }
+ }
+}
+
+/**
+ * Authentication
+ */
+function get_admin_user() {
+ return get_setting('admin_user', 'admin');
+}
+
+function get_admin_hash() {
+ return get_setting('admin_hash', '');
+}
+
+function verify_admin_password($password) {
+ $hash = get_admin_hash();
+ if (empty($hash)) {
+ // Fallback: no hash set yet, use legacy plain-text check
+ // After first settings save this branch is never reached
+ return false;
+ }
+ return password_verify($password, $hash);
+}
+
+function set_admin_password($password) {
+ $hash = password_hash($password, PASSWORD_DEFAULT);
+ update_setting('admin_hash', $hash);
+}
+
+function auth_check() {
+ if (empty($_SESSION['admin'])) {
+ redirect('login.php');
+ }
+}
+
+function auth_verify($username, $password) {
+ if ($username !== get_admin_user()) {
+ return false;
+ }
+ return verify_admin_password($password);
+}
+
+function get_setting($key, $default = '') {
+ $db = get_db();
+ $stmt = $db->prepare("SELECT value FROM settings WHERE `key` = ?");
+ $stmt->bind_param('s', $key);
+ $stmt->execute();
+ $result = $stmt->get_result();
+ if ($row = $result->fetch_assoc()) {
+ return $row['value'];
+ }
+ return $default;
+}
+
+function update_setting($key, $value) {
+ $db = get_db();
+ $stmt = $db->prepare("INSERT INTO settings (`key`, value) VALUES (?, ?) ON DUPLICATE KEY UPDATE value = VALUES(value)");
+ $stmt->bind_param('ss', $key, $value);
+ return $stmt->execute();
+}
+
+function time_ago($datetime) {
+ $timestamp = strtotime($datetime);
+ $diff = time() - $timestamp;
+ if ($diff < 60) return '刚刚';
+ if ($diff < 3600) return floor($diff / 60) . '分钟前';
+ if ($diff < 86400) return floor($diff / 3600) . '小时前';
+ if ($diff < 2592000) return floor($diff / 86400) . '天前';
+ return date('Y-m-d', $timestamp);
+}
+
+/**
+ * HTML sanitizer - prevent XSS in rich-text content
+ * Uses DOMDocument + whitelist approach (no external dependencies needed)
+ */
+function sanitize_html($html) {
+ if (empty($html)) return '';
+
+ // Step 1: Remove dangerous tags via regex pre-clean (before DOM parsing)
+ $html = preg_replace('#<(script|style|iframe|object|embed|form|input|button|select|textarea)[^>]*>.*?\1>#is', '', $html);
+
+ // Step 2: Remove event-handler attributes (onclick, onerror, etc.)
+ $html = preg_replace('/\s+on\w+\s*=\s*["\'][^"\']*["\']/i', '', $html);
+
+ // Step 3: Use DOMDocument to normalize and re-serialize
+ libxml_use_internal_errors(true);
+ $dom = new DOMDocument();
+ // Wrap in a div so we can use loadHTML on fragment
+ $dom->loadHTML('' . "\n" . '' . $html . '
', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
+ libxml_clear_errors();
+
+ // Whitelist of allowed tags and attributes
+ $allowed_tags = [
+ 'h1','h2','h3','h4','h5','h6',
+ 'p','br','hr',
+ 'strong','b','em','i','u','s','del','sup','sub','mark',
+ 'blockquote','pre','code',
+ 'ul','ol','li',
+ 'a','img',
+ 'table','thead','tbody','tfoot','tr','th','td',
+ 'div','span','section','article','header','footer','aside',
+ ];
+ $allowed_attrs = [
+ 'href', 'src', 'alt', 'title', 'width', 'height',
+ 'class', 'id', 'style',
+ ];
+
+ $xpath = new DOMXPath($dom);
+ $all = $xpath->query('//*');
+ $remove = [];
+
+ foreach ($all as $el) {
+ $tag = strtolower($el->nodeName);
+ if (!in_array($tag, $allowed_tags)) {
+ // Replace tag with its children
+ foreach ($el->childNodes as $child) {
+ $el->parentNode->insertBefore($child->cloneNode(true), $el);
+ }
+ $remove[] = $el;
+ continue;
+ }
+ // Filter attributes
+ $bad = [];
+ if ($el->attributes) {
+ foreach ($el->attributes as $attr) {
+ $name = strtolower($attr->nodeName);
+ if (!in_array($name, $allowed_attrs)) {
+ $bad[] = $attr;
+ continue;
+ }
+ // Sanitize URL attributes
+ if (in_array($name, ['href', 'src'])) {
+ $val = $attr->nodeValue;
+ if (!preg_match('#^(https?://|mailto:|tel:|data:)#i', $val)) {
+ // Remove javascript: and other dangerous protocols
+ if (preg_match('#^javascript:#i', $val)) {
+ $bad[] = $attr;
+ continue;
+ }
+ // For src/href, if it doesn't match safe schemes, clear it
+ if (!preg_match('#^https?://|data:#i', $val)) {
+ $bad[] = $attr;
+ }
+ }
+ }
+ }
+ foreach ($bad as $attr) {
+ $el->removeAttributeNode($attr);
+ }
+ }
+ }
+
+ foreach ($remove as $el) {
+ $el->parentNode->removeChild($el);
+ }
+
+ // Extract content from the wrapper div
+ $out = '';
+ foreach ($dom->getElementsByTagName('div')->item(0)->childNodes as $node) {
+ $out .= $dom->saveHTML($node);
+ }
+ return trim($out);
+}
+
+/**
+ * Rate limiting for comment submissions
+ */
+function rate_limit($key, $max_requests = 5, $window_seconds = 60) {
+ $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
+ $cache_file = '/tmp/rate_limit_' . md5($key . '_' . $ip) . '.json';
+ $now = time();
+ $data = [];
+ if (file_exists($cache_file)) {
+ $data = json_decode(file_get_contents($cache_file), true) ?: [];
+ }
+ $data = array_values(array_filter($data, function($ts) use ($now, $window_seconds) {
+ return ($now - $ts) < $window_seconds;
+ }));
+ if (count($data) >= $max_requests) {
+ return false;
+ }
+ $data[] = $now;
+ @file_put_contents($cache_file, json_encode($data));
+ return true;
+}
+
+/**
+ * Auto-detect site URL from current request
+ */
+function get_site_url() {
+ $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
+ $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
+ $base = $scheme . '://' . $host;
+ $script = dirname($_SERVER['SCRIPT_NAME'] ?? '');
+ return rtrim($base . ($script === '/' || $script === '\\' ? '' : $script), '/');
+}
\ No newline at end of file
diff --git a/include/function.post.php b/include/function.post.php
new file mode 100644
index 0000000..4a0f56e
--- /dev/null
+++ b/include/function.post.php
@@ -0,0 +1,139 @@
+prepare($sql);
+ if ($params) {
+ $stmt->bind_param($types, ...$params);
+ }
+ $stmt->execute();
+ return $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
+}
+
+function get_total_posts($category_id = null) {
+ $db = get_db();
+ $where = "WHERE status = 'published' AND deleted_at IS NULL";
+ $params = [];
+ $types = '';
+
+ if ($category_id) {
+ $where .= " AND category_id = ?";
+ $params[] = $category_id;
+ $types .= 'i';
+ }
+
+ $sql = "SELECT COUNT(*) FROM posts $where";
+ $stmt = $db->prepare($sql);
+ if ($params) {
+ $stmt->bind_param($types, ...$params);
+ }
+ $stmt->execute();
+ $result = $stmt->get_result();
+ return $result->fetch_row()[0];
+}
+
+function get_post_by_slug($slug) {
+ $db = get_db();
+ $stmt = $db->prepare("SELECT p.*, c.name as category_name, c.slug as category_slug
+ FROM posts p
+ LEFT JOIN categories c ON p.category_id = c.id
+ WHERE p.slug = ? AND p.status = 'published' AND p.deleted_at IS NULL");
+ $stmt->bind_param('s', $slug);
+ $stmt->execute();
+ $post = $stmt->get_result()->fetch_assoc();
+
+ if ($post) {
+ $stmt2 = $db->prepare("UPDATE posts SET views = views + 1 WHERE id = ?");
+ $stmt2->bind_param('i', $post['id']);
+ $stmt2->execute();
+ }
+
+ return $post;
+}
+
+function get_post_by_id($id) {
+ $db = get_db();
+ $stmt = $db->prepare("SELECT * FROM posts WHERE id = ?");
+ $stmt->bind_param('i', $id);
+ $stmt->execute();
+ return $stmt->get_result()->fetch_assoc();
+}
+
+function create_post($data) {
+ $db = get_db();
+ $stmt = $db->prepare("INSERT INTO posts (title, content, excerpt, slug, category_id, template, status)
+ VALUES (?, ?, ?, ?, ?, ?, ?)");
+ $stmt->bind_param('sssssss',
+ $data['title'],
+ $data['content'],
+ $data['excerpt'],
+ $data['slug'],
+ $data['category_id'],
+ $data['template'],
+ $data['status']
+ );
+ return $stmt->execute();
+}
+
+function update_post($id, $data) {
+ $db = get_db();
+ $stmt = $db->prepare("UPDATE posts SET title = ?, content = ?, excerpt = ?, slug = ?, category_id = ?, template = ?, status = ?, updated_at = NOW() WHERE id = ?");
+ $stmt->bind_param('sssssssi',
+ $data['title'],
+ $data['content'],
+ $data['excerpt'],
+ $data['slug'],
+ $data['category_id'],
+ $data['template'],
+ $data['status'],
+ $id
+ );
+ return $stmt->execute();
+}
+
+function delete_post($id) {
+ $db = get_db();
+ $stmt = $db->prepare("UPDATE posts SET deleted_at = NOW() WHERE id = ?");
+ $stmt->bind_param('i', $id);
+ return $stmt->execute();
+}
+
+function generate_slug($title) {
+ $slug = preg_replace('/[^\x{4e00}-\x{9fa5}a-zA-Z0-9]+/u', '-', $title);
+ $slug = trim($slug, '-');
+ $slug = strtolower($slug);
+ return $slug ?: 'post-' . time();
+}
+
+function get_recent_posts($limit = 5) {
+ $db = get_db();
+ $stmt = $db->prepare("SELECT id, title, slug, created_at FROM posts WHERE status = 'published' AND deleted_at IS NULL ORDER BY created_at DESC LIMIT ?");
+ $stmt->bind_param('i', $limit);
+ $stmt->execute();
+ return $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
+}
diff --git a/include/router.php b/include/router.php
new file mode 100644
index 0000000..cc2cf7c
--- /dev/null
+++ b/include/router.php
@@ -0,0 +1,70 @@
+query("SELECT id, title, slug, created_at FROM posts WHERE status = 'published' AND deleted_at IS NULL ORDER BY created_at DESC");
+$all_posts = $result->fetch_all(MYSQLI_ASSOC);
+
+// Group by year and month
+$archives = [];
+foreach ($all_posts as $post) {
+ $year = date('Y', strtotime($post['created_at']));
+ $month = date('m', strtotime($post['created_at']));
+ if (!isset($archives[$year])) {
+ $archives[$year] = [];
+ }
+ if (!isset($archives[$year][$month])) {
+ $archives[$year][$month] = [];
+ }
+ $archives[$year][$month][] = $post;
+}
+
+$categories = get_categories();
+$recent_posts = get_recent_posts(5);
+
+include __DIR__ . '/header.php';
+?>
+
+
+
+
+
+ $months): ?>
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/flow/category.php b/templates/flow/category.php
new file mode 100644
index 0000000..cc26233
--- /dev/null
+++ b/templates/flow/category.php
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 👁
+
+
+
+
+
+
+
+
+ 1): ?>
+
+
+
+
+
+
+
+
diff --git a/templates/flow/footer.php b/templates/flow/footer.php
new file mode 100644
index 0000000..a0e9866
--- /dev/null
+++ b/templates/flow/footer.php
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+