forked from snxraven/ravenscott-blog
336 lines
12 KiB
JavaScript
336 lines
12 KiB
JavaScript
require('dotenv').config(); // Load environment variables
|
|
const express = require('express');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const { marked } = require('marked');
|
|
const nodemailer = require('nodemailer');
|
|
const hljs = require('highlight.js');
|
|
const { format } = require('date-fns'); // To format dates in a proper XML format
|
|
const axios = require('axios'); // Add axios for reCAPTCHA verification
|
|
|
|
const app = express();
|
|
|
|
// Set options for marked to use highlight.js for syntax highlighting
|
|
marked.setOptions({
|
|
highlight: function (code, language) {
|
|
const validLanguage = hljs.getLanguage(language) ? language : 'plaintext';
|
|
return hljs.highlight(validLanguage, code).value;
|
|
}
|
|
});
|
|
|
|
// Set EJS as templating engine
|
|
app.set('view engine', 'ejs');
|
|
app.set('views', path.join(__dirname, 'views'));
|
|
|
|
// Middleware to parse URL-encoded bodies (form submissions)
|
|
app.use(express.urlencoded({ extended: false }));
|
|
|
|
// Serve static files (CSS, Images)
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
|
|
// Function to load and parse markdown files and extract lead
|
|
function loadMarkdownWithLead(file) {
|
|
const markdownContent = fs.readFileSync(path.join(__dirname, 'markdown', file), 'utf-8');
|
|
|
|
let lead = '';
|
|
let contentMarkdown = markdownContent;
|
|
|
|
const leadKeyword = '<!-- lead -->';
|
|
if (contentMarkdown.includes(leadKeyword)) {
|
|
const [beforeLead, afterLead] = contentMarkdown.split(leadKeyword);
|
|
lead = afterLead.split('\n').find(line => line.trim() !== '').trim();
|
|
contentMarkdown = beforeLead + afterLead.replace(lead, '').trim();
|
|
}
|
|
|
|
const contentHtml = marked.parse(contentMarkdown);
|
|
return { contentHtml, lead };
|
|
}
|
|
|
|
// Function to convert a title into a URL-friendly slug
|
|
function titleToSlug(title) {
|
|
return title
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9\s-]/g, '')
|
|
.replace(/\s+/g, '-');
|
|
}
|
|
|
|
// Function to convert a slug back into a readable title
|
|
function slugToTitle(slug) {
|
|
return slug.replace(/-/g, ' ');
|
|
}
|
|
|
|
// Function to load all blog posts with pagination and search support
|
|
function getAllBlogPosts(page = 1, postsPerPage = 5, searchQuery = '') {
|
|
let blogFiles = fs.readdirSync(path.join(__dirname, 'markdown')).filter(file => file.endsWith('.md'));
|
|
|
|
if (searchQuery) {
|
|
const lowerCaseQuery = searchQuery.toLowerCase();
|
|
blogFiles = blogFiles.filter(file => file.toLowerCase().includes(lowerCaseQuery));
|
|
}
|
|
|
|
if (blogFiles.length === 0) {
|
|
return { blogPosts: [], totalPages: 0 }; // Return empty results if no files
|
|
}
|
|
|
|
blogFiles.sort((a, b) => {
|
|
const statA = fs.statSync(path.join(__dirname, 'markdown', a)).birthtime;
|
|
const statB = fs.statSync(path.join(__dirname, 'markdown', b)).birthtime;
|
|
return statB - statA;
|
|
});
|
|
|
|
const totalPosts = blogFiles.length;
|
|
const totalPages = Math.ceil(totalPosts / postsPerPage);
|
|
const start = (page - 1) * postsPerPage;
|
|
const end = start + postsPerPage;
|
|
|
|
const paginatedFiles = blogFiles.slice(start, end);
|
|
|
|
const blogPosts = paginatedFiles.map(file => {
|
|
const title = file.replace('.md', '').replace(/-/g, ' ');
|
|
const slug = titleToSlug(title);
|
|
const stats = fs.statSync(path.join(__dirname, 'markdown', file));
|
|
const dateCreated = new Date(stats.birthtime);
|
|
|
|
return { title, slug, dateCreated };
|
|
});
|
|
|
|
return { blogPosts, totalPages };
|
|
}
|
|
|
|
// Home Route (Blog Home with Pagination and Search)
|
|
app.get('/', (req, res) => {
|
|
const page = parseInt(req.query.page) || 1;
|
|
const searchQuery = req.query.search || '';
|
|
|
|
if (page < 1) {
|
|
return res.redirect(req.hostname);
|
|
}
|
|
|
|
const postsPerPage = 5;
|
|
const { blogPosts, totalPages } = getAllBlogPosts(page, postsPerPage, searchQuery);
|
|
|
|
const noResults = blogPosts.length === 0; // Check if there are no results
|
|
|
|
res.render('index', {
|
|
title: `${process.env.OWNER_NAME}'s Blog`,
|
|
blogPosts,
|
|
currentPage: page,
|
|
totalPages,
|
|
searchQuery, // Pass search query to the view
|
|
noResults // Pass this flag to indicate no results found
|
|
});
|
|
});
|
|
|
|
|
|
// About Route (Load markdown and render using EJS)
|
|
app.get('/about', (req, res) => {
|
|
const aboutMarkdownFile = path.join(__dirname, 'me', 'about.md');
|
|
|
|
// Read the markdown file and convert to HTML
|
|
fs.readFile(aboutMarkdownFile, 'utf-8', (err, data) => {
|
|
if (err) {
|
|
return res.status(500).send('Error loading About page');
|
|
}
|
|
|
|
const aboutContentHtml = marked(data); // Convert markdown to HTML
|
|
|
|
res.render('about', {
|
|
title: `About ${process.env.OWNER_NAME}`,
|
|
content: aboutContentHtml
|
|
});
|
|
});
|
|
});
|
|
|
|
// Display the Request a Quote form
|
|
app.get('/contact', (req, res) => {
|
|
res.render('contact', { title: `Contact ${process.env.OWNER_NAME}`, msg: undefined });
|
|
});
|
|
|
|
// Handle contact form submission
|
|
app.post('/contact', async (req, res) => {
|
|
const { name, email, subject, message, 'g-recaptcha-response': captchaToken } = req.body;
|
|
|
|
if (!name || !email || !subject || !message) {
|
|
return res.render('contact', { title: `Contact ${process.env.OWNER_NAME}`, msg: 'All fields are required.' });
|
|
}
|
|
|
|
const captchaSecret = process.env.CAPTCHA_SECRET_KEY;
|
|
const captchaVerifyUrl = `https://www.google.com/recaptcha/api/siteverify?secret=${captchaSecret}&response=${captchaToken}`;
|
|
|
|
try {
|
|
const captchaResponse = await axios.post(captchaVerifyUrl);
|
|
if (!captchaResponse.data.success) {
|
|
return res.render('contact', { title: `Contact ${process.env.OWNER_NAME}`, msg: 'Captcha verification failed. Please try again.' });
|
|
}
|
|
|
|
const output = `
|
|
<p>You have a new contact request from <strong>${name}</strong>.</p>
|
|
<h3>Contact Details</h3>
|
|
<ul>
|
|
<li><strong>Name:</strong> ${name}</li>
|
|
<li><strong>Email:</strong> ${email}</li>
|
|
<li><strong>Subject:</strong> ${subject}</li>
|
|
</ul>
|
|
<h3>Message</h3>
|
|
<p>${message}</p>
|
|
`;
|
|
|
|
let transporter = nodemailer.createTransport({
|
|
host: process.env.SMTP_HOST,
|
|
port: process.env.SMTP_PORT,
|
|
secure: false,
|
|
auth: {
|
|
user: process.env.EMAIL_USER,
|
|
pass: process.env.EMAIL_PASS,
|
|
},
|
|
tls: { rejectUnauthorized: false },
|
|
});
|
|
|
|
let mailOptions = {
|
|
from: `"${name}" <${process.env.RECEIVER_EMAIL}>`,
|
|
to: process.env.RECEIVER_EMAIL,
|
|
subject: subject,
|
|
html: output,
|
|
};
|
|
|
|
transporter.sendMail(mailOptions, (error, info) => {
|
|
if (error) {
|
|
return res.render('contact', { title: `Contact ${process.env.OWNER_NAME}`, msg: 'An error occurred. Please try again.' });
|
|
}
|
|
return res.render('contact', { title: `Contact ${process.env.OWNER_NAME}`, msg: 'Your message has been sent successfully!' });
|
|
});
|
|
} catch (error) {
|
|
return res.render('contact', { title: `Contact ${process.env.OWNER_NAME}`, msg: 'An error occurred while verifying CAPTCHA. Please try again.' });
|
|
}
|
|
});
|
|
|
|
// Blog Post Route
|
|
app.get('/blog/:slug', (req, res) => {
|
|
const slug = req.params.slug;
|
|
const markdownFile = fs.readdirSync(path.join(__dirname, 'markdown'))
|
|
.find(file => titleToSlug(file.replace('.md', '')) === slug);
|
|
|
|
if (markdownFile) {
|
|
const originalTitle = markdownFile.replace('.md', '');
|
|
const blogPosts = getAllBlogPosts();
|
|
const { contentHtml, lead } = loadMarkdownWithLead(markdownFile);
|
|
|
|
res.render('blog-post', {
|
|
title: originalTitle,
|
|
content: contentHtml,
|
|
lead: lead,
|
|
blogPosts
|
|
});
|
|
} else {
|
|
res.redirect('/');
|
|
}
|
|
});
|
|
|
|
// Sitemap Route
|
|
app.get('/sitemap.xml', (req, res) => {
|
|
const hostname = req.headers.host || 'http://localhost';
|
|
const blogFiles = fs.readdirSync(path.join(__dirname, 'markdown'))
|
|
.filter(file => file.endsWith('.md'))
|
|
.sort((a, b) => {
|
|
const statA = fs.statSync(path.join(__dirname, 'markdown', a)).birthtime;
|
|
const statB = fs.statSync(path.join(__dirname, 'markdown', b)).birthtime;
|
|
return statB - statA;
|
|
});
|
|
|
|
const staticUrls = [
|
|
{ url: `${process.env.HOST_URL}`, changefreq: 'weekly', priority: 1.0 },
|
|
{ url: `${process.env.HOST_URL}/about`, changefreq: 'monthly', priority: 0.8 },
|
|
{ url: `${process.env.HOST_URL}/contact`, changefreq: 'monthly', priority: 0.8 }
|
|
];
|
|
|
|
const blogUrls = blogFiles.map(file => {
|
|
const title = file.replace('.md', '');
|
|
const slug = titleToSlug(title);
|
|
const stats = fs.statSync(path.join(__dirname, 'markdown', file));
|
|
const lastModifiedDate = format(new Date(stats.birthtime), 'yyyy-MM-dd');
|
|
|
|
return {
|
|
url: `${process.env.BLOG_URL}${slug}`,
|
|
lastmod: lastModifiedDate,
|
|
changefreq: 'monthly',
|
|
priority: 0.9
|
|
};
|
|
});
|
|
|
|
const urls = [...staticUrls, ...blogUrls];
|
|
|
|
let sitemap = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n`;
|
|
urls.forEach(({ url, lastmod, changefreq, priority }) => {
|
|
sitemap += ` <url>\n`;
|
|
sitemap += ` <loc>${url}</loc>\n`;
|
|
if (lastmod) {
|
|
sitemap += ` <lastmod>${lastmod}</lastmod>\n`;
|
|
}
|
|
sitemap += ` <changefreq>${changefreq}</changefreq>\n`;
|
|
sitemap += ` <priority>${priority}</priority>\n`;
|
|
sitemap += ` </url>\n`;
|
|
});
|
|
sitemap += `</urlset>`;
|
|
|
|
res.header('Content-Type', 'application/xml');
|
|
res.send(sitemap);
|
|
});
|
|
|
|
// RSS Feed Route
|
|
app.get('/rss', (req, res) => {
|
|
const hostname = req.headers.host || 'http://localhost';
|
|
const blogFiles = fs.readdirSync(path.join(__dirname, 'markdown'))
|
|
.filter(file => file.endsWith('.md'))
|
|
.sort((a, b) => {
|
|
const statA = fs.statSync(path.join(__dirname, 'markdown', a)).birthtime;
|
|
const statB = fs.statSync(path.join(__dirname, 'markdown', b)).birthtime;
|
|
return statB - statA;
|
|
});
|
|
|
|
let rssFeed = `<?xml version="1.0" encoding="UTF-8" ?>\n<rss version="2.0">\n<channel>\n`;
|
|
rssFeed += `<title>${process.env.OWNER_NAME} Blog</title>\n`;
|
|
rssFeed += `<link>https://${hostname}</link>\n`;
|
|
rssFeed += `<description>This is the RSS feed for ${process.env.OWNER_NAME}'s blog.</description>\n`;
|
|
|
|
blogFiles.forEach(file => {
|
|
const title = file.replace('.md', '');
|
|
const slug = titleToSlug(title);
|
|
const stats = fs.statSync(path.join(__dirname, 'markdown', file));
|
|
const lastModifiedDate = new Date(stats.birthtime).toUTCString();
|
|
const { lead } = loadMarkdownWithLead(file);
|
|
|
|
rssFeed += `<item>\n`;
|
|
rssFeed += `<title>${title}</title>\n`;
|
|
rssFeed += `<link>${process.env.BLOG_URL}${slug}</link>\n`;
|
|
rssFeed += `<description>${lead || 'Read the full post on the blog.'}</description>\n`;
|
|
rssFeed += `<pubDate>${lastModifiedDate}</pubDate>\n`;
|
|
rssFeed += `<guid>${process.env.BLOG_URL}${slug}</guid>\n`;
|
|
rssFeed += `</item>\n`;
|
|
});
|
|
|
|
rssFeed += `</channel>\n</rss>`;
|
|
res.header('Content-Type', 'application/rss+xml');
|
|
res.send(rssFeed);
|
|
});
|
|
|
|
// Create a URL object from the environment variable
|
|
const blog_URL = new URL(process.env.BLOG_URL);
|
|
|
|
// Extract just the hostname (e.g., blog.raven-scott.fyi)
|
|
const hostname = blog_URL.hostname;
|
|
|
|
// Global 404 handler for unmatched routes
|
|
app.use((req, res) => {
|
|
if (req.hostname === hostname) {
|
|
res.redirect(process.env.HOST_URL);
|
|
} else {
|
|
res.redirect('/');
|
|
}
|
|
});
|
|
|
|
// Server Listening
|
|
const PORT = process.env.PORT || 8899;
|
|
app.listen(PORT, () => {
|
|
console.log(`Server running on http://localhost:${PORT}`);
|
|
});
|