feat: initial release of pengzhan.dev personal editorial blog platform
- Editorial web platform built with Node.js, Express, EJS, and Vanilla CSS - Markdown content engine with YAML frontmatter, bilingual variants, and hot-reload watcher - Unified SQLite with FTS5 search indexing, query logging, and schema filters - Centered editorial post layout with margin-docked left TOC and mobile bottom sheet - WordPress-grade comment system with moderation dashboard and bulk operations under /guanli - Complete SEO suite (JSON-LD structured data, RSS/Atom feeds, XML sitemap, robots.txt) - CI/CD workflows for linting, security audits (Gitleaks), automated tests, and Docker releases
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const commentService = require('../services/comments');
|
||||
const contentService = require('../services/content');
|
||||
const pageCache = require('../middleware/cache');
|
||||
const { loadSiteConfig } = require('../config');
|
||||
|
||||
// Helper to parse cookies from headers without requiring extra npm dependencies
|
||||
function parseCookies(req) {
|
||||
const list = {};
|
||||
const rc = req.headers.cookie;
|
||||
if (rc) {
|
||||
rc.split(';').forEach(cookie => {
|
||||
const parts = cookie.split('=');
|
||||
const key = parts.shift().trim();
|
||||
const val = parts.join('=').trim();
|
||||
try {
|
||||
list[key] = decodeURIComponent(val);
|
||||
} catch (_) {
|
||||
list[key] = val;
|
||||
}
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function getExpectedAdminKey() {
|
||||
const config = loadSiteConfig();
|
||||
return process.env.ADMIN_SECRET || config.admin?.secret || config.site?.admin_secret || 'admin';
|
||||
}
|
||||
|
||||
// Admin authentication middleware
|
||||
function requireAdminAuth(req, res, next) {
|
||||
const expectedKey = getExpectedAdminKey();
|
||||
const cookies = parseCookies(req);
|
||||
const providedKey = req.query.key || req.headers['x-admin-key'] || cookies.admin_key;
|
||||
|
||||
if (providedKey && providedKey === expectedKey) {
|
||||
// If authenticated via query param, set cookie for convenience
|
||||
if (req.query.key) {
|
||||
res.setHeader('Set-Cookie', `admin_key=${encodeURIComponent(expectedKey)}; Path=/; HttpOnly; SameSite=Lax`);
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
// Render clean login prompt if unauthorized
|
||||
res.status(401);
|
||||
return res.render('pages/admin-login', {
|
||||
title: 'Admin Authorization',
|
||||
error: providedKey ? 'Incorrect admin password.' : null,
|
||||
redirect: req.originalUrl
|
||||
});
|
||||
}
|
||||
|
||||
// Explicit 404 for legacy /admin routes to prevent leakage and bust browser 301 cache
|
||||
router.all(['/admin', '/admin/*'], (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||
res.status(404).render('pages/error', {
|
||||
title: '404 - Page Not Found',
|
||||
errorCode: 404,
|
||||
errorMessage: "The page you are looking for doesn't exist or has moved."
|
||||
});
|
||||
});
|
||||
|
||||
// Redirect /guanli root to comments
|
||||
router.get('/guanli', (req, res) => {
|
||||
res.redirect('/guanli/comments');
|
||||
});
|
||||
|
||||
// Login GET endpoint (clean access)
|
||||
router.get('/guanli/login', (req, res) => {
|
||||
res.render('pages/admin-login', {
|
||||
title: 'Admin Authorization',
|
||||
error: null,
|
||||
redirect: req.query.redirect || '/guanli/comments'
|
||||
});
|
||||
});
|
||||
|
||||
// Login POST endpoint
|
||||
router.post('/guanli/login', (req, res) => {
|
||||
const expectedKey = getExpectedAdminKey();
|
||||
const providedKey = (req.body.key || '').trim();
|
||||
const redirectUrl = req.body.redirect || '/guanli/comments';
|
||||
|
||||
if (providedKey === expectedKey) {
|
||||
res.setHeader('Set-Cookie', `admin_key=${encodeURIComponent(expectedKey)}; Path=/; HttpOnly; SameSite=Lax`);
|
||||
return res.redirect(redirectUrl);
|
||||
}
|
||||
|
||||
res.status(401).render('pages/admin-login', {
|
||||
title: 'Admin Authorization',
|
||||
error: 'Incorrect admin password. Please try again.',
|
||||
redirect: redirectUrl
|
||||
});
|
||||
});
|
||||
|
||||
// Logout endpoint
|
||||
router.get('/guanli/logout', (req, res) => {
|
||||
res.setHeader('Set-Cookie', 'admin_key=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT');
|
||||
res.redirect('/guanli/comments');
|
||||
});
|
||||
|
||||
const dbService = require('../services/db');
|
||||
|
||||
// 1. Comment Moderation Dashboard
|
||||
router.get('/guanli/comments', requireAdminAuth, (req, res) => {
|
||||
const status = req.query.status || 'all';
|
||||
const q = req.query.q || '';
|
||||
const page = parseInt(req.query.page, 10) || 1;
|
||||
const limit = 15;
|
||||
|
||||
const paginated = dbService.getPaginatedComments({ status, q, page, limit });
|
||||
const counts = dbService.getCommentCounts();
|
||||
const adminKey = getExpectedAdminKey();
|
||||
const stats = dbService.getAdminStats();
|
||||
|
||||
res.render('pages/admin-comments', {
|
||||
title: 'Comment Moderation Dashboard',
|
||||
comments: paginated.comments,
|
||||
totalComments: paginated.total,
|
||||
page: paginated.page,
|
||||
totalPages: paginated.totalPages,
|
||||
status,
|
||||
q,
|
||||
counts,
|
||||
adminKey,
|
||||
stats
|
||||
});
|
||||
});
|
||||
|
||||
// 2a. Single Moderation Action Endpoint
|
||||
router.post('/guanli/comments/:action/:id', requireAdminAuth, (req, res) => {
|
||||
const { action, id } = req.params;
|
||||
|
||||
if (action === 'approve') {
|
||||
commentService.approveComment(Number(id));
|
||||
pageCache.clear();
|
||||
} else if (action === 'reject' || action === 'spam') {
|
||||
commentService.rejectComment(Number(id));
|
||||
pageCache.clear();
|
||||
} else if (action === 'delete') {
|
||||
commentService.deleteComment(Number(id));
|
||||
pageCache.clear();
|
||||
}
|
||||
|
||||
const isAjax = req.xhr || req.headers['content-type'] === 'application/json' || req.headers.accept?.includes('application/json');
|
||||
if (isAjax) {
|
||||
const counts = dbService.getCommentCounts();
|
||||
const stats = dbService.getAdminStats();
|
||||
return res.json({ success: true, action, id: Number(id), counts, stats });
|
||||
}
|
||||
|
||||
const returnUrl = req.headers.referer || '/guanli/comments';
|
||||
res.redirect(returnUrl);
|
||||
});
|
||||
|
||||
// 2b. Batch Moderation Action Endpoint
|
||||
router.post('/guanli/comments/batch', requireAdminAuth, (req, res) => {
|
||||
const action = req.body.action;
|
||||
let ids = req.body.ids;
|
||||
|
||||
if (typeof ids === 'string') {
|
||||
ids = ids.split(',').map(s => Number(s.trim())).filter(Boolean);
|
||||
} else if (Array.isArray(ids)) {
|
||||
ids = ids.map(Number).filter(Boolean);
|
||||
} else {
|
||||
ids = [];
|
||||
}
|
||||
|
||||
if (ids.length > 0) {
|
||||
if (action === 'approve') {
|
||||
commentService.batchApproveComments(ids);
|
||||
pageCache.clear();
|
||||
} else if (action === 'reject' || action === 'spam') {
|
||||
commentService.batchRejectComments(ids);
|
||||
pageCache.clear();
|
||||
} else if (action === 'delete') {
|
||||
commentService.batchDeleteComments(ids);
|
||||
pageCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const isAjax = req.xhr || req.headers['content-type'] === 'application/json' || req.headers.accept?.includes('application/json');
|
||||
if (isAjax) {
|
||||
const counts = dbService.getCommentCounts();
|
||||
const stats = dbService.getAdminStats();
|
||||
return res.json({ success: true, action, ids, count: ids.length, counts, stats });
|
||||
}
|
||||
|
||||
const returnUrl = req.headers.referer || '/guanli/comments';
|
||||
res.redirect(returnUrl);
|
||||
});
|
||||
|
||||
// 3. Dynamic SEO Analyzer Dashboard
|
||||
router.get('/guanli/seo', requireAdminAuth, (req, res) => {
|
||||
const posts = contentService.getAllPosts();
|
||||
const adminKey = getExpectedAdminKey();
|
||||
const stats = dbService.getAdminStats();
|
||||
const report = [];
|
||||
|
||||
for (const post of posts) {
|
||||
const raw = post.rawContent || '';
|
||||
const internalLinks = (raw.match(/\[[^\]]+\]\((\/[^)]+)\)/g) || []).length;
|
||||
const externalLinks = (raw.match(/\[[^\]]+\]\((https?:\/\/[^)]+)\)/g) || []).length;
|
||||
const imagesMissingAlt = (raw.match(/!\[\s*\]\([^)]+\)/g) || []).length;
|
||||
const totalImages = (raw.match(/!\[[^\]]*\]\([^)]+\)/g) || []).length;
|
||||
|
||||
// Check heading hierarchy
|
||||
const headings = post.toc || [];
|
||||
let headingIssues = [];
|
||||
let lastLevel = 1;
|
||||
for (const h of headings) {
|
||||
if (h.level > lastLevel + 1) {
|
||||
headingIssues.push(`Skipped heading level: from h${lastLevel} to h${h.level} ("${h.title}")`);
|
||||
}
|
||||
lastLevel = h.level;
|
||||
}
|
||||
|
||||
const wordCount = raw.trim().split(/\s+/).length;
|
||||
const titleLength = post.title.length;
|
||||
|
||||
report.push({
|
||||
type: 'post',
|
||||
slug: post.slug,
|
||||
title: post.title,
|
||||
wordCount,
|
||||
titleLength,
|
||||
titleOk: titleLength >= 20 && titleLength <= 70,
|
||||
internalLinks,
|
||||
externalLinks,
|
||||
totalImages,
|
||||
imagesMissingAlt,
|
||||
headingIssues,
|
||||
hasExcerpt: Boolean(post.excerpt && post.excerpt.length > 50)
|
||||
});
|
||||
}
|
||||
|
||||
res.render('pages/admin-seo', {
|
||||
title: 'SEO Analyzer & Content Audit',
|
||||
report,
|
||||
adminKey,
|
||||
stats
|
||||
});
|
||||
});
|
||||
|
||||
// 4. Search Analytics Dashboard
|
||||
router.get('/guanli/search', requireAdminAuth, (req, res) => {
|
||||
const adminKey = getExpectedAdminKey();
|
||||
const stats = dbService.getAdminStats();
|
||||
const searchLogs = dbService.getAllSearchLogs(100);
|
||||
const recommendations = dbService.getTopSearchRecommendations(20);
|
||||
|
||||
res.render('pages/admin-search', {
|
||||
title: 'Search Analytics & Query Logs',
|
||||
adminKey,
|
||||
stats,
|
||||
searchLogs,
|
||||
recommendations
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user