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;