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:
346
chat/server.js
346
chat/server.js
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user