ravenscott-blog/app.js
2024-09-26 00:18:59 -04:00

354 lines
13 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) {
// Check if the language is valid
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;
// Detect and extract the lead section
const leadKeyword = '<!-- lead -->';
if (contentMarkdown.includes(leadKeyword)) {
const [beforeLead, afterLead] = contentMarkdown.split(leadKeyword);
// Extract the first paragraph after the lead keyword
lead = afterLead.split('\n').find(line => line.trim() !== '').trim();
// Remove the lead from the main content
contentMarkdown = beforeLead + afterLead.replace(lead, '').trim();
}
// Convert markdown to HTML
const contentHtml = marked.parse(contentMarkdown);
return { contentHtml, lead };
}
// Function to convert a title (with spaces and special characters) into a URL-friendly slug (with dashes)
function titleToSlug(title) {
return title
.toLowerCase() // Convert to lowercase
.replace(/[^a-z0-9\s-]/g, '') // Remove all non-alphanumeric characters except spaces and dashes
.replace(/\s+/g, '-'); // Replace spaces with dashes
}
// Function to convert a slug (with dashes) back into a readable title (with spaces)
function slugToTitle(slug) {
return slug.replace(/-/g, ' ');
}
// Function to load all blog posts with pagination support
function getAllBlogPosts(page = 1, postsPerPage = 5) {
const blogFiles = fs.readdirSync(path.join(__dirname, 'markdown')).filter(file => file.endsWith('.md'));
// Sort by birthtime (latest first) like in your RSS feed
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; // Descending order, latest first
});
// Paginate the results
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, ' '); // Keep original casing for title
const slug = titleToSlug(title); // Convert title to slug (lowercase)
// Get the creation time of the markdown file
const stats = fs.statSync(path.join(__dirname, 'markdown', file));
const dateCreated = new Date(stats.birthtime); // Use birthtime for sorting and displaying
return {
title,
slug,
dateCreated, // Keep as Date object for sorting
};
});
// Return the paginated and sorted blog posts and the total pages
return { blogPosts, totalPages };
}
// Home Route (Blog Home with Pagination)
app.get('/', (req, res) => {
const page = parseInt(req.query.page) || 1;
if (page < 1) {
return res.redirect(req.hostname);
}
const postsPerPage = 5; // Set how many posts to display per page
const { blogPosts, totalPages } = getAllBlogPosts(page, postsPerPage);
res.render('index', {
title: 'Raven Scott Blog',
blogPosts,
currentPage: page,
totalPages
});
});
// About Route
app.get('/about', (req, res) => {
res.render('about', { title: 'About Raven Scott' });
});
// Display the Request a Quote form
app.get('/contact', (req, res) => {
res.render('contact', { title: 'Contact Raven Scott', msg: undefined });
});
// Handle contact form submission
app.post('/contact', async (req, res) => {
const { name, email, subject, message, 'g-recaptcha-response': captchaToken } = req.body;
// Validate form inputs (basic example)
if (!name || !email || !subject || !message) {
return res.render('contact', { title: 'Contact Raven Scott', msg: 'All fields are required.' });
}
// Verify the reCAPTCHA token
const captchaSecret = process.env.CAPTCHA_SECRET_KEY; // Your reCAPTCHA 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 Raven Scott', msg: 'Captcha verification failed. Please try again.' });
}
// CAPTCHA passed, proceed with sending email
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>
`;
// Set up Nodemailer transporter
let transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
secure: false, // true for 465, false for other ports
auth: {
user: process.env.EMAIL_USER, // Email user from environment variables
pass: process.env.EMAIL_PASS, // Email password from environment variables
},
tls: {
rejectUnauthorized: false,
},
});
// Set up email options
let mailOptions = {
from: `"${name}" <quote@node-geeks.com>`,
to: process.env.RECEIVER_EMAIL, // Your email address to receive contact form submissions
subject: subject,
html: output,
};
// Send email
transporter.sendMail(mailOptions, (error, info) => {
if (error) {
console.error(error);
return res.render('contact', { title: 'Contact Raven Scott', msg: 'An error occurred. Please try again.' });
} else {
console.log('Email sent: ' + info.response);
return res.render('contact', { title: 'Contact Raven Scott', msg: 'Your message has been sent successfully!' });
}
});
} catch (error) {
console.error('Error verifying CAPTCHA:', error);
return res.render('contact', { title: 'Contact Raven Scott', 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', ''); // Original title with casing
const blogPosts = getAllBlogPosts();
const { contentHtml, lead } = loadMarkdownWithLead(markdownFile);
res.render('blog-post', {
title: originalTitle, // Use the original title with casing
content: contentHtml,
lead: lead,
blogPosts
});
} else {
res.redirect('/'); // Redirect to the home page if the blog post is not found
}
});
// Sitemap Route
// Sitemap Route
app.get('/sitemap.xml', (req, res) => {
const hostname = req.headers.host || 'http://localhost'; // Ensure this is your site URL in production
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; // Sort in descending order (latest first)
});
// Static URLs (e.g., homepage, about, contact)
const staticUrls = [
{ url: '/', changefreq: 'weekly', priority: 1.0 },
{ url: '/about', changefreq: 'monthly', priority: 0.8 },
{ url: '/contact', changefreq: 'monthly', priority: 0.8 }
];
// Dynamic URLs (e.g., blog posts)
const blogUrls = blogFiles.map(file => {
const title = file.replace('.md', '');
const slug = titleToSlug(title);
// Get the last modified date of the markdown file
const stats = fs.statSync(path.join(__dirname, 'markdown', file));
const lastModifiedDate = format(new Date(stats.birthtime), 'yyyy-MM-dd');
return {
url: `https://blog.raven-scott.fyi/${slug}`,
lastmod: lastModifiedDate,
changefreq: 'monthly',
priority: 0.9
};
});
// Combine static and dynamic URLs
const urls = [...staticUrls, ...blogUrls];
// Generate the XML for the sitemap
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>https://${hostname}${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>`;
// Set the content type to XML and send the response
res.header('Content-Type', 'application/xml');
res.send(sitemap);
});
// RSS Feed Route
app.get('/rss', (req, res) => {
const hostname = req.headers.host || 'http://localhost'; // Adjust for production if needed
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; // Sort in descending order (latest first)
});
// Build RSS feed
let rssFeed = `<?xml version="1.0" encoding="UTF-8" ?>\n<rss version="2.0">\n<channel>\n`;
rssFeed += `<title>Raven Scott Blog</title>\n`;
rssFeed += `<link>https://${hostname}</link>\n`;
rssFeed += `<description>This is the RSS feed for Raven Scott's blog.</description>\n`;
// Generate RSS items for each blog post
blogFiles.forEach(file => {
const title = file.replace('.md', '');
const slug = titleToSlug(title);
// Get the last modified date of the markdown file
const stats = fs.statSync(path.join(__dirname, 'markdown', file));
const lastModifiedDate = new Date(stats.birthtime).toUTCString(); // Use UTC date for RSS
// Load and parse markdown content to extract a lead or description
const { lead } = loadMarkdownWithLead(file);
// RSS item for each post
rssFeed += `<item>\n`;
rssFeed += `<title>${title}</title>\n`;
rssFeed += `<link>https://blog.raven-scott.fyi/${slug}</link>\n`;
rssFeed += `<description>${lead || 'Read the full post on the blog.'}</description>\n`;
rssFeed += `<pubDate>${lastModifiedDate}</pubDate>\n`;
rssFeed += `<guid>https://blog.raven-scott.fyi/${slug}</guid>\n`;
rssFeed += `</item>\n`;
});
rssFeed += `</channel>\n</rss>`;
// Set content type to XML and send the RSS feed
res.header('Content-Type', 'application/rss+xml');
res.send(rssFeed);
});
// Global 404 handler for unmatched routes
app.use((req, res) => {
if (req.hostname === 'blog.raven-scott.fyi') {
// Redirect to the main domain
res.redirect('https://raven-scott.fyi');
} else {
// Redirect to home page of the current domain
res.redirect('/');
}
});
// ================================
// Server Listening
// ================================
const PORT = process.env.PORT || 8899;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});