- 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
263 lines
8.0 KiB
JavaScript
263 lines
8.0 KiB
JavaScript
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;
|