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 = ''; 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 = `

You have a new contact request from ${name}.

Contact Details

Message

${message}

`; 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 = `\n\n`; urls.forEach(({ url, lastmod, changefreq, priority }) => { sitemap += ` \n`; sitemap += ` ${url}\n`; if (lastmod) { sitemap += ` ${lastmod}\n`; } sitemap += ` ${changefreq}\n`; sitemap += ` ${priority}\n`; sitemap += ` \n`; }); sitemap += ``; 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 = `\n\n\n`; rssFeed += `${process.env.OWNER_NAME} Blog\n`; rssFeed += `https://${hostname}\n`; rssFeed += `This is the RSS feed for ${process.env.OWNER_NAME}'s blog.\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 += `\n`; rssFeed += `${title}\n`; rssFeed += `${process.env.BLOG_URL}${slug}\n`; rssFeed += `${lead || 'Read the full post on the blog.'}\n`; rssFeed += `${lastModifiedDate}\n`; rssFeed += `${process.env.BLOG_URL}${slug}\n`; rssFeed += `\n`; }); rssFeed += `\n`; res.header('Content-Type', 'application/rss+xml'); res.send(rssFeed); }); // Function to parse markdown and format as robots.txt function generateRobotsTxt(env) { const robotsConfigFile = path.join(__dirname, 'me', 'robots.md'); const markdownContent = fs.readFileSync(robotsConfigFile, 'utf-8'); const sections = markdownContent.split('###').map(section => section.trim()).filter(Boolean); let configSection = sections.find(section => section.startsWith(env.charAt(0).toUpperCase() + env.slice(1))); if (configSection) { configSection = configSection.split('\n').slice(1); // Remove the section title return configSection.map(line => line.replace('- ', '')).join('\n'); // Remove Markdown list dashes } // Default fallback if no matching environment is found return 'User-agent: *\nDisallow: /'; } // Robots.txt Route app.get('/robots.txt', (req, res) => { const env = process.env.NODE_ENV || 'development'; // Default to 'development' if not set const robotsContent = generateRobotsTxt(env); res.type('text/plain'); res.send(robotsContent); }); // 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}`); });