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,64 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
// Content directory: prioritize ENV var, then /app/content (in K8s), fallback to ./sample-content
|
||||
function resolveContentDir() {
|
||||
if (process.env.CONTENT_DIR && fs.existsSync(process.env.CONTENT_DIR)) {
|
||||
return path.resolve(process.env.CONTENT_DIR);
|
||||
}
|
||||
if (fs.existsSync('/app/content')) {
|
||||
return '/app/content';
|
||||
}
|
||||
return path.resolve(__dirname, '../sample-content');
|
||||
}
|
||||
|
||||
const CONTENT_DIR = resolveContentDir();
|
||||
|
||||
function loadSiteConfig() {
|
||||
const configPath = path.join(CONTENT_DIR, 'config.yaml');
|
||||
const defaults = {
|
||||
site: {
|
||||
title: "Pengzhan",
|
||||
tagline: "Was childish, Now foolish",
|
||||
url: "https://pengzhan.dev",
|
||||
author: {
|
||||
name: "Pengzhan Hao",
|
||||
subtitle: "Software engineer on Cloud",
|
||||
gravatar: ""
|
||||
}
|
||||
},
|
||||
navigation: [
|
||||
{ label: "Home", path: "/" },
|
||||
{ label: "Blogs", path: "/blogs" },
|
||||
{ label: "Search", path: "/search" },
|
||||
{ label: "Quick Links", path: "/quick-links" }
|
||||
],
|
||||
social: [
|
||||
{ platform: "github", url: "https://github.com/CoderSherlock" },
|
||||
{ platform: "instagram", url: "https://www.instagram.com/pengzhan.hao/" },
|
||||
{ platform: "linkedin", url: "https://www.linkedin.com/in/pengzhanhao/" },
|
||||
{ platform: "email", address: "[email protected]" }
|
||||
],
|
||||
search: {
|
||||
recommended: []
|
||||
},
|
||||
plugins: {}
|
||||
};
|
||||
|
||||
if (fs.existsSync(configPath)) {
|
||||
try {
|
||||
const fileContent = fs.readFileSync(configPath, 'utf8');
|
||||
const parsed = yaml.load(fileContent);
|
||||
return { ...defaults, ...parsed };
|
||||
} catch (err) {
|
||||
console.error(`[Config] Error loading ${configPath}:`, err);
|
||||
}
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CONTENT_DIR,
|
||||
loadSiteConfig
|
||||
};
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const { CONTENT_DIR, loadSiteConfig } = require('./config');
|
||||
const contentService = require('./services/content');
|
||||
const commentService = require('./services/comments');
|
||||
const pluginManager = require('./services/plugin-manager');
|
||||
const watcherService = require('./services/watcher');
|
||||
const seoMiddleware = require('./middleware/seo');
|
||||
const pageCache = require('./middleware/cache');
|
||||
const { notFoundHandler, serverErrorHandler } = require('./middleware/error-handler');
|
||||
|
||||
// Route modules
|
||||
const homeRouter = require('./routes/home');
|
||||
const blogsRouter = require('./routes/blogs');
|
||||
const searchRouter = require('./routes/search');
|
||||
const feedRouter = require('./routes/feed');
|
||||
const sitemapRouter = require('./routes/sitemap');
|
||||
const commentsRouter = require('./routes/comments');
|
||||
const postRouter = require('./routes/post');
|
||||
const pagesRouter = require('./routes/pages');
|
||||
const adminRouter = require('./routes/admin');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// View engine setup
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, '../views'));
|
||||
|
||||
// Parse JSON & URL-encoded request bodies
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
|
||||
// Load site configuration
|
||||
const siteConfig = loadSiteConfig();
|
||||
|
||||
// Serve built-in skeleton static assets (CSS, JS, fonts)
|
||||
app.use(express.static(path.join(__dirname, '../public')));
|
||||
|
||||
// Serve Persistent Volume static directories
|
||||
const pvStaticDir = path.join(CONTENT_DIR, 'static');
|
||||
if (fs.existsSync(pvStaticDir)) {
|
||||
app.use(express.static(pvStaticDir));
|
||||
}
|
||||
|
||||
// Serve PV custom CSS if present
|
||||
const pvCssDir = path.join(CONTENT_DIR, 'css');
|
||||
if (fs.existsSync(pvCssDir)) {
|
||||
app.use('/css/custom', express.static(pvCssDir));
|
||||
}
|
||||
|
||||
// Serve post assets (e.g. cover images) at /posts-assets/<slug>/...
|
||||
const pvPostsDir = path.join(CONTENT_DIR, 'posts');
|
||||
if (fs.existsSync(pvPostsDir)) {
|
||||
app.use('/posts-assets', express.static(pvPostsDir));
|
||||
}
|
||||
|
||||
// Initialize services
|
||||
commentService.init(CONTENT_DIR);
|
||||
pluginManager.loadPlugins(CONTENT_DIR, app, siteConfig);
|
||||
contentService.init(CONTENT_DIR);
|
||||
watcherService.start(CONTENT_DIR, app);
|
||||
|
||||
// Inject SEO locals & plugin hook helper
|
||||
app.use(seoMiddleware(siteConfig));
|
||||
app.use((req, res, next) => {
|
||||
res.locals.pluginHook = (hookName, extraContext = {}) => {
|
||||
return pluginManager.renderHook(hookName, {
|
||||
req,
|
||||
res,
|
||||
site: res.locals.site,
|
||||
...extraContext
|
||||
});
|
||||
};
|
||||
next();
|
||||
});
|
||||
|
||||
// Super Page Cache micro-caching
|
||||
app.use(pageCache.middleware());
|
||||
|
||||
// Mount routes in order of specificity
|
||||
app.use('/', adminRouter);
|
||||
app.use('/', homeRouter);
|
||||
app.use('/', blogsRouter);
|
||||
app.use('/', searchRouter);
|
||||
app.use('/', feedRouter);
|
||||
app.use('/', sitemapRouter);
|
||||
app.use('/', commentsRouter);
|
||||
|
||||
// Specific blog post routes (/:slug, /:slug/:lang)
|
||||
app.use('/', postRouter);
|
||||
|
||||
// Fallback standalone pages (/:pageSlug from pages/)
|
||||
app.use('/', pagesRouter);
|
||||
|
||||
// Error handlers
|
||||
app.use(notFoundHandler);
|
||||
app.use(serverErrorHandler);
|
||||
|
||||
if (require.main === module) {
|
||||
const server = app.listen(PORT, () => {
|
||||
console.log(`=========================================`);
|
||||
console.log(` Pengzhan.dev Web Server running at:`);
|
||||
console.log(` http://localhost:${PORT}`);
|
||||
console.log(` Content directory: ${CONTENT_DIR}`);
|
||||
console.log(`=========================================`);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('SIGTERM signal received: closing HTTP server');
|
||||
watcherService.close();
|
||||
server.close(() => {
|
||||
console.log('HTTP server closed');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = app;
|
||||
@@ -0,0 +1,70 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
class SuperPageCache {
|
||||
constructor() {
|
||||
this.cache = new Map(); // path -> { body, contentType, etag, createdAt }
|
||||
this.ttlMs = 10 * 60 * 1000; // 10 minutes default memory cache
|
||||
}
|
||||
|
||||
middleware() {
|
||||
return (req, res, next) => {
|
||||
// Only cache GET requests that are not admin, guanli, or API routes
|
||||
if (req.method !== 'GET' || req.path.startsWith('/guanli') || req.path.startsWith('/admin') || req.path.startsWith('/api')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const cacheKey = req.originalUrl || req.url;
|
||||
const cached = this.cache.get(cacheKey);
|
||||
|
||||
if (cached && (Date.now() - cached.createdAt < this.ttlMs)) {
|
||||
// Check conditional ETag
|
||||
if (req.headers['if-none-match'] === cached.etag) {
|
||||
res.setHeader('X-Cache', 'HIT-304');
|
||||
return res.status(304).end();
|
||||
}
|
||||
|
||||
res.setHeader('X-Cache', 'HIT');
|
||||
res.setHeader('ETag', cached.etag);
|
||||
res.setHeader('Content-Type', cached.contentType || 'text/html; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
|
||||
return res.send(cached.body);
|
||||
}
|
||||
|
||||
// Intercept res.send to populate cache
|
||||
const originalSend = res.send.bind(res);
|
||||
res.send = (body) => {
|
||||
if (res.statusCode === 200 && typeof body === 'string') {
|
||||
const etag = `W/"${crypto.createHash('md5').update(body).digest('hex').slice(0, 16)}"`;
|
||||
this.cache.set(cacheKey, {
|
||||
body,
|
||||
contentType: res.getHeader('Content-Type') || 'text/html; charset=utf-8',
|
||||
etag,
|
||||
createdAt: Date.now()
|
||||
});
|
||||
res.setHeader('X-Cache', 'MISS');
|
||||
res.setHeader('ETag', etag);
|
||||
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
|
||||
}
|
||||
return originalSend(body);
|
||||
};
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
clear() {
|
||||
const size = this.cache.size;
|
||||
this.cache.clear();
|
||||
console.log(`[SuperPageCache] Flushed ${size} cached pages.`);
|
||||
}
|
||||
|
||||
invalidate(urlPattern) {
|
||||
for (const key of this.cache.keys()) {
|
||||
if (key.includes(urlPattern)) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new SuperPageCache();
|
||||
@@ -0,0 +1,23 @@
|
||||
function notFoundHandler(req, res, next) {
|
||||
res.status(404);
|
||||
res.render('pages/error', {
|
||||
title: '404 - Page Not Found',
|
||||
errorCode: 404,
|
||||
errorMessage: "The page you are looking for doesn't exist or has moved."
|
||||
});
|
||||
}
|
||||
|
||||
function serverErrorHandler(err, req, res, next) {
|
||||
console.error('[ServerError]', err);
|
||||
res.status(500);
|
||||
res.render('pages/error', {
|
||||
title: '500 - Server Error',
|
||||
errorCode: 500,
|
||||
errorMessage: "An unexpected error occurred."
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
notFoundHandler,
|
||||
serverErrorHandler
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
function generateJsonLd(site, seo, req) {
|
||||
const siteUrl = site.url || 'https://pengzhan.dev';
|
||||
const person = {
|
||||
'@type': 'Person',
|
||||
'@id': `${siteUrl}/#person`,
|
||||
name: (site.author && site.author.name) || site.title,
|
||||
url: siteUrl,
|
||||
jobTitle: (site.author && site.author.subtitle) || 'Software Engineer',
|
||||
sameAs: (site.social || []).map(s => s.url).filter(Boolean)
|
||||
};
|
||||
|
||||
const website = {
|
||||
'@type': 'WebSite',
|
||||
'@id': `${siteUrl}/#website`,
|
||||
url: siteUrl,
|
||||
name: site.title,
|
||||
description: site.tagline,
|
||||
publisher: { '@id': `${siteUrl}/#person` },
|
||||
potentialAction: [
|
||||
{
|
||||
'@type': 'SearchAction',
|
||||
target: {
|
||||
'@type': 'EntryPoint',
|
||||
urlTemplate: `${siteUrl}/search?q={search_term_string}`
|
||||
},
|
||||
'query-input': 'required name=search_term_string'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const graph = [person, website];
|
||||
|
||||
if (seo.type === 'article') {
|
||||
const blogPosting = {
|
||||
'@type': 'BlogPosting',
|
||||
'@id': `${seo.canonicalUrl}#article`,
|
||||
isPartOf: { '@id': `${siteUrl}/#website` },
|
||||
author: { '@id': `${siteUrl}/#person` },
|
||||
headline: seo.title,
|
||||
datePublished: seo.publishedTime || new Date().toISOString(),
|
||||
dateModified: seo.modifiedTime || seo.publishedTime || new Date().toISOString(),
|
||||
mainEntityOfPage: seo.canonicalUrl,
|
||||
description: seo.description,
|
||||
image: seo.ogImage,
|
||||
articleSection: seo.category || 'Tech',
|
||||
keywords: Array.isArray(seo.tags) ? seo.tags.join(', ') : seo.tags
|
||||
};
|
||||
graph.push(blogPosting);
|
||||
}
|
||||
|
||||
// Breadcrumbs
|
||||
const breadcrumbItems = [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Home',
|
||||
item: siteUrl
|
||||
}
|
||||
];
|
||||
|
||||
if (req.path !== '/') {
|
||||
breadcrumbItems.push({
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: seo.title,
|
||||
item: seo.canonicalUrl
|
||||
});
|
||||
}
|
||||
|
||||
graph.push({
|
||||
'@type': 'BreadcrumbList',
|
||||
'@id': `${seo.canonicalUrl}#breadcrumb`,
|
||||
itemListElement: breadcrumbItems
|
||||
});
|
||||
|
||||
return JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': graph
|
||||
});
|
||||
}
|
||||
|
||||
function seoMiddleware(siteConfig) {
|
||||
return (req, res, next) => {
|
||||
res.locals.site = siteConfig.site;
|
||||
res.locals.navigation = siteConfig.navigation;
|
||||
res.locals.social = siteConfig.social;
|
||||
res.locals.currentPath = req.path;
|
||||
|
||||
// Helper to generate SEO metadata
|
||||
res.locals.seo = {
|
||||
title: siteConfig.site.title,
|
||||
description: siteConfig.site.tagline,
|
||||
canonicalUrl: `${siteConfig.site.url}${req.path}`,
|
||||
ogImage: `${siteConfig.site.url}/images/og-default.png`,
|
||||
type: 'website',
|
||||
tags: [],
|
||||
category: ''
|
||||
};
|
||||
|
||||
res.locals.getJsonLd = () => generateJsonLd(siteConfig.site, res.locals.seo, req);
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = seoMiddleware;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,42 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const contentService = require('../services/content');
|
||||
|
||||
router.get('/blogs', (req, res) => {
|
||||
let posts = contentService.getAllPosts();
|
||||
const { category, tag, page } = req.query;
|
||||
|
||||
let activeFilter = null;
|
||||
|
||||
if (category) {
|
||||
const catLower = category.trim().toLowerCase();
|
||||
posts = posts.filter(p => (p.category || '').toLowerCase() === catLower);
|
||||
activeFilter = { type: 'category', value: category };
|
||||
} else if (tag) {
|
||||
const tagLower = tag.trim().toLowerCase();
|
||||
posts = posts.filter(p => (p.tags || []).some(t => t.toLowerCase() === tagLower));
|
||||
activeFilter = { type: 'tag', value: tag };
|
||||
}
|
||||
|
||||
const postsPerPage = 12;
|
||||
const currentPage = Math.max(1, parseInt(page, 10) || 1);
|
||||
const totalPosts = posts.length;
|
||||
const totalPages = Math.ceil(totalPosts / postsPerPage);
|
||||
|
||||
const startIndex = (currentPage - 1) * postsPerPage;
|
||||
const paginatedPosts = posts.slice(startIndex, startIndex + postsPerPage);
|
||||
|
||||
const taxonomy = contentService.getTaxonomy();
|
||||
|
||||
res.render('pages/blogs', {
|
||||
title: activeFilter ? `Blogs - ${activeFilter.type}: ${activeFilter.value}` : 'Blogs',
|
||||
posts: paginatedPosts,
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalPosts,
|
||||
activeFilter,
|
||||
taxonomy
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,60 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const commentService = require('../services/comments');
|
||||
const contentService = require('../services/content');
|
||||
const pluginManager = require('../services/plugin-manager');
|
||||
|
||||
router.get('/api/comments/:slug', (req, res) => {
|
||||
const comments = commentService.getCommentsForPost(req.params.slug);
|
||||
res.json({ comments });
|
||||
});
|
||||
|
||||
router.post('/api/comments', async (req, res) => {
|
||||
try {
|
||||
const { postSlug, authorName, authorEmail, authorWebsite, body, honeypot } = req.body;
|
||||
|
||||
if (!postSlug || !authorName || !body) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
const clientIp = req.ip || req.headers['x-forwarded-for'] || req.socket.remoteAddress;
|
||||
|
||||
const newComment = commentService.addComment({
|
||||
postSlug,
|
||||
authorName,
|
||||
authorEmail,
|
||||
authorWebsite,
|
||||
body,
|
||||
honeypot,
|
||||
ip: clientIp
|
||||
});
|
||||
|
||||
// Invalidate page cache so new comment and admin views update immediately
|
||||
const pageCache = require('../middleware/cache');
|
||||
pageCache.clear();
|
||||
|
||||
const post = contentService.getPost(postSlug);
|
||||
// Fire plugin lifecycle hook asynchronously
|
||||
pluginManager.triggerCommentCreated(newComment, post).catch(err => {
|
||||
console.error('[CommentRoute] Error in onCommentCreated plugin hook:', err);
|
||||
});
|
||||
|
||||
// If submitted via AJAX, return JSON
|
||||
if (req.xhr || req.headers.accept.includes('json')) {
|
||||
return res.json({
|
||||
success: true,
|
||||
comment: newComment,
|
||||
message: newComment.approved === 1 ? 'Comment posted!' : 'Your comment is awaiting moderation.'
|
||||
});
|
||||
}
|
||||
|
||||
// Otherwise redirect back to post comments anchor with flash message
|
||||
const msg = newComment.approved === 1 ? 'comment_posted' : 'comment_pending';
|
||||
res.redirect(`/${postSlug}?comment_status=${msg}#comments`);
|
||||
} catch (err) {
|
||||
console.error('[CommentRoute] Error saving comment:', err);
|
||||
res.status(500).json({ error: 'Failed to post comment' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,58 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { Feed } = require('feed');
|
||||
const contentService = require('../services/content');
|
||||
|
||||
function generateFeed(site) {
|
||||
const feed = new Feed({
|
||||
title: site.title,
|
||||
description: site.tagline,
|
||||
id: site.url,
|
||||
link: site.url,
|
||||
language: 'en',
|
||||
image: `${site.url}/images/og-default.png`,
|
||||
favicon: `${site.url}/favicon.ico`,
|
||||
copyright: `All rights reserved ${new Date().getFullYear()}, ${site.author ? site.author.name : site.title}`,
|
||||
author: {
|
||||
name: site.author ? site.author.name : site.title,
|
||||
link: site.url
|
||||
}
|
||||
});
|
||||
|
||||
const posts = contentService.getAllPosts();
|
||||
|
||||
for (const post of posts) {
|
||||
const postUrl = `${site.url}/${post.slug}`;
|
||||
feed.addItem({
|
||||
title: post.title,
|
||||
id: postUrl,
|
||||
link: postUrl,
|
||||
description: post.excerpt,
|
||||
content: post.contentHtml,
|
||||
author: [
|
||||
{
|
||||
name: post.author,
|
||||
link: site.url
|
||||
}
|
||||
],
|
||||
date: post.date,
|
||||
image: post.cover ? (post.cover.startsWith('http') ? post.cover : `${site.url}${post.cover}`) : undefined
|
||||
});
|
||||
}
|
||||
|
||||
return feed;
|
||||
}
|
||||
|
||||
router.get(['/feed', '/rss.xml', '/feed.xml'], (req, res) => {
|
||||
const feed = generateFeed(res.locals.site);
|
||||
res.header('Content-Type', 'application/xml; charset=utf-8');
|
||||
res.send(feed.rss2());
|
||||
});
|
||||
|
||||
router.get('/atom.xml', (req, res) => {
|
||||
const feed = generateFeed(res.locals.site);
|
||||
res.header('Content-Type', 'application/atom+xml; charset=utf-8');
|
||||
res.send(feed.atom1());
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,16 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const contentService = require('../services/content');
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const homePage = contentService.getPage('/');
|
||||
const recentPosts = contentService.getAllPosts().slice(0, 6);
|
||||
|
||||
res.render('pages/home', {
|
||||
title: homePage ? homePage.title : res.locals.site.title,
|
||||
page: homePage,
|
||||
recentPosts
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,28 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const contentService = require('../services/content');
|
||||
|
||||
router.get('/:slug', (req, res, next) => {
|
||||
const pagePath = `/${req.params.slug}`;
|
||||
const page = contentService.getPage(pagePath);
|
||||
if (!page) {
|
||||
return next(); // Fall through to 404
|
||||
}
|
||||
|
||||
res.locals.seo = {
|
||||
title: `${page.title} - ${res.locals.site.title}`,
|
||||
description: page.frontmatter.description || res.locals.site.tagline,
|
||||
canonicalUrl: `${res.locals.site.url}${pagePath}`,
|
||||
ogImage: res.locals.seo.ogImage,
|
||||
type: 'website'
|
||||
};
|
||||
|
||||
const layout = page.layout === 'home' ? 'pages/home' : 'pages/page';
|
||||
|
||||
res.render(layout, {
|
||||
title: page.title,
|
||||
page
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,52 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const contentService = require('../services/content');
|
||||
const commentService = require('../services/comments');
|
||||
|
||||
function renderPost(req, res, next, slug, lang) {
|
||||
const post = contentService.getPost(slug, lang);
|
||||
if (!post) {
|
||||
return next(); // Fall through to standalone pages or 404
|
||||
}
|
||||
|
||||
const { prev, next: nextPost } = contentService.getAdjacentPosts(slug);
|
||||
const comments = commentService.getCommentsForPost(slug);
|
||||
|
||||
// Set SEO metadata for post
|
||||
res.locals.seo = {
|
||||
title: `${post.title} - ${res.locals.site.title}`,
|
||||
description: post.excerpt || res.locals.site.tagline,
|
||||
canonicalUrl: `${res.locals.site.url}/${post.slug}${post.lang !== 'en' && !post.isDefault ? `/${post.lang}` : ''}`,
|
||||
ogImage: post.cover ? (post.cover.startsWith('http') ? post.cover : `${res.locals.site.url}${post.cover}`) : res.locals.seo.ogImage,
|
||||
type: 'article',
|
||||
publishedTime: post.date.toISOString(),
|
||||
author: post.author
|
||||
};
|
||||
|
||||
res.render('pages/post', {
|
||||
title: post.title,
|
||||
post,
|
||||
currentLang: lang || post.lang,
|
||||
comments,
|
||||
commentStatus: req.query.comment_status,
|
||||
prevPost: prev,
|
||||
nextPost
|
||||
});
|
||||
}
|
||||
|
||||
// Route for bilingual variant: /<post-slug>/<lang> (e.g. /my-post/zh)
|
||||
router.get('/:slug/:lang([a-zA-Z]{2,5})', (req, res, next) => {
|
||||
renderPost(req, res, next, req.params.slug, req.params.lang);
|
||||
});
|
||||
|
||||
// Route for default language: /<post-slug>
|
||||
router.get('/:slug', (req, res, next) => {
|
||||
// Exclude reserved top-level prefixes if any
|
||||
const reserved = ['blogs', 'search', 'quick-links', 'feed', 'rss.xml', 'sitemap.xml', 'robots.txt', 'api', 'plugins', 'public', 'posts-assets', 'guanli', 'admin'];
|
||||
if (reserved.includes(req.params.slug)) {
|
||||
return next();
|
||||
}
|
||||
renderPost(req, res, next, req.params.slug, null);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,104 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const searchEngine = require('../services/search-engine');
|
||||
const contentService = require('../services/content');
|
||||
const { loadSiteConfig } = require('../config');
|
||||
|
||||
function getRecommendedSearches() {
|
||||
const config = loadSiteConfig();
|
||||
const taxonomy = contentService.getTaxonomy();
|
||||
const dbService = require('../services/db');
|
||||
|
||||
const dynamicPills = [];
|
||||
|
||||
// 1. Add categories as discovery pills
|
||||
for (const cat of taxonomy.categories) {
|
||||
dynamicPills.push({
|
||||
label: `category:${cat.name.toLowerCase()}`,
|
||||
query: `category:${cat.name.toLowerCase()}`,
|
||||
count: cat.count
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Add top tags as discovery pills
|
||||
for (const tag of taxonomy.tags.slice(0, 6)) {
|
||||
dynamicPills.push({
|
||||
label: `tag:${tag.name.toLowerCase()}`,
|
||||
query: `tag:${tag.name.toLowerCase()}`,
|
||||
count: tag.count
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Add top trending searches from SQLite database
|
||||
const trendingDb = dbService.getTopSearchRecommendations(4);
|
||||
const customPills = [];
|
||||
for (const item of trendingDb) {
|
||||
if (!dynamicPills.some(p => p.query === item.query)) {
|
||||
customPills.push({
|
||||
label: item.label,
|
||||
query: item.query
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Add any static curated pills from config
|
||||
const staticConfig = (config.search && config.search.recommended) || [];
|
||||
for (const item of staticConfig) {
|
||||
if (!dynamicPills.some(p => p.query === item.query) && !customPills.some(p => p.query === item.query)) {
|
||||
customPills.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
dynamicPills,
|
||||
customPills
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/search', (req, res) => {
|
||||
const query = (req.query.q || '').trim();
|
||||
const recommended = getRecommendedSearches();
|
||||
|
||||
let searchData = {
|
||||
query: { filters: {}, phrases: [], keywords: [], raw: '' },
|
||||
results: [],
|
||||
total: 0
|
||||
};
|
||||
|
||||
if (query) {
|
||||
searchData = searchEngine.search(query);
|
||||
}
|
||||
|
||||
res.render('pages/search', {
|
||||
title: query ? `Search: ${query}` : 'Search',
|
||||
query,
|
||||
searchData,
|
||||
recommended
|
||||
});
|
||||
});
|
||||
|
||||
// JSON API endpoint for client-side search autocomplete / live search
|
||||
router.get('/api/search', (req, res) => {
|
||||
const query = (req.query.q || '').trim();
|
||||
if (!query) {
|
||||
return res.json({ query: null, results: [], total: 0 });
|
||||
}
|
||||
|
||||
const searchData = searchEngine.search(query);
|
||||
res.json({
|
||||
query: searchData.query,
|
||||
total: searchData.total,
|
||||
results: searchData.results.map(r => ({
|
||||
slug: r.post.slug,
|
||||
lang: r.post.lang,
|
||||
title: r.post.title,
|
||||
date: r.post.date,
|
||||
category: r.post.category,
|
||||
tags: r.post.tags,
|
||||
score: r.score,
|
||||
snippet: r.snippet
|
||||
}))
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,75 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const contentService = require('../services/content');
|
||||
|
||||
router.get('/sitemap.xml', (req, res) => {
|
||||
const siteUrl = res.locals.site.url;
|
||||
const posts = contentService.getAllPosts();
|
||||
const pages = Array.from(contentService.pagesBySlug.values());
|
||||
|
||||
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
||||
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n';
|
||||
|
||||
// Static / Top-level pages
|
||||
const staticRoutes = ['/', '/blogs', '/search'];
|
||||
for (const route of staticRoutes) {
|
||||
xml += ' <url>\n';
|
||||
xml += ` <loc>${siteUrl}${route}</loc>\n`;
|
||||
xml += ` <changefreq>daily</changefreq>\n`;
|
||||
xml += ` <priority>${route === '/' ? '1.0' : '0.8'}</priority>\n`;
|
||||
xml += ' </url>\n';
|
||||
}
|
||||
|
||||
// Standalone pages
|
||||
for (const page of pages) {
|
||||
if (!page.slug || page.slug === '/') continue;
|
||||
const cleanSlug = page.slug.startsWith('/') ? page.slug : `/${page.slug}`;
|
||||
if (cleanSlug === '/guanli' || cleanSlug.startsWith('/guanli/') || cleanSlug === '/admin' || cleanSlug.startsWith('/admin/')) {
|
||||
continue;
|
||||
}
|
||||
xml += ' <url>\n';
|
||||
xml += ` <loc>${siteUrl}${cleanSlug}</loc>\n`;
|
||||
xml += ` <changefreq>weekly</changefreq>\n`;
|
||||
xml += ` <priority>0.7</priority>\n`;
|
||||
xml += ' </url>\n';
|
||||
}
|
||||
|
||||
// Blog posts (including translations)
|
||||
for (const post of posts) {
|
||||
if (!post.slug || post.slug.startsWith('guanli') || post.slug.startsWith('admin')) continue;
|
||||
xml += ' <url>\n';
|
||||
xml += ` <loc>${siteUrl}/${post.slug}</loc>\n`;
|
||||
xml += ` <lastmod>${post.date.toISOString().split('T')[0]}</lastmod>\n`;
|
||||
xml += ` <changefreq>monthly</changefreq>\n`;
|
||||
xml += ` <priority>0.9</priority>\n`;
|
||||
xml += ' </url>\n';
|
||||
|
||||
// Bilingual URLs
|
||||
if (post.availableLanguages) {
|
||||
for (const lang of post.availableLanguages) {
|
||||
if (lang !== post.lang && lang !== 'default') {
|
||||
xml += ' <url>\n';
|
||||
xml += ` <loc>${siteUrl}/${post.slug}/${lang}</loc>\n`;
|
||||
xml += ` <lastmod>${post.date.toISOString().split('T')[0]}</lastmod>\n`;
|
||||
xml += ` <changefreq>monthly</changefreq>\n`;
|
||||
xml += ` <priority>0.8</priority>\n`;
|
||||
xml += ' </url>\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
xml += '</urlset>';
|
||||
|
||||
res.header('Content-Type', 'application/xml; charset=utf-8');
|
||||
res.send(xml);
|
||||
});
|
||||
|
||||
router.get('/robots.txt', (req, res) => {
|
||||
const siteUrl = res.locals.site.url;
|
||||
const content = `User-agent: *\nDisallow: /admin/\nAllow: /\n\nSitemap: ${siteUrl}/sitemap.xml\n`;
|
||||
res.header('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.send(content);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,49 @@
|
||||
const dbService = require('./db');
|
||||
|
||||
class CommentService {
|
||||
init(contentDir) {
|
||||
dbService.init(contentDir);
|
||||
}
|
||||
|
||||
getCommentsForPost(postSlug) {
|
||||
return dbService.getApprovedCommentsForPost(postSlug);
|
||||
}
|
||||
|
||||
addComment(params) {
|
||||
return dbService.addComment(params);
|
||||
}
|
||||
|
||||
getPendingComments() {
|
||||
return dbService.getAllComments(0); // 0 = pending
|
||||
}
|
||||
|
||||
getAllComments() {
|
||||
return dbService.getAllComments();
|
||||
}
|
||||
|
||||
approveComment(id) {
|
||||
return dbService.updateCommentStatus(id, 1);
|
||||
}
|
||||
|
||||
rejectComment(id) {
|
||||
return dbService.updateCommentStatus(id, -1);
|
||||
}
|
||||
|
||||
deleteComment(id) {
|
||||
return dbService.deleteComment(id);
|
||||
}
|
||||
|
||||
batchApproveComments(ids) {
|
||||
return dbService.batchUpdateCommentStatus(ids, 1);
|
||||
}
|
||||
|
||||
batchRejectComments(ids) {
|
||||
return dbService.batchUpdateCommentStatus(ids, -1);
|
||||
}
|
||||
|
||||
batchDeleteComments(ids) {
|
||||
return dbService.batchDeleteComments(ids);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CommentService();
|
||||
@@ -0,0 +1,284 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const matter = require('gray-matter');
|
||||
const { createMarkdownRenderer, extractToc } = require('./markdown');
|
||||
const searchEngine = require('./search-engine');
|
||||
const pluginManager = require('./plugin-manager');
|
||||
|
||||
class ContentService {
|
||||
constructor() {
|
||||
this.contentDir = '';
|
||||
this.postsBySlug = new Map(); // slug -> { defaultPost, variants: { lang -> post } }
|
||||
this.allPostsList = []; // Chronological list of default language posts
|
||||
this.pagesBySlug = new Map(); // slug -> page object
|
||||
this.taxonomy = {
|
||||
categories: new Map(), // name.toLowerCase() -> { name, count, posts }
|
||||
tags: new Map() // name.toLowerCase() -> { name, slug, count, posts }
|
||||
};
|
||||
this.markdownRenderer = null;
|
||||
}
|
||||
|
||||
init(contentDir) {
|
||||
this.contentDir = contentDir;
|
||||
this.markdownRenderer = createMarkdownRenderer(pluginManager);
|
||||
this.reload();
|
||||
}
|
||||
|
||||
reload() {
|
||||
console.log('[ContentService] Scanning content directory:', this.contentDir);
|
||||
this.postsBySlug.clear();
|
||||
this.allPostsList = [];
|
||||
this.pagesBySlug.clear();
|
||||
this.taxonomy.categories.clear();
|
||||
this.taxonomy.tags.clear();
|
||||
|
||||
// Re-initialize markdown renderer in case plugins changed
|
||||
this.markdownRenderer = createMarkdownRenderer(pluginManager);
|
||||
|
||||
this.loadPosts();
|
||||
this.loadPages();
|
||||
|
||||
// Update search engine with all searchable post variants
|
||||
const allSearchable = [];
|
||||
for (const entry of this.postsBySlug.values()) {
|
||||
allSearchable.push(entry.defaultPost);
|
||||
for (const [lang, variant] of Object.entries(entry.variants)) {
|
||||
if (lang !== entry.defaultPost.lang) {
|
||||
allSearchable.push(variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
searchEngine.setPosts(allSearchable);
|
||||
|
||||
// Sync to SQLite database & FTS5
|
||||
const dbService = require('./db');
|
||||
dbService.syncPosts(allSearchable);
|
||||
|
||||
// Invalidate super page cache on content refresh
|
||||
const pageCache = require('../middleware/cache');
|
||||
pageCache.clear();
|
||||
|
||||
console.log(`[ContentService] Loaded ${this.allPostsList.length} blog posts and ${this.pagesBySlug.size} pages.`);
|
||||
}
|
||||
|
||||
loadPosts() {
|
||||
const postsDir = path.join(this.contentDir, 'posts');
|
||||
if (!fs.existsSync(postsDir)) {
|
||||
console.warn(`[ContentService] Posts directory not found at ${postsDir}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(postsDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const slug = entry.name;
|
||||
const postDirPath = path.join(postsDir, slug);
|
||||
|
||||
// Find all markdown files in the post folder
|
||||
const files = fs.readdirSync(postDirPath);
|
||||
const mdFiles = files.filter(f => f.endsWith('.md'));
|
||||
if (mdFiles.length === 0) continue;
|
||||
|
||||
const variants = {};
|
||||
let defaultPost = null;
|
||||
|
||||
for (const file of mdFiles) {
|
||||
const filePath = path.join(postDirPath, file);
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data: frontmatter, content: rawMarkdown } = matter(fileContent);
|
||||
|
||||
// Determine language:
|
||||
// if file is 'index.md', lang defaults to frontmatter.lang or 'en'
|
||||
// if file is 'zh.md', lang is 'zh'
|
||||
const baseName = path.basename(file, '.md');
|
||||
const lang = frontmatter.lang || (baseName === 'index' ? 'en' : baseName);
|
||||
|
||||
const renderedHtml = this.markdownRenderer.render(rawMarkdown);
|
||||
const toc = extractToc(rawMarkdown);
|
||||
|
||||
// Compute excerpt if not explicitly given
|
||||
let excerpt = frontmatter.excerpt;
|
||||
if (!excerpt) {
|
||||
const firstParaMatch = rawMarkdown.replace(/#+\s+.+/g, '').trim().split(/\n\s*\n/)[0];
|
||||
excerpt = firstParaMatch ? firstParaMatch.replace(/[*_`#[\]]/g, '').slice(0, 180) : '';
|
||||
}
|
||||
|
||||
// Resolve cover image path relative to web root
|
||||
let cover = frontmatter.cover || null;
|
||||
if (cover && !cover.startsWith('http') && !cover.startsWith('/')) {
|
||||
cover = `/posts-assets/${slug}/${cover}`;
|
||||
}
|
||||
|
||||
// Tags normalization
|
||||
const rawTags = Array.isArray(frontmatter.tags)
|
||||
? frontmatter.tags
|
||||
: (frontmatter.tags ? String(frontmatter.tags).split(',').map(s => s.trim()) : []);
|
||||
|
||||
const postObj = {
|
||||
slug,
|
||||
lang,
|
||||
title: frontmatter.title || slug,
|
||||
date: frontmatter.date ? new Date(frontmatter.date) : new Date(),
|
||||
author: frontmatter.author || 'Pengzhan Hao',
|
||||
category: frontmatter.category || 'Tech',
|
||||
tags: rawTags,
|
||||
cover,
|
||||
excerpt,
|
||||
rawContent: rawMarkdown,
|
||||
contentHtml: renderedHtml,
|
||||
toc,
|
||||
isDefault: (baseName === 'index'),
|
||||
availableLanguages: []
|
||||
};
|
||||
|
||||
pluginManager.applyPostHooks(postObj);
|
||||
|
||||
variants[lang] = postObj;
|
||||
if (baseName === 'index' || !defaultPost) {
|
||||
defaultPost = postObj;
|
||||
}
|
||||
}
|
||||
|
||||
if (defaultPost) {
|
||||
const availableLangs = Object.keys(variants);
|
||||
defaultPost.availableLanguages = availableLangs;
|
||||
for (const v of Object.values(variants)) {
|
||||
v.availableLanguages = availableLangs;
|
||||
}
|
||||
|
||||
this.postsBySlug.set(slug, {
|
||||
slug,
|
||||
defaultPost,
|
||||
variants
|
||||
});
|
||||
|
||||
this.allPostsList.push(defaultPost);
|
||||
|
||||
// Index Dynamic Taxonomy from default post
|
||||
this.indexTaxonomy(defaultPost);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort posts list in reverse chronological order
|
||||
this.allPostsList.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||
}
|
||||
|
||||
indexTaxonomy(post) {
|
||||
// 1. Category indexing
|
||||
if (post.category) {
|
||||
const catKey = post.category.trim().toLowerCase();
|
||||
if (!this.taxonomy.categories.has(catKey)) {
|
||||
this.taxonomy.categories.set(catKey, {
|
||||
name: post.category.trim(),
|
||||
count: 0,
|
||||
posts: []
|
||||
});
|
||||
}
|
||||
const catObj = this.taxonomy.categories.get(catKey);
|
||||
catObj.count++;
|
||||
catObj.posts.push(post);
|
||||
}
|
||||
|
||||
// 2. Tag indexing
|
||||
if (Array.isArray(post.tags)) {
|
||||
for (const tag of post.tags) {
|
||||
if (!tag) continue;
|
||||
const tagName = String(tag).trim();
|
||||
const tagKey = tagName.toLowerCase();
|
||||
const tagSlug = encodeURIComponent(tagKey.replace(/\s+/g, '-'));
|
||||
|
||||
if (!this.taxonomy.tags.has(tagKey)) {
|
||||
this.taxonomy.tags.set(tagKey, {
|
||||
name: tagName,
|
||||
slug: tagSlug,
|
||||
count: 0,
|
||||
posts: []
|
||||
});
|
||||
}
|
||||
const tagObj = this.taxonomy.tags.get(tagKey);
|
||||
tagObj.count++;
|
||||
tagObj.posts.push(post);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadPages() {
|
||||
const pagesDir = path.join(this.contentDir, 'pages');
|
||||
if (!fs.existsSync(pagesDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(pagesDir);
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.md') && !file.endsWith('.html')) continue;
|
||||
const filePath = path.join(pagesDir, file);
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data: frontmatter, content: rawContent } = matter(fileContent);
|
||||
|
||||
const baseName = path.basename(file, path.extname(file));
|
||||
const slug = frontmatter.permalink || (baseName === 'home' ? '/' : `/${baseName}`);
|
||||
|
||||
let contentHtml = '';
|
||||
if (file.endsWith('.html')) {
|
||||
contentHtml = rawContent;
|
||||
} else {
|
||||
contentHtml = this.markdownRenderer.render(rawContent);
|
||||
}
|
||||
|
||||
const pageObj = {
|
||||
slug,
|
||||
title: frontmatter.title || baseName,
|
||||
layout: frontmatter.layout || (baseName === 'home' ? 'home' : 'page'),
|
||||
frontmatter,
|
||||
rawContent,
|
||||
contentHtml
|
||||
};
|
||||
|
||||
this.pagesBySlug.set(slug, pageObj);
|
||||
}
|
||||
}
|
||||
|
||||
getAllPosts() {
|
||||
return this.allPostsList;
|
||||
}
|
||||
|
||||
getPost(slug, lang) {
|
||||
const entry = this.postsBySlug.get(slug);
|
||||
if (!entry) return null;
|
||||
|
||||
if (lang && entry.variants[lang]) {
|
||||
return entry.variants[lang];
|
||||
}
|
||||
return entry.defaultPost;
|
||||
}
|
||||
|
||||
getPage(slug) {
|
||||
return this.pagesBySlug.get(slug) || null;
|
||||
}
|
||||
|
||||
getTaxonomy() {
|
||||
// Return structured taxonomy with top tags sorted by count
|
||||
const categories = Array.from(this.taxonomy.categories.values())
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
const tags = Array.from(this.taxonomy.tags.values())
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
return { categories, tags };
|
||||
}
|
||||
|
||||
getAdjacentPosts(currentSlug) {
|
||||
const index = this.allPostsList.findIndex(p => p.slug === currentSlug);
|
||||
if (index === -1) return { prev: null, next: null };
|
||||
|
||||
// List is sorted descending by date:
|
||||
// next is newer (index - 1), prev is older (index + 1)
|
||||
return {
|
||||
next: index > 0 ? this.allPostsList[index - 1] : null,
|
||||
prev: index < this.allPostsList.length - 1 ? this.allPostsList[index + 1] : null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ContentService();
|
||||
@@ -0,0 +1,419 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
|
||||
class DatabaseService {
|
||||
constructor() {
|
||||
this.db = null;
|
||||
this.dbPath = null;
|
||||
}
|
||||
|
||||
init(contentDir) {
|
||||
const dataDir = path.join(contentDir, 'data');
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
this.dbPath = path.join(dataDir, 'site.db');
|
||||
this.db = new DatabaseSync(this.dbPath);
|
||||
|
||||
// Initialize unified tables
|
||||
this.db.exec(`
|
||||
-- Posts storage & metadata
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
slug TEXT NOT NULL,
|
||||
lang TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
author TEXT,
|
||||
category TEXT,
|
||||
tags TEXT,
|
||||
excerpt TEXT,
|
||||
raw_content TEXT,
|
||||
cover TEXT,
|
||||
is_default INTEGER DEFAULT 1,
|
||||
PRIMARY KEY (slug, lang)
|
||||
);
|
||||
|
||||
-- Full Text Search virtual table
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
|
||||
slug UNINDEXED,
|
||||
lang UNINDEXED,
|
||||
title,
|
||||
category,
|
||||
tags,
|
||||
excerpt,
|
||||
content,
|
||||
tokenize='porter unicode61'
|
||||
);
|
||||
|
||||
-- Search query analytics & history
|
||||
CREATE TABLE IF NOT EXISTS search_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
query TEXT NOT NULL,
|
||||
result_count INTEGER DEFAULT 0,
|
||||
searched_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Search query recommendations (curated + dynamic)
|
||||
CREATE TABLE IF NOT EXISTS search_recommendations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
label TEXT NOT NULL,
|
||||
query TEXT NOT NULL UNIQUE,
|
||||
clicks INTEGER DEFAULT 0,
|
||||
is_pinned INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
-- Comments & Moderation Queue
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_slug TEXT NOT NULL,
|
||||
author_name TEXT NOT NULL,
|
||||
author_email TEXT,
|
||||
author_website TEXT,
|
||||
body TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
approved INTEGER DEFAULT 0, -- 0: Pending moderation, 1: Approved, -1: Rejected/Spam
|
||||
ip TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_slug ON comments(post_slug, approved);
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_email ON comments(author_email);
|
||||
`);
|
||||
|
||||
console.log(`[DB] Unified SQLite database initialized at ${this.dbPath}`);
|
||||
}
|
||||
|
||||
// --- Posts & FTS5 Synchronization ---
|
||||
syncPosts(postsList) {
|
||||
if (!this.db) return;
|
||||
|
||||
try {
|
||||
this.db.exec('DELETE FROM posts; DELETE FROM posts_fts;');
|
||||
|
||||
const insertPost = this.db.prepare(`
|
||||
INSERT INTO posts (slug, lang, title, date, author, category, tags, excerpt, raw_content, cover, is_default)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const insertFts = this.db.prepare(`
|
||||
INSERT INTO posts_fts (slug, lang, title, category, tags, excerpt, content)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const post of postsList) {
|
||||
const tagsStr = (post.tags || []).join(' ');
|
||||
const dateStr = post.date instanceof Date ? post.date.toISOString() : String(post.date);
|
||||
|
||||
insertPost.run(
|
||||
post.slug,
|
||||
post.lang || 'en',
|
||||
post.title || '',
|
||||
dateStr,
|
||||
post.author || '',
|
||||
post.category || '',
|
||||
tagsStr,
|
||||
post.excerpt || '',
|
||||
post.rawContent || '',
|
||||
post.cover || '',
|
||||
post.isDefault ? 1 : 0
|
||||
);
|
||||
|
||||
insertFts.run(
|
||||
post.slug,
|
||||
post.lang || 'en',
|
||||
post.title || '',
|
||||
post.category || '',
|
||||
tagsStr,
|
||||
post.excerpt || '',
|
||||
post.rawContent || ''
|
||||
);
|
||||
}
|
||||
console.log(`[DB] Synced ${postsList.length} post variants to SQLite & FTS5.`);
|
||||
} catch (err) {
|
||||
console.error('[DB] Error syncing posts to SQLite:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Search Query Logging & Recommendations in DB ---
|
||||
logSearch(query, resultCount = 0) {
|
||||
if (!this.db || !query || !query.trim()) return;
|
||||
try {
|
||||
const trimmed = query.trim().slice(0, 150);
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO search_logs (query, result_count, searched_at)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
stmt.run(trimmed, resultCount, new Date().toISOString());
|
||||
|
||||
// Update recommendations clicks or add to recommendations if valid
|
||||
const recStmt = this.db.prepare(`
|
||||
INSERT INTO search_recommendations (label, query, clicks)
|
||||
VALUES (?, ?, 1)
|
||||
ON CONFLICT(query) DO UPDATE SET clicks = clicks + 1
|
||||
`);
|
||||
recStmt.run(trimmed, trimmed);
|
||||
} catch (err) {
|
||||
console.error('[DB] Error logging search:', err);
|
||||
}
|
||||
}
|
||||
|
||||
getTopSearchRecommendations(limit = 6) {
|
||||
if (!this.db) return [];
|
||||
try {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT label, query, clicks, is_pinned
|
||||
FROM search_recommendations
|
||||
ORDER BY is_pinned DESC, clicks DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
return stmt.all(limit);
|
||||
} catch (err) {
|
||||
console.error('[DB] Error fetching recommendations:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// --- Comments & WordPress-Grade Moderation Workflow ---
|
||||
isAuthorWhitelisted(email) {
|
||||
if (!this.db || !email) return false;
|
||||
try {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT COUNT(*) as count FROM comments
|
||||
WHERE author_email = ? AND approved = 1
|
||||
`);
|
||||
const res = stmt.get(email.toLowerCase().trim());
|
||||
return res && res.count > 0;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
addComment({ postSlug, authorName, authorEmail, authorWebsite, body, honeypot, ip }) {
|
||||
if (!this.db) throw new Error('DB not initialized');
|
||||
|
||||
// Honeypot spam check
|
||||
if (honeypot && honeypot.trim().length > 0) {
|
||||
console.warn('[DB] Honeypot triggered, dropping spam.');
|
||||
return { id: -1, status: 'spam' };
|
||||
}
|
||||
|
||||
const cleanName = String(authorName).trim().slice(0, 80);
|
||||
const cleanEmail = authorEmail ? String(authorEmail).toLowerCase().trim().slice(0, 120) : '';
|
||||
const cleanWebsite = authorWebsite ? String(authorWebsite).trim().slice(0, 200) : '';
|
||||
const cleanBody = String(body).trim().slice(0, 5000);
|
||||
const createdAt = new Date().toISOString();
|
||||
|
||||
// WordPress-parity rule: If author's email was previously approved, auto-approve new comment!
|
||||
const autoApprove = cleanEmail ? this.isAuthorWhitelisted(cleanEmail) : false;
|
||||
const approvedStatus = autoApprove ? 1 : 0; // 0: pending moderation
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO comments (post_slug, author_name, author_email, author_website, body, created_at, approved, ip)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(postSlug, cleanName, cleanEmail, cleanWebsite, cleanBody, createdAt, approvedStatus, ip || '');
|
||||
|
||||
return {
|
||||
post_slug: postSlug,
|
||||
author_name: cleanName,
|
||||
author_email: cleanEmail,
|
||||
author_website: cleanWebsite,
|
||||
body: cleanBody,
|
||||
created_at: createdAt,
|
||||
approved: approvedStatus,
|
||||
status: approvedStatus === 1 ? 'approved' : 'pending'
|
||||
};
|
||||
}
|
||||
|
||||
getApprovedCommentsForPost(postSlug) {
|
||||
if (!this.db) return [];
|
||||
try {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT id, post_slug, author_name, author_website, body, created_at
|
||||
FROM comments
|
||||
WHERE post_slug = ? AND approved = 1
|
||||
ORDER BY id ASC
|
||||
`);
|
||||
return stmt.all(postSlug);
|
||||
} catch (err) {
|
||||
console.error('[DB] Error fetching comments:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
getCommentCounts() {
|
||||
if (!this.db) return { all: 0, pending: 0, approved: 0, spam: 0 };
|
||||
try {
|
||||
const all = this.db.prepare('SELECT COUNT(*) as c FROM comments').get().c;
|
||||
const pending = this.db.prepare('SELECT COUNT(*) as c FROM comments WHERE approved = 0').get().c;
|
||||
const approved = this.db.prepare('SELECT COUNT(*) as c FROM comments WHERE approved = 1').get().c;
|
||||
const spam = this.db.prepare('SELECT COUNT(*) as c FROM comments WHERE approved = -1').get().c;
|
||||
return { all, pending, approved, spam };
|
||||
} catch (err) {
|
||||
console.error('[DB] Error fetching comment counts:', err);
|
||||
return { all: 0, pending: 0, approved: 0, spam: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
getPaginatedComments({ status = 'all', q = '', page = 1, limit = 15 } = {}) {
|
||||
if (!this.db) return { comments: [], total: 0, page: 1, totalPages: 1 };
|
||||
try {
|
||||
let whereClauses = [];
|
||||
let params = [];
|
||||
|
||||
if (status === 'pending') {
|
||||
whereClauses.push('approved = 0');
|
||||
} else if (status === 'approved') {
|
||||
whereClauses.push('approved = 1');
|
||||
} else if (status === 'spam') {
|
||||
whereClauses.push('approved = -1');
|
||||
}
|
||||
|
||||
if (q && q.trim()) {
|
||||
whereClauses.push('(author_name LIKE ? OR author_email LIKE ? OR author_website LIKE ? OR body LIKE ? OR post_slug LIKE ? OR ip LIKE ?)');
|
||||
const searchPattern = `%${q.trim()}%`;
|
||||
params.push(searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, searchPattern);
|
||||
}
|
||||
|
||||
const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
|
||||
|
||||
const countStmt = this.db.prepare(`SELECT COUNT(*) as count FROM comments ${whereSql}`);
|
||||
const total = countStmt.get(...params).count;
|
||||
|
||||
const pageNum = Math.max(1, parseInt(page, 10) || 1);
|
||||
const pageSize = Math.max(1, parseInt(limit, 10) || 15);
|
||||
const totalPages = Math.ceil(total / pageSize) || 1;
|
||||
const offset = (pageNum - 1) * pageSize;
|
||||
|
||||
const dataStmt = this.db.prepare(`
|
||||
SELECT id, post_slug, author_name, author_email, author_website, body, created_at, approved, ip
|
||||
FROM comments
|
||||
${whereSql}
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`);
|
||||
|
||||
const comments = dataStmt.all(...params, pageSize, offset);
|
||||
return { comments, total, page: pageNum, totalPages, limit: pageSize };
|
||||
} catch (err) {
|
||||
console.error('[DB] Error in getPaginatedComments:', err);
|
||||
return { comments: [], total: 0, page: 1, totalPages: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
getAllComments(filterApproved = null) {
|
||||
if (!this.db) return [];
|
||||
try {
|
||||
if (filterApproved !== null) {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT id, post_slug, author_name, author_email, author_website, body, created_at, approved, ip
|
||||
FROM comments
|
||||
WHERE approved = ?
|
||||
ORDER BY id DESC
|
||||
`);
|
||||
return stmt.all(filterApproved);
|
||||
}
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT id, post_slug, author_name, author_email, author_website, body, created_at, approved, ip
|
||||
FROM comments
|
||||
ORDER BY id DESC
|
||||
`);
|
||||
return stmt.all();
|
||||
} catch (err) {
|
||||
console.error('[DB] Error fetching all comments:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
updateCommentStatus(commentId, newStatus) {
|
||||
if (!this.db) return false;
|
||||
try {
|
||||
const stmt = this.db.prepare(`UPDATE comments SET approved = ? WHERE id = ?`);
|
||||
stmt.run(newStatus, commentId);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[DB] Error updating comment status:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
deleteComment(commentId) {
|
||||
if (!this.db) return false;
|
||||
try {
|
||||
const stmt = this.db.prepare(`DELETE FROM comments WHERE id = ?`);
|
||||
stmt.run(commentId);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[DB] Error deleting comment:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
batchUpdateCommentStatus(ids, newStatus) {
|
||||
if (!this.db || !Array.isArray(ids) || ids.length === 0) return 0;
|
||||
try {
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
const stmt = this.db.prepare(`UPDATE comments SET approved = ? WHERE id IN (${placeholders})`);
|
||||
const info = stmt.run(newStatus, ...ids);
|
||||
return info.changes;
|
||||
} catch (err) {
|
||||
console.error('[DB] Error batch updating comments:', err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
batchDeleteComments(ids) {
|
||||
if (!this.db || !Array.isArray(ids) || ids.length === 0) return 0;
|
||||
try {
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
const stmt = this.db.prepare(`DELETE FROM comments WHERE id IN (${placeholders})`);
|
||||
const info = stmt.run(...ids);
|
||||
return info.changes;
|
||||
} catch (err) {
|
||||
console.error('[DB] Error batch deleting comments:', err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Admin Dashboard Stats & Logs ---
|
||||
getAdminStats() {
|
||||
if (!this.db) {
|
||||
return { totalPosts: 0, pendingComments: 0, approvedComments: 0, totalSearches: 0 };
|
||||
}
|
||||
try {
|
||||
const postsCount = this.db.prepare('SELECT COUNT(*) as count FROM posts').get()?.count || 0;
|
||||
const pendingCount = this.db.prepare('SELECT COUNT(*) as count FROM comments WHERE approved = 0').get()?.count || 0;
|
||||
const approvedCount = this.db.prepare('SELECT COUNT(*) as count FROM comments WHERE approved = 1').get()?.count || 0;
|
||||
const searchCount = this.db.prepare('SELECT COUNT(*) as count FROM search_logs').get()?.count || 0;
|
||||
|
||||
return {
|
||||
totalPosts: postsCount,
|
||||
pendingComments: pendingCount,
|
||||
approvedComments: approvedCount,
|
||||
totalSearches: searchCount
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[DB] Error getting admin stats:', err);
|
||||
return { totalPosts: 0, pendingComments: 0, approvedComments: 0, totalSearches: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
getAllSearchLogs(limit = 100) {
|
||||
if (!this.db) return [];
|
||||
try {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT id, query, result_count, searched_at
|
||||
FROM search_logs
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
return stmt.all(limit);
|
||||
} catch (err) {
|
||||
console.error('[DB] Error fetching search logs:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new DatabaseService();
|
||||
@@ -0,0 +1,88 @@
|
||||
const MarkdownIt = require('markdown-it');
|
||||
const markdownItAnchor = require('markdown-it-anchor');
|
||||
const markdownItFootnote = require('markdown-it-footnote');
|
||||
const markdownItAttrs = require('markdown-it-attrs');
|
||||
const Prism = require('prismjs');
|
||||
require('prismjs/components/prism-bash');
|
||||
require('prismjs/components/prism-json');
|
||||
require('prismjs/components/prism-yaml');
|
||||
require('prismjs/components/prism-python');
|
||||
require('prismjs/components/prism-go');
|
||||
require('prismjs/components/prism-docker');
|
||||
require('prismjs/components/prism-sql');
|
||||
require('prismjs/components/prism-typescript');
|
||||
require('prismjs/components/prism-markdown');
|
||||
|
||||
function slugifyHeading(s) {
|
||||
return encodeURIComponent(String(s).trim().toLowerCase().replace(/\s+/g, '-'));
|
||||
}
|
||||
|
||||
function createMarkdownRenderer(pluginManager) {
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
highlight: function (str, lang) {
|
||||
if (lang && Prism.languages[lang]) {
|
||||
try {
|
||||
return `<pre class="language-${lang}"><code class="language-${lang}">${Prism.highlight(str, Prism.languages[lang], lang)}</code></pre>`;
|
||||
} catch (__) {}
|
||||
}
|
||||
const escaped = md.utils.escapeHtml(str);
|
||||
return `<pre class="language-text"><code class="language-text">${escaped}</code></pre>`;
|
||||
}
|
||||
});
|
||||
|
||||
md.use(markdownItAnchor, {
|
||||
slugify: slugifyHeading,
|
||||
permalink: markdownItAnchor.permalink.linkInsideHeader({
|
||||
symbol: '#',
|
||||
placement: 'after',
|
||||
ariaHidden: true
|
||||
})
|
||||
});
|
||||
|
||||
md.use(markdownItFootnote);
|
||||
md.use(markdownItAttrs);
|
||||
|
||||
// Disable indented code blocks (4 spaces) so indented HTML elements don't get converted to <pre><code>
|
||||
md.disable('code');
|
||||
|
||||
if (pluginManager) {
|
||||
pluginManager.applyMarkdownHooks(md);
|
||||
}
|
||||
|
||||
return md;
|
||||
}
|
||||
|
||||
function extractToc(markdownContent) {
|
||||
const headings = [];
|
||||
const lines = markdownContent.split(/\r?\n/);
|
||||
let inCodeBlock = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim().startsWith('```')) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
continue;
|
||||
}
|
||||
if (inCodeBlock) continue;
|
||||
|
||||
const match = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (match) {
|
||||
const level = match[1].length;
|
||||
// Strip markdown links/formatting from heading text for TOC display
|
||||
const title = match[2].replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/[*_`]/g, '').trim();
|
||||
const id = slugifyHeading(title);
|
||||
headings.push({ level, title, id });
|
||||
}
|
||||
}
|
||||
|
||||
// Build hierarchical tree if needed or flat list with levels
|
||||
return headings;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createMarkdownRenderer,
|
||||
extractToc,
|
||||
slugifyHeading
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
|
||||
class PluginManager {
|
||||
constructor() {
|
||||
this.plugins = new Map();
|
||||
this.pluginStaticRouters = [];
|
||||
}
|
||||
|
||||
loadPlugins(contentDir, app, siteConfig) {
|
||||
const pluginsDir = path.join(contentDir, 'plugins');
|
||||
this.plugins.clear();
|
||||
|
||||
if (!fs.existsSync(pluginsDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const pluginName = entry.name;
|
||||
const pluginPath = path.join(pluginsDir, pluginName);
|
||||
const manifestPath = path.join(pluginPath, 'plugin.json');
|
||||
const entryScriptPath = path.join(pluginPath, 'index.js');
|
||||
|
||||
if (!fs.existsSync(entryScriptPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
let manifest = { name: pluginName, enabled: true };
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
manifest = { ...manifest, ...JSON.parse(fs.readFileSync(manifestPath, 'utf8')) };
|
||||
}
|
||||
|
||||
// Check if enabled in site config (default true if not explicitly false)
|
||||
const configEntry = siteConfig.plugins && siteConfig.plugins[pluginName];
|
||||
if (configEntry === false || (configEntry && configEntry.enabled === false)) {
|
||||
console.log(`[PluginManager] Plugin ${pluginName} is disabled in config`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clear module cache for hot reload
|
||||
delete require.cache[require.resolve(entryScriptPath)];
|
||||
const pluginModule = require(entryScriptPath);
|
||||
|
||||
const pluginInstance = {
|
||||
name: pluginName,
|
||||
manifest,
|
||||
module: pluginModule,
|
||||
config: (configEntry && typeof configEntry === 'object') ? configEntry : {}
|
||||
};
|
||||
|
||||
// Mount public assets if directory exists
|
||||
const publicDir = path.join(pluginPath, 'public');
|
||||
if (fs.existsSync(publicDir) && app) {
|
||||
app.use(`/plugins/${pluginName}`, express.static(publicDir));
|
||||
}
|
||||
|
||||
// Run onInit hook
|
||||
if (typeof pluginModule.onInit === 'function' && app) {
|
||||
try {
|
||||
pluginModule.onInit({
|
||||
app,
|
||||
config: siteConfig,
|
||||
pluginConfig: pluginInstance.config
|
||||
});
|
||||
} catch (initErr) {
|
||||
console.error(`[PluginManager] Error in onInit for plugin ${pluginName}:`, initErr);
|
||||
}
|
||||
}
|
||||
|
||||
this.plugins.set(pluginName, pluginInstance);
|
||||
console.log(`[PluginManager] Loaded plugin: ${pluginName}`);
|
||||
} catch (err) {
|
||||
console.error(`[PluginManager] Failed to load plugin ${pluginName}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applyMarkdownHooks(markdownIt) {
|
||||
for (const [name, plugin] of this.plugins) {
|
||||
if (typeof plugin.module.onMarkdownInit === 'function') {
|
||||
try {
|
||||
plugin.module.onMarkdownInit({ markdownIt, pluginConfig: plugin.config });
|
||||
} catch (err) {
|
||||
console.error(`[PluginManager] Error in onMarkdownInit for ${name}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applyPostHooks(post) {
|
||||
for (const [name, plugin] of this.plugins) {
|
||||
if (typeof plugin.module.onPostLoad === 'function') {
|
||||
try {
|
||||
plugin.module.onPostLoad({ post, pluginConfig: plugin.config });
|
||||
} catch (err) {
|
||||
console.error(`[PluginManager] Error in onPostLoad for ${name}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async triggerCommentCreated(comment, post) {
|
||||
for (const [name, plugin] of this.plugins) {
|
||||
if (typeof plugin.module.onCommentCreated === 'function') {
|
||||
try {
|
||||
await plugin.module.onCommentCreated({ comment, post, pluginConfig: plugin.config });
|
||||
} catch (err) {
|
||||
console.error(`[PluginManager] Error in onCommentCreated for ${name}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderHook(hookName, context) {
|
||||
let output = '';
|
||||
for (const [name, plugin] of this.plugins) {
|
||||
if (typeof plugin.module[hookName] === 'function') {
|
||||
try {
|
||||
const res = plugin.module[hookName]({ ...context, pluginConfig: plugin.config });
|
||||
if (res && typeof res === 'string') {
|
||||
output += '\n' + res;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[PluginManager] Error in ${hookName} for ${name}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new PluginManager();
|
||||
@@ -0,0 +1,243 @@
|
||||
class SearchEngine {
|
||||
constructor() {
|
||||
this.posts = [];
|
||||
}
|
||||
|
||||
setPosts(posts) {
|
||||
this.posts = posts;
|
||||
}
|
||||
|
||||
// Parse a query like: tag:k8s "container image" category:tech cgroup
|
||||
parseQuery(queryString) {
|
||||
if (!queryString || typeof queryString !== 'string') {
|
||||
return { filters: {}, phrases: [], keywords: [], raw: '' };
|
||||
}
|
||||
|
||||
const raw = queryString.trim();
|
||||
const filters = {
|
||||
tags: [],
|
||||
categories: [],
|
||||
authors: [],
|
||||
langs: []
|
||||
};
|
||||
const phrases = [];
|
||||
const keywords = [];
|
||||
|
||||
// Regex to match: field:value, field:"quoted value", "quoted phrase", or bare words
|
||||
const tokenRegex = /(?:([a-zA-Z_-]+):(?:"([^"]+)"|([^\s]+)))|(?:"([^"]+)")|([^\s]+)/g;
|
||||
let match;
|
||||
|
||||
while ((match = tokenRegex.exec(raw)) !== null) {
|
||||
if (match[1]) {
|
||||
// Schema filter (e.g. tag:k8s, category:tech)
|
||||
const field = match[1].toLowerCase();
|
||||
const value = (match[2] || match[3]).trim().toLowerCase();
|
||||
if (field === 'tag' || field === 'tags') {
|
||||
filters.tags.push(value);
|
||||
} else if (field === 'category' || field === 'cat') {
|
||||
filters.categories.push(value);
|
||||
} else if (field === 'author') {
|
||||
filters.authors.push(value);
|
||||
} else if (field === 'lang') {
|
||||
filters.langs.push(value);
|
||||
} else {
|
||||
// Unknown field, treat as keyword
|
||||
keywords.push(`${field}:${value}`);
|
||||
}
|
||||
} else if (match[4]) {
|
||||
// Quoted exact phrase
|
||||
phrases.push(match[4].trim());
|
||||
} else if (match[5]) {
|
||||
// Bare keyword
|
||||
keywords.push(match[5].trim());
|
||||
}
|
||||
}
|
||||
|
||||
return { filters, phrases, keywords, raw };
|
||||
}
|
||||
|
||||
search(queryString) {
|
||||
const parsed = this.parseQuery(queryString);
|
||||
if (!parsed.raw) {
|
||||
return { query: parsed, results: [], total: 0 };
|
||||
}
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const post of this.posts) {
|
||||
let matches = true;
|
||||
let score = 0;
|
||||
|
||||
// 1. Schema Filter Gate: Category
|
||||
if (parsed.filters.categories.length > 0) {
|
||||
const postCat = (post.category || '').toLowerCase();
|
||||
const catMatch = parsed.filters.categories.every(cat => postCat === cat);
|
||||
if (!catMatch) matches = false;
|
||||
}
|
||||
|
||||
// 2. Schema Filter Gate: Tags
|
||||
if (matches && parsed.filters.tags.length > 0) {
|
||||
const postTags = (post.tags || []).map(t => String(t).toLowerCase());
|
||||
const tagsMatch = parsed.filters.tags.every(reqTag =>
|
||||
postTags.some(t => t === reqTag || t.replace(/[^a-z0-9]/g, '') === reqTag.replace(/[^a-z0-9]/g, ''))
|
||||
);
|
||||
if (!tagsMatch) matches = false;
|
||||
}
|
||||
|
||||
// 3. Schema Filter Gate: Author
|
||||
if (matches && parsed.filters.authors.length > 0) {
|
||||
const postAuthor = (post.author || '').toLowerCase();
|
||||
const authorMatch = parsed.filters.authors.every(a => postAuthor.includes(a));
|
||||
if (!authorMatch) matches = false;
|
||||
}
|
||||
|
||||
// 4. Schema Filter Gate: Lang
|
||||
if (matches && parsed.filters.langs.length > 0) {
|
||||
const postLang = (post.lang || 'en').toLowerCase();
|
||||
const availableLangs = (post.availableLanguages || [postLang]).map(l => l.toLowerCase());
|
||||
const langMatch = parsed.filters.langs.every(l => availableLangs.includes(l));
|
||||
if (!langMatch) matches = false;
|
||||
}
|
||||
|
||||
if (!matches) continue;
|
||||
|
||||
// Searchable text fields
|
||||
const title = post.title || '';
|
||||
const excerpt = post.excerpt || '';
|
||||
const content = post.rawContent || '';
|
||||
const tagsStr = (post.tags || []).join(' ');
|
||||
|
||||
const fullHaystack = `${title}\n${tagsStr}\n${excerpt}\n${content}`.toLowerCase();
|
||||
|
||||
// 5. Exact Phrase Matching (must match ALL quoted phrases)
|
||||
let phraseMatches = true;
|
||||
for (const phrase of parsed.phrases) {
|
||||
const lowerPhrase = phrase.toLowerCase();
|
||||
if (!fullHaystack.includes(lowerPhrase)) {
|
||||
phraseMatches = false;
|
||||
break;
|
||||
}
|
||||
// Score bonus for phrase
|
||||
if (title.toLowerCase().includes(lowerPhrase)) score += 30;
|
||||
if (excerpt.toLowerCase().includes(lowerPhrase)) score += 15;
|
||||
if (content.toLowerCase().includes(lowerPhrase)) score += 10;
|
||||
}
|
||||
if (!phraseMatches) continue;
|
||||
|
||||
// 6. Bare Keywords Matching (must match ALL keywords)
|
||||
let keywordMatches = true;
|
||||
for (const kw of parsed.keywords) {
|
||||
const lowerKw = kw.toLowerCase();
|
||||
if (!fullHaystack.includes(lowerKw)) {
|
||||
keywordMatches = false;
|
||||
break;
|
||||
}
|
||||
if (title.toLowerCase().includes(lowerKw)) score += 15;
|
||||
if (tagsStr.toLowerCase().includes(lowerKw)) score += 10;
|
||||
if (excerpt.toLowerCase().includes(lowerKw)) score += 5;
|
||||
if (content.toLowerCase().includes(lowerKw)) score += 2;
|
||||
}
|
||||
if (!keywordMatches) continue;
|
||||
|
||||
// Recency score bonus (up to 5 points)
|
||||
if (post.date) {
|
||||
const ageInDays = (Date.now() - new Date(post.date).getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (ageInDays > 0) {
|
||||
score += Math.max(0, 5 - Math.log10(ageInDays));
|
||||
}
|
||||
}
|
||||
|
||||
// Generate snippet with highlighted terms
|
||||
const snippet = this.generateSnippet(post, parsed);
|
||||
|
||||
results.push({
|
||||
post,
|
||||
score,
|
||||
snippet
|
||||
});
|
||||
}
|
||||
|
||||
// Deduplicate by post.slug so bilingual variants don't produce duplicate cards
|
||||
const uniqueResultsMap = new Map();
|
||||
for (const res of results) {
|
||||
const existing = uniqueResultsMap.get(res.post.slug);
|
||||
if (!existing || res.score > existing.score) {
|
||||
uniqueResultsMap.set(res.post.slug, res);
|
||||
}
|
||||
}
|
||||
|
||||
const deduplicatedResults = Array.from(uniqueResultsMap.values());
|
||||
|
||||
// Sort by relevance score descending
|
||||
deduplicatedResults.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Log query to SQLite for trending recommendations & analytics
|
||||
try {
|
||||
const dbService = require('./db');
|
||||
dbService.logSearch(queryString, deduplicatedResults.length);
|
||||
} catch (e) {}
|
||||
|
||||
return {
|
||||
query: parsed,
|
||||
results: deduplicatedResults,
|
||||
total: deduplicatedResults.length
|
||||
};
|
||||
}
|
||||
|
||||
generateSnippet(post, parsed) {
|
||||
const text = post.rawContent || post.excerpt || '';
|
||||
const searchTerms = [...parsed.phrases, ...parsed.keywords];
|
||||
if (searchTerms.length === 0) {
|
||||
return post.excerpt ? this.escapeHtml(post.excerpt) : '';
|
||||
}
|
||||
|
||||
// Find position of first matching term
|
||||
let bestIndex = -1;
|
||||
let matchedTerm = '';
|
||||
const lowerText = text.toLowerCase();
|
||||
|
||||
for (const term of searchTerms) {
|
||||
const idx = lowerText.indexOf(term.toLowerCase());
|
||||
if (idx !== -1 && (bestIndex === -1 || idx < bestIndex)) {
|
||||
bestIndex = idx;
|
||||
matchedTerm = term;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestIndex === -1) {
|
||||
return post.excerpt ? this.escapeHtml(post.excerpt) : '';
|
||||
}
|
||||
|
||||
// Extract snippet around bestIndex
|
||||
const start = Math.max(0, bestIndex - 60);
|
||||
const end = Math.min(text.length, bestIndex + 100);
|
||||
let snippet = text.slice(start, end).replace(/\s+/g, ' ');
|
||||
|
||||
if (start > 0) snippet = '...' + snippet;
|
||||
if (end < text.length) snippet = snippet + '...';
|
||||
|
||||
// Highlight terms
|
||||
let safeSnippet = this.escapeHtml(snippet);
|
||||
for (const term of searchTerms) {
|
||||
if (!term) continue;
|
||||
const regex = new RegExp(`(${this.escapeRegex(term)})`, 'gi');
|
||||
safeSnippet = safeSnippet.replace(regex, '<mark>$1</mark>');
|
||||
}
|
||||
|
||||
return safeSnippet;
|
||||
}
|
||||
|
||||
escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
escapeRegex(str) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new SearchEngine();
|
||||
@@ -0,0 +1,60 @@
|
||||
const chokidar = require('chokidar');
|
||||
const path = require('path');
|
||||
const contentService = require('./content');
|
||||
const pluginManager = require('./plugin-manager');
|
||||
const { loadSiteConfig } = require('../config');
|
||||
|
||||
class WatcherService {
|
||||
constructor() {
|
||||
this.watcher = null;
|
||||
this.debounceTimer = null;
|
||||
}
|
||||
|
||||
start(contentDir, app) {
|
||||
console.log(`[Watcher] Starting filesystem watcher on ${contentDir}`);
|
||||
|
||||
this.watcher = chokidar.watch(contentDir, {
|
||||
ignored: [
|
||||
/(^|[\/\\])\../, // dotfiles
|
||||
/(^|[\/\\])data([\/\\]|$)/, // ignore entire data/ directory (site.db, journals, etc.)
|
||||
/\.db(-journal|-wal|-shm)?$/, // sqlite database files
|
||||
/node_modules/
|
||||
],
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 300,
|
||||
pollInterval: 100
|
||||
}
|
||||
});
|
||||
|
||||
const triggerReload = (event, filePath) => {
|
||||
const relative = path.relative(contentDir, filePath);
|
||||
console.log(`[Watcher] Detected ${event} on ${relative}`);
|
||||
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
try {
|
||||
const freshConfig = loadSiteConfig();
|
||||
pluginManager.loadPlugins(contentDir, app, freshConfig);
|
||||
contentService.reload();
|
||||
console.log(`[Watcher] Hot reload finished successfully.`);
|
||||
} catch (err) {
|
||||
console.error(`[Watcher] Error during hot reload:`, err);
|
||||
}
|
||||
}, 250);
|
||||
};
|
||||
|
||||
this.watcher.on('add', (p) => triggerReload('add', p));
|
||||
this.watcher.on('change', (p) => triggerReload('change', p));
|
||||
this.watcher.on('unlink', (p) => triggerReload('unlink', p));
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.watcher) {
|
||||
this.watcher.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new WatcherService();
|
||||
Reference in New Issue
Block a user