feat: implement comprehensive blog system

Add dual-source blog system with Editor.js integration:
- Blog storage supporting repo-based (JSON) and database sources
- Admin panel with rich text editor using Editor.js
- Public news page with infinite scroll
- Individual blog post viewer page
- Categories management in admin
- Image upload functionality
- 4 SEO blog posts about WordPress with PluginCompass promotion
- 3 News blog posts about Plugin Compass
- API endpoints for CRUD operations
- Security and validation for admin operations

Closes blog feature request
This commit is contained in:
southseact-3d
2026-02-10 13:23:37 +00:00
parent 82ae9687b8
commit cfd8d9c706
17 changed files with 5126 additions and 10 deletions

View File

@@ -16,6 +16,7 @@ const nodemailer = require('nodemailer');
const PDFDocument = require('pdfkit');
const security = require('./security');
const { createExternalWpTester, getExternalTestingConfig } = require('./external-wp-testing');
const blogSystem = require('./blog-system');
let sharp = null;
try {
@@ -11413,6 +11414,282 @@ async function handleContactMessageDelete(req, res, id) {
sendJson(res, 200, { ok: true });
}
// Blog API Handlers
async function handleBlogsList(req, res, url) {
try {
const type = url.searchParams.get('type');
const category = url.searchParams.get('category');
const limit = parseInt(url.searchParams.get('limit')) || 10;
const offset = parseInt(url.searchParams.get('offset')) || 0;
const blogs = await blogSystem.getAllBlogs({
type,
category,
status: 'published',
limit,
offset
});
// Sanitize response (don't send full content in list)
const sanitized = blogs.map(b => ({
id: b.id,
slug: b.slug,
type: b.type,
title: b.title,
excerpt: b.excerpt,
author: b.author,
featured_image: b.featured_image,
category: b.category,
tags: b.tags,
published_at: b.published_at,
updated_at: b.updated_at
}));
sendJson(res, 200, { posts: sanitized, total: blogs.length });
} catch (error) {
log('Failed to list blogs', { error: String(error) });
sendJson(res, 500, { error: 'Failed to list blogs' });
}
}
async function handleBlogGet(req, res, slug) {
try {
const blog = await blogSystem.getBlogBySlug(slug);
if (!blog) {
return sendJson(res, 404, { error: 'Blog post not found' });
}
// Convert blocks to HTML for easier consumption
const html = blogSystem.blocksToHtml(blog.content?.blocks || []);
sendJson(res, 200, {
post: {
...blog,
html
}
});
} catch (error) {
log('Failed to get blog', { error: String(error), slug });
sendJson(res, 500, { error: 'Failed to get blog post' });
}
}
async function handleBlogCategoriesList(req, res) {
try {
const categories = await blogSystem.getCategories();
sendJson(res, 200, { categories });
} catch (error) {
log('Failed to list blog categories', { error: String(error) });
sendJson(res, 500, { error: 'Failed to list categories' });
}
}
async function handleAdminBlogsList(req, res, url) {
try {
const type = url.searchParams.get('type');
const status = url.searchParams.get('status');
const source = url.searchParams.get('source');
const category = url.searchParams.get('category');
const blogs = await blogSystem.getAllBlogs({ type, status, source, category });
const categories = await blogSystem.getCategories();
sendJson(res, 200, { posts: blogs, categories });
} catch (error) {
log('Failed to list admin blogs', { error: String(error) });
sendJson(res, 500, { error: 'Failed to list blogs' });
}
}
async function handleAdminBlogCreate(req, res) {
try {
const body = await readBodyJson(req);
const { title, slug, type, content, excerpt, author, featured_image, meta_title, meta_description, category, tags, status } = body;
if (!title || !slug || !type) {
return sendJson(res, 400, { error: 'Title, slug, and type are required' });
}
const newPost = await blogSystem.createBlog({
title,
slug,
type,
content,
excerpt,
author: author || 'Admin',
featured_image,
meta_title,
meta_description,
category,
tags: tags || [],
status: status || 'draft'
});
sendJson(res, 201, { post: newPost });
} catch (error) {
log('Failed to create blog', { error: String(error) });
sendJson(res, 500, { error: error.message || 'Failed to create blog post' });
}
}
async function handleAdminBlogUpdate(req, res, id) {
try {
const body = await readBodyJson(req);
const { title, slug, content, excerpt, author, featured_image, meta_title, meta_description, category, tags, status, published_at } = body;
const updated = await blogSystem.updateBlog(id, {
title,
slug,
content,
excerpt,
author,
featured_image,
meta_title,
meta_description,
category,
tags,
status,
published_at
});
sendJson(res, 200, { post: updated });
} catch (error) {
log('Failed to update blog', { error: String(error), id });
sendJson(res, 500, { error: error.message || 'Failed to update blog post' });
}
}
async function handleAdminBlogDelete(req, res, id) {
try {
await blogSystem.deleteBlog(id);
sendJson(res, 200, { ok: true });
} catch (error) {
log('Failed to delete blog', { error: String(error), id });
sendJson(res, 500, { error: error.message || 'Failed to delete blog post' });
}
}
async function handleAdminBlogCategoryCreate(req, res) {
try {
const body = await readBodyJson(req);
const { name, slug, description } = body;
if (!name || !slug) {
return sendJson(res, 400, { error: 'Name and slug are required' });
}
const category = await blogSystem.createCategory({ name, slug, description });
sendJson(res, 201, { category });
} catch (error) {
log('Failed to create blog category', { error: String(error) });
sendJson(res, 500, { error: error.message || 'Failed to create category' });
}
}
async function handleAdminBlogCategoryUpdate(req, res, id) {
try {
const body = await readBodyJson(req);
const { name, slug, description } = body;
const updated = await blogSystem.updateCategory(id, { name, slug, description });
sendJson(res, 200, { category: updated });
} catch (error) {
log('Failed to update blog category', { error: String(error), id });
sendJson(res, 500, { error: error.message || 'Failed to update category' });
}
}
async function handleAdminBlogCategoryDelete(req, res, id) {
try {
await blogSystem.deleteCategory(id);
sendJson(res, 200, { ok: true });
} catch (error) {
log('Failed to delete blog category', { error: String(error), id });
sendJson(res, 500, { error: error.message || 'Failed to delete category' });
}
}
async function handleAdminBlogImageUpload(req, res) {
try {
const contentType = req.headers['content-type'] || '';
if (!contentType.includes('multipart/form-data')) {
return sendJson(res, 400, { error: 'Expected multipart/form-data' });
}
// Parse multipart form data manually
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
// Simple boundary parsing
const boundary = contentType.split('boundary=')[1];
if (!boundary) {
return sendJson(res, 400, { error: 'No boundary found' });
}
// Find file data (simplified - in production use proper multipart parser)
const boundaryBuffer = Buffer.from('--' + boundary);
const parts = [];
let start = 0;
while (true) {
const idx = buffer.indexOf(boundaryBuffer, start);
if (idx === -1) break;
const nextIdx = buffer.indexOf(boundaryBuffer, idx + boundaryBuffer.length);
const part = buffer.slice(idx + boundaryBuffer.length, nextIdx !== -1 ? nextIdx : buffer.length);
parts.push(part);
start = idx + boundaryBuffer.length;
if (nextIdx === -1) break;
}
// Process first file part
let fileData = null;
let fileName = null;
for (const part of parts) {
const headerEnd = part.indexOf('\r\n\r\n');
if (headerEnd === -1) continue;
const headers = part.slice(0, headerEnd).toString();
const data = part.slice(headerEnd + 4);
if (headers.includes('filename="')) {
const match = headers.match(/filename="([^"]+)"/);
if (match) {
fileName = match[1];
// Remove trailing \r\n--
fileData = data.slice(0, -4);
break;
}
}
}
if (!fileData || !fileName) {
return sendJson(res, 400, { error: 'No file found' });
}
// Generate unique filename
const ext = path.extname(fileName) || '.jpg';
const uniqueName = `blog-${Date.now()}-${Math.random().toString(36).substr(2, 9)}${ext}`;
const filePath = path.join(blogSystem.BLOGS_UPLOAD_DIR, uniqueName);
await fs.writeFile(filePath, fileData);
sendJson(res, 200, {
success: 1,
file: {
url: `/blogs/images/${uniqueName}`,
name: uniqueName
}
});
} catch (error) {
log('Failed to upload blog image', { error: String(error) });
sendJson(res, 500, { error: 'Failed to upload image' });
}
}
async function handleUserLogout(req, res) {
const session = getUserSession(req);
if (session) {
@@ -17099,6 +17376,57 @@ async function routeInternal(req, res, url, pathname) {
if (req.method === 'POST' && contactMessageReadMatch) return handleContactMessageMarkRead(req, res, contactMessageReadMatch[1]);
const contactMessageDeleteMatch = pathname.match(/^\/api\/contact\/messages\/([a-f0-9\-]+)$/i);
if (req.method === 'DELETE' && contactMessageDeleteMatch) return handleContactMessageDelete(req, res, contactMessageDeleteMatch[1]);
// Blog API Routes
if (req.method === 'GET' && pathname === '/api/blogs') return handleBlogsList(req, res, url);
if (req.method === 'GET' && pathname === '/api/blogs/categories') return handleBlogCategoriesList(req, res);
const blogDetailMatch = pathname.match(/^\/api\/blogs\/([^\/]+)$/i);
if (req.method === 'GET' && blogDetailMatch) return handleBlogGet(req, res, blogDetailMatch[1]);
if (req.method === 'GET' && pathname === '/api/admin/blogs') {
const session = requireAdminAuth(req, res);
if (!session) return;
return handleAdminBlogsList(req, res, url);
}
if (req.method === 'POST' && pathname === '/api/admin/blogs') {
const session = requireAdminAuth(req, res);
if (!session) return;
return handleAdminBlogCreate(req, res);
}
const adminBlogUpdateMatch = pathname.match(/^\/api\/admin\/blogs\/([^\/]+)$/i);
if (req.method === 'PUT' && adminBlogUpdateMatch) {
const session = requireAdminAuth(req, res);
if (!session) return;
return handleAdminBlogUpdate(req, res, adminBlogUpdateMatch[1]);
}
const adminBlogDeleteMatch = pathname.match(/^\/api\/admin\/blogs\/([^\/]+)$/i);
if (req.method === 'DELETE' && adminBlogDeleteMatch) {
const session = requireAdminAuth(req, res);
if (!session) return;
return handleAdminBlogDelete(req, res, adminBlogDeleteMatch[1]);
}
if (req.method === 'POST' && pathname === '/api/admin/blogs/categories') {
const session = requireAdminAuth(req, res);
if (!session) return;
return handleAdminBlogCategoryCreate(req, res);
}
const adminBlogCategoryUpdateMatch = pathname.match(/^\/api\/admin\/blogs\/categories\/([^\/]+)$/i);
if (req.method === 'PUT' && adminBlogCategoryUpdateMatch) {
const session = requireAdminAuth(req, res);
if (!session) return;
return handleAdminBlogCategoryUpdate(req, res, adminBlogCategoryUpdateMatch[1]);
}
const adminBlogCategoryDeleteMatch = pathname.match(/^\/api\/admin\/blogs\/categories\/([^\/]+)$/i);
if (req.method === 'DELETE' && adminBlogCategoryDeleteMatch) {
const session = requireAdminAuth(req, res);
if (!session) return;
return handleAdminBlogCategoryDelete(req, res, adminBlogCategoryDeleteMatch[1]);
}
if (req.method === 'POST' && pathname === '/api/admin/blogs/upload-image') {
const session = requireAdminAuth(req, res);
if (!session) return;
return handleAdminBlogImageUpload(req, res);
}
if (req.method === 'POST' && pathname === '/api/login') return handleUserLogin(req, res);
if (req.method === 'POST' && pathname === '/api/register') return handleUserRegister(req, res);
if (req.method === 'POST' && pathname === '/api/logout') return handleUserLogout(req, res);
@@ -17505,6 +17833,15 @@ async function routeInternal(req, res, url, pathname) {
if (pathname === '/faq' || pathname === '/faqs') return serveFile(res, safeStaticPath('faq.html'), 'text/html');
if (pathname === '/settings') return serveFile(res, safeStaticPath('settings.html'), 'text/html');
if (pathname === '/feature-requests') return serveFile(res, safeStaticPath('feature-requests.html'), 'text/html');
// Blog pages
if (pathname === '/news' || pathname === '/news/') return serveFile(res, safeStaticPath('news.html'), 'text/html');
const blogPostMatch = pathname.match(/^\/blog\/(.+)$/i);
if (blogPostMatch) return serveFile(res, safeStaticPath('blog.html'), 'text/html');
if (pathname === '/admin/blogs') {
const session = getAdminSession(req);
if (!session) return serveFile(res, safeStaticPath('admin-login.html'), 'text/html');
return serveFile(res, safeStaticPath('admin-blogs.html'), 'text/html');
}
// Serve legal pages with proper caching headers
if (pathname === '/terms') return serveFile(res, safeStaticPath('terms.html'), 'text/html');
if (pathname === '/privacy') return serveFile(res, safeStaticPath('privacy.html'), 'text/html');
@@ -17688,6 +18025,15 @@ async function bootstrap() {
} catch (err) {
log('OpenCode process manager initialization failed (will fall back to per-message spawning)', { error: String(err) });
}
// Initialize blog system
log('Initializing blog system...');
try {
await blogSystem.initBlogStorage();
log('Blog system initialized successfully');
} catch (err) {
log('Blog system initialization failed', { error: String(err) });
}
// Restore interrupted sessions after restart
await restoreInterruptedSessions();