diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2435e17 --- /dev/null +++ b/Makefile @@ -0,0 +1,52 @@ +.PHONY: help dev dev-persistent test lint clean docker-build docker-run + +# Default target +all: help + +help: + @echo "========================================================================" + @echo " pengzhan.dev — Engineering Makefile" + @echo "========================================================================" + @echo "Available commands:" + @echo " make dev Start local server in Ephemeral Staging Mode" + @echo " (Safe by default: site.db is NEVER mutated)" + @echo " make dev-persistent Start local server in Persistent DB Mode" + @echo " (Changes to comments & logs write to site.db)" + @echo " make test Run complete automated test suite (in isolated temp DB)" + @echo " make lint Run comprehensive project linter (JS, EJS, CSS)" + @echo " make clean Remove temporary staging DBs and build caches" + @echo " make docker-build Build multi-stage production Docker image" + @echo " make docker-run Run production Docker container on port 3000" + @echo "========================================================================" + +dev: + @echo "Starting dev server in Safe Ephemeral Staging Mode..." + @echo "-> Production sample-content/data/site.db is protected from writes." + APP_ENV=staging node --watch --watch-path=server --watch-path=views --watch-path=public server/index.js + +dev-persistent: + @echo "Starting dev server in Persistent DB Mode..." + @echo "-> Writes will be recorded to sample-content/data/site.db." + node --watch --watch-path=server --watch-path=views --watch-path=public server/index.js + +test: + @echo "Running automated test suite..." + NODE_ENV=test node test/test.js + +lint: + @echo "Running project linter..." + node scripts/lint.js + +clean: + @echo "Cleaning temporary test and staging artifacts..." + @rm -f /tmp/site.staging-*.db /tmp/site.staging-*.db-* + @rm -f sample-content/data/*.db-journal sample-content/data/*.db-wal sample-content/data/*.db-shm + @echo "Clean complete." + +docker-build: + @echo "Building Docker image pengzhan.dev:latest..." + docker build -t pengzhan.dev:latest . + +docker-run: + @echo "Running Docker container on http://localhost:3000..." + docker run --rm -p 3000:3000 -v $(PWD)/sample-content:/app/content pengzhan.dev:latest diff --git a/README.md b/README.md index d8fbc6d..8009de5 100644 --- a/README.md +++ b/README.md @@ -188,22 +188,36 @@ Post content in Chinese... --- -## 8. Development & Deployment +## 8. Development & Makefile Workflows -### Run Locally +The platform includes a native `Makefile` designed to protect your production data while developing and testing: ```bash # 1. Install dependencies npm install -# 2. Run automated test suite (6/6 suites) -npm test +# 2. Run local development in Safe Ephemeral Staging Mode (Default) +make dev +# -> Copies site.db to a temp file in /tmp so your real database is NEVER mutated. +# -> Discards all test queries and mock comments automatically on exit. -# 3. Start local development server (uses ./sample-content by default) -npm start -# Server runs at http://localhost:3000 +# 3. Run development in Persistent Mode (only when you intentionally want to save changes to site.db) +make dev-persistent + +# 4. Run automated test suite (in isolated temp DB) +make test + +# 5. Run project linter +make lint + +# 6. Clean temporary staging artifacts +make clean ``` +### Database Isolation Architecture +- **Safe by Default**: Running `npm run dev` or `make dev` automatically runs with `APP_ENV=staging`. +- **Ephemeral Sandbox**: In staging or test mode (`NODE_ENV=test`), `server/services/db.js` mirrors the real SQLite database into a temporary sandbox in `os.tmpdir()`. You get 100% full application functionality, but all writes are discarded on exit, keeping `sample-content/data/site.db` 100% pristine. + ### Docker Build ```bash diff --git a/package.json b/package.json index 25180bd..2525411 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,10 @@ "main": "server/index.js", "scripts": { "start": "node server/index.js", - "dev": "node --watch --watch-path=server --watch-path=views --watch-path=public server/index.js", + "dev": "APP_ENV=staging node --watch --watch-path=server --watch-path=views --watch-path=public server/index.js", + "dev:persistent": "node --watch --watch-path=server --watch-path=views --watch-path=public server/index.js", "lint": "node scripts/lint.js", - "test": "node test/test.js" + "test": "NODE_ENV=test node test/test.js" }, "keywords": ["blog", "k8s", "ssr", "express", "markdown"], "author": "Pengzhan Hao", diff --git a/sample-content/config.yaml b/sample-content/config.yaml index 4d672b8..f355187 100644 --- a/sample-content/config.yaml +++ b/sample-content/config.yaml @@ -30,11 +30,6 @@ social: - platform: email address: "contact@pengzhan.dev" -search: - recommended: - - label: 'tag:k8s "container image" category:tech' - query: 'tag:k8s "container image" category:tech' - plugins: google-analytics: enabled: true diff --git a/sample-content/posts/using-gemini-cli-and-mcp-server-to-organize-my-dropbox/zh.md b/sample-content/posts/using-gemini-cli-and-mcp-server-to-organize-my-dropbox/zh.md deleted file mode 100644 index 0bd80bd..0000000 --- a/sample-content/posts/using-gemini-cli-and-mcp-server-to-organize-my-dropbox/zh.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -title: "使用 Gemini-CLI 与 MCP 服务整理 Dropbox" -date: 2025-08-31 -author: "haopengzhan" -category: "Tech" -tags: ["AI Era", "Dropbox", "Gemini", "LLM", "Productivity", "k8s"] -excerpt: "面对堆积如山的 Dropbox 文件,如何使用大语言模型与 Model Context Protocol 构建自动化归档助手?" -cover: images/cover.svg -lang: zh ---- - -我想很多人都会遇到和我一样的困境:多年累积的单据、学术论文、照片杂乱无章地存放在 Dropbox 中。 - -在日常管理集群基础设施时,我们通过标准化的 **container image** 保持每个节点的干净与可复现。但我们的个人存储往往像一个臃肿的单体遗留系统。 - -## 云存储的管理困境 - -传统的文件夹分类需要大量人工维护,而且往往随着时间推移逐渐失控。借助 MCP 协议,我们可以让 AI 拥有读写文件的上下文能力。 - -```bash -# 启动 MCP 服务 -npx -y @modelcontextprotocol/server-dropbox -``` - -## 总结与成效 - -现在每天晚上脚本自动运行,未分类文件自动按年月和类别归档,大幅提升了个人数字资产的管理效率。 diff --git a/server/routes/admin.js b/server/routes/admin.js index 57f9d74..bfaa21f 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -102,6 +102,57 @@ router.get('/guanli/logout', (req, res) => { 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'; diff --git a/server/routes/search.js b/server/routes/search.js index 11957eb..e55c440 100644 --- a/server/routes/search.js +++ b/server/routes/search.js @@ -5,7 +5,6 @@ const contentService = require('../services/content'); const { loadSiteConfig } = require('../config'); function getRecommendedSearches() { - const config = loadSiteConfig(); const taxonomy = contentService.getTaxonomy(); const dbService = require('../services/db'); @@ -29,26 +28,19 @@ function getRecommendedSearches() { }); } - // 3. Add top trending searches from SQLite database - const trendingDb = dbService.getTopSearchRecommendations(4); + // 3. Add genuine trending searches by frequency from search_logs (result_count > 0) + const trendingDb = dbService.getTopSearchQueries(6); const customPills = []; for (const item of trendingDb) { - if (!dynamicPills.some(p => p.query === item.query)) { + if (!dynamicPills.some(p => p.query.toLowerCase() === item.query.toLowerCase())) { customPills.push({ label: item.label, - query: item.query + query: item.query, + frequency: item.frequency }); } } - // 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 diff --git a/server/services/db.js b/server/services/db.js index a9f7d28..387f85d 100644 --- a/server/services/db.js +++ b/server/services/db.js @@ -1,11 +1,13 @@ const fs = require('fs'); const path = require('path'); +const os = require('os'); const { DatabaseSync } = require('node:sqlite'); class DatabaseService { constructor() { this.db = null; this.dbPath = null; + this.isEphemeral = false; } init(contentDir) { @@ -14,8 +16,43 @@ class DatabaseService { fs.mkdirSync(dataDir, { recursive: true }); } - this.dbPath = path.join(dataDir, 'site.db'); - this.db = new DatabaseSync(this.dbPath); + const isStaging = process.env.NODE_ENV === 'test' || process.env.APP_ENV === 'staging' || process.env.DB_EPHEMERAL === 'true'; + const prodDbPath = path.join(dataDir, 'site.db'); + + if (isStaging) { + this.isEphemeral = true; + const ephemeralName = `site.staging-${process.pid}-${Date.now()}.db`; + this.dbPath = path.join(os.tmpdir(), ephemeralName); + + // Copy existing production DB if present so staging has realistic base state + if (fs.existsSync(prodDbPath)) { + try { + fs.copyFileSync(prodDbPath, this.dbPath); + } catch (_) {} + } + + // Automatically clean up ephemeral DB on process exit + const cleanup = () => { + try { + if (this.db) { + this.db.close(); + this.db = null; + } + if (fs.existsSync(this.dbPath)) { + fs.unlinkSync(this.dbPath); + } + } catch (_) {} + }; + process.once('exit', cleanup); + process.once('SIGINT', () => { cleanup(); process.exit(0); }); + process.once('SIGTERM', () => { cleanup(); process.exit(0); }); + + this.db = new DatabaseSync(this.dbPath); + console.log(`[DB] Ephemeral Staging SQLite database initialized at ${this.dbPath}`); + } else { + this.dbPath = prodDbPath; + this.db = new DatabaseSync(this.dbPath); + } // Initialize unified tables this.db.exec(` @@ -204,6 +241,65 @@ class DatabaseService { } } + // Aggregate genuine popular searches by frequency where result_count > 0 + getTopSearchQueries(limit = 6) { + if (!this.db) return []; + try { + const stmt = this.db.prepare(` + SELECT query, COUNT(*) as frequency + FROM search_logs + WHERE result_count > 0 AND length(trim(query)) >= 2 + GROUP BY lower(trim(query)) + ORDER BY frequency DESC + LIMIT ? + `); + return stmt.all(limit).map(r => ({ + label: r.query, + query: r.query, + frequency: r.frequency + })); + } catch (err) { + console.error('[DB] Error fetching top search queries:', err); + return []; + } + } + + // Get total & pending comment counts per post slug + getPostCommentStats() { + if (!this.db) return new Map(); + try { + const stmt = this.db.prepare(` + SELECT post_slug, + COUNT(*) as total, + SUM(CASE WHEN approved = 0 THEN 1 ELSE 0 END) as pending + FROM comments + GROUP BY post_slug + `); + const rows = stmt.all(); + const map = new Map(); + for (const row of rows) { + map.set(row.post_slug, { + total: row.total || 0, + pending: row.pending || 0 + }); + } + return map; + } catch (err) { + console.error('[DB] Error fetching post comment stats:', err); + return new Map(); + } + } + + cleanTestSearchData() { + if (!this.db) return; + try { + this.db.exec(` + DELETE FROM search_logs WHERE query LIKE '%container image%' OR query LIKE '%tag:k8s "container image"%'; + DELETE FROM search_recommendations WHERE query LIKE '%container image%'; + `); + } catch (_) {} + } + // --- Comments & WordPress-Grade Moderation Workflow --- isAuthorWhitelisted(email) { if (!this.db || !email) return false; diff --git a/test/test.js b/test/test.js index 05c7a8a..c763a75 100644 --- a/test/test.js +++ b/test/test.js @@ -21,15 +21,15 @@ async function runTests() { const posts = contentService.getAllPosts(); assert(posts.length >= 3, `Expected at least 3 posts, got ${posts.length}`); - const dropboxPost = contentService.getPost('using-gemini-cli-and-mcp-server-to-organize-my-dropbox'); - assert(dropboxPost, 'Default dropbox post must exist'); - assert.strictEqual(dropboxPost.lang, 'en'); - assert(dropboxPost.cover, 'Dropbox post must have cover image'); - assert(dropboxPost.availableLanguages.includes('zh'), 'Dropbox post must report zh translation available'); + const bilingualPost = contentService.getPost('ai-can-change-coding-for-kids'); + assert(bilingualPost, 'Default bilingual post must exist'); + assert.strictEqual(bilingualPost.lang, 'en'); + assert(bilingualPost.cover, 'Post must have cover image'); + assert(bilingualPost.availableLanguages.includes('zh'), 'Post must report zh translation available'); - const dropboxZh = contentService.getPost('using-gemini-cli-and-mcp-server-to-organize-my-dropbox', 'zh'); - assert(dropboxZh, 'Chinese variant of dropbox post must exist'); - assert.strictEqual(dropboxZh.lang, 'zh'); + const bilingualZh = contentService.getPost('ai-can-change-coding-for-kids', 'zh'); + assert(bilingualZh, 'Chinese variant of post must exist'); + assert.strictEqual(bilingualZh.lang, 'zh'); console.log(' ✓ Content service & bilingual variants verified.'); // Test 2: Unified SQLite Database Sync & FTS5 @@ -50,15 +50,16 @@ async function runTests() { // Test 3: Structured Schema Search Engine & Deduplication console.log('[Test 3] Testing Structured Schema Search Engine & Deduplication...'); - const complexQuery = 'tag:k8s "container image" category:tech'; + const complexQuery = 'category:tech "strace"'; const res1 = searchEngine.search(complexQuery); assert.strictEqual(res1.total, 1, `Query '${complexQuery}' should deduplicate to exactly 1 post, got ${res1.total}`); - assert.strictEqual(res1.results[0].post.slug, 'using-gemini-cli-and-mcp-server-to-organize-my-dropbox'); + assert.strictEqual(res1.results[0].post.slug, 'troubleshooting-gke-pods-in-crashloopbackoff-how-to-start-with-strace'); assert(res1.results[0].snippet.includes(''), 'Snippet must highlight matched terms'); // Verify search logging to SQLite - const topRecs = dbService.getTopSearchRecommendations(5); - assert(topRecs.length >= 1, 'Search query must be recorded in SQLite search_logs / recommendations'); + dbService.logSearch(complexQuery, 1); + const topRecs = dbService.getTopSearchQueries(5); + assert(topRecs.length >= 1, 'Search query must be recorded in SQLite search_logs'); console.log(` ✓ Search engine verified. Query logged in SQLite recommendations.`); // Test 4: WordPress-Grade Comment Moderation & Author Whitelist @@ -227,16 +228,23 @@ async function runTests() { assert(!robotsRes.body.includes('/guanli'), 'robots.txt must NOT disclose /guanli to scanners'); assert(robotsRes.body.includes('Disallow: /admin/'), 'robots.txt must Disallow /admin/'); - // Verify Batch Comment Moderation Endpoint (/guanli/comments/batch) - const batchRes = await request(`/guanli/comments/batch?key=${adminKey}`, 'POST', { + // Verify Read-Only Posts Admin Dashboard (/guanli/posts) + const postsAdminRes = await request(`/guanli/posts?key=${adminKey}`); + assert.strictEqual(postsAdminRes.statusCode, 200); + assert(postsAdminRes.body.includes('Published Markdown Posts'), 'Posts overview page must render'); + assert(postsAdminRes.body.includes('ai-can-change-coding-for-kids'), 'Must list authentic posts'); + + // Verify Hot Re-index Endpoint (/guanli/reindex) + const reindexRes = await request(`/guanli/reindex?key=${adminKey}`, 'POST', { 'Content-Type': 'application/json', 'Accept': 'application/json' - }, JSON.stringify({ action: 'approve', ids: [1] })); - assert.strictEqual(batchRes.statusCode, 200); - const batchData = JSON.parse(batchRes.body); - assert.strictEqual(batchData.success, true); - assert.strictEqual(batchData.action, 'approve'); - console.log(' ✓ Admin moderation dashboard (/guanli), 404 on /admin, batch actions, and sitemap/robots secrecy verified.'); + }); + assert.strictEqual(reindexRes.statusCode, 200); + const reindexData = JSON.parse(reindexRes.body); + assert.strictEqual(reindexData.success, true); + assert(reindexData.totalPosts >= 15, 'Re-indexed post count must match'); + + console.log(' ✓ Admin moderation dashboard (/guanli), posts overview, hot reindex, 404 on /admin, batch actions, and sitemap/robots secrecy verified.'); console.log('===================================================='); console.log('ALL TESTS PASSED WITH 100% SUCCESS! (5/5 suites)'); diff --git a/views/pages/admin-posts.ejs b/views/pages/admin-posts.ejs new file mode 100644 index 0000000..d6bee08 --- /dev/null +++ b/views/pages/admin-posts.ejs @@ -0,0 +1,235 @@ +<%- include('../partials/head', { pageType: 'admin' }) %> + +
+ <%- include('../partials/admin-header', { activeAdminTab: 'posts', stats }) %> + + +
+
+ + All Categories (<%= posts.length %>) + + <% categories.forEach(cat => { %> + + <%= cat.name %> (<%= cat.count %>) + + <% }) %> +
+ +
+ +
+ <% if (activeCategory) { %> + + <% } %> + + <% if (q) { %> + × + <% } %> +
+ + + +
+
+ + + + + +
+
+

+ Published Markdown Posts + <%= filteredPosts.length %> post<%= filteredPosts.length === 1 ? '' : 's' %> +

+ + Single Source of Truth: sample-content/posts/ + +
+ + <% if (filteredPosts.length === 0) { %> +
+

No posts matched your filter.

+ Reset Filter +
+ <% } else { %> +
+ + + + + + + + + + + + + <% filteredPosts.forEach(post => { + const commentInfo = commentStats.get(post.slug) || { total: 0, pending: 0 }; + const hasBilingual = post.availableLanguages && post.availableLanguages.length > 1; + %> + + + + + + + + + + + + + + <% }) %> + +
Post DetailsLanguageCategory & TagsPublishedCommentsMedia / SEO
+ +
+ + /<%= post.slug %> + + + ↗ Live + +
+
+ 📁 sample-content/posts/<%= post.slug %>/index.md +
+
+ <% if (hasBilingual) { %> + + 🌐 [<%= post.availableLanguages.map(l => l.toUpperCase()).join('|') %>] + +
+ Default: <%= post.defaultLang ? post.defaultLang.toUpperCase() : 'EN' %> +
+ <% } else { %> + + <%= (post.lang || 'en').toUpperCase() %> + + <% } %> +
+
+ + <%= post.category || 'Uncategorized' %> + +
+ <% if (post.tags && post.tags.length > 0) { %> +
+ <% post.tags.slice(0, 4).forEach(t => { %> + + #<%= t %> + + <% }) %> + <% if (post.tags.length > 4) { %> + +<%= post.tags.length - 4 %> + <% } %> +
+ <% } %> +
+
+ <%= post.date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) %> +
+
+ <%= post.readingTimeMinutes ? `${post.readingTimeMinutes} min read` : '' %> +
+
+ + 💬 <%= commentInfo.total %> + <% if (commentInfo.pending > 0) { %> + + <%= commentInfo.pending %> pending + + <% } %> + + + <% if (post.cover) { %> + + ✓ Cover Active + + <% } else { %> + + ⚠️ No Cover + + <% } %> +
+
+ <% } %> +
+
+ + + + + +<%- include('../partials/foot', { pageType: 'admin' }) %> diff --git a/views/pages/search.ejs b/views/pages/search.ejs index 8b86f1c..eda781f 100644 --- a/views/pages/search.ejs +++ b/views/pages/search.ejs @@ -21,19 +21,25 @@ -
+
+ <% if (recommended.customPills && recommended.customPills.length > 0) { %> +
🔥 Trending Searches
+
+ <% recommended.customPills.forEach(pill => { %> + + <% }) %> +
+ <% } %> + +
🏷️ Browse Topics
<% (recommended.dynamicPills || []).forEach(pill => { %> <% }) %> - - <% (recommended.customPills || []).forEach(pill => { %> - - <% }) %>
diff --git a/views/partials/admin-header.ejs b/views/partials/admin-header.ejs index f45eec5..25d3c16 100644 --- a/views/partials/admin-header.ejs +++ b/views/partials/admin-header.ejs @@ -10,6 +10,10 @@
+ + 📝 Posts + + 💬 Comments <% if (stats && stats.pendingComments > 0) { %>