Files
haopengzhan 95a7dcb93b feat(admin): add ephemeral staging DB, read-only posts overview, and dynamic search
- Implement ephemeral staging mode (APP_ENV=staging) to isolate test/dev DB writes in /tmp
- Add read-only posts management dashboard under /guanli/posts with live links and comment counters
- Add hot re-indexing endpoint (/guanli/reindex) to refresh Markdown without restarting server
- Replace static recommended search config with dynamic frequency-based trending searches
- Remove early scaffold mockup file (dropbox/zh.md) and clean legacy mock test queries
- Add native Makefile with dev, dev-persistent, test, lint, and docker targets
- Update test suite and documentation for development workflows
2026-09-16 23:09:14 -07:00

314 lines
9.6 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');
// 0. Posts Read-Only Overview Dashboard
router.get('/guanli/posts', requireAdminAuth, (req, res) => {
const q = (req.query.q || '').trim().toLowerCase();
const activeCategory = (req.query.category || '').trim();
const allPosts = contentService.getAllPosts();
let filteredPosts = allPosts;
if (activeCategory) {
filteredPosts = filteredPosts.filter(p => (p.category || '').toLowerCase() === activeCategory.toLowerCase());
}
if (q) {
filteredPosts = filteredPosts.filter(p =>
(p.title || '').toLowerCase().includes(q) ||
(p.slug || '').toLowerCase().includes(q) ||
(p.tags || []).some(t => t.toLowerCase().includes(q))
);
}
const taxonomy = contentService.getTaxonomy();
const commentStats = dbService.getPostCommentStats();
const stats = dbService.getAdminStats();
res.render('pages/admin-posts', {
title: 'Posts Overview - Site Administration',
posts: allPosts,
filteredPosts,
categories: taxonomy.categories,
activeCategory,
q: req.query.q || '',
commentStats,
stats
});
});
// Hot Content Re-index & Cache Flush endpoint
router.post('/guanli/reindex', requireAdminAuth, (req, res) => {
try {
const { CONTENT_DIR } = require('../config');
contentService.init(CONTENT_DIR);
pageCache.clear();
const totalPosts = contentService.getAllPosts().length;
const totalPages = contentService.pagesBySlug.size;
res.json({ success: true, totalPosts, totalPages });
} catch (err) {
console.error('[Admin] Hot re-index failed:', err);
res.status(500).json({ success: false, error: err.message });
}
});
// 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;