Newer
Older
express-blog / src / controllers / blogControllers.js
// src/routes/post.js
const { marked } = require("marked");
const fs = require("fs").promises;
const path = require("path");
const fsSync = require("fs");
const crypto = require("crypto");
const { meta } = require("../config/loader");

const matter = require("gray-matter");
const { getAllPosts } = require("../utils/postFileUtils");

const HttpError = require("../utils/HttpError");

const { node_env } = meta;

exports.blogPost = async (req, res, next) => {
  const { year, month, name } = req.params;

  // Validate year: 4 digits only
  if (!/^\d{4}$/.test(year)) {
    return next(new HttpError("Invalid year parameter.", 400));
  }

  // Validate month: 01-12 only
  if (!/^(0[1-9]|1[0-2])$/.test(month)) {
    return next(new HttpError("Invalid month parameter.", 400));
  }

  // Validate name: allow alphanumeric, dash, underscore only (no dots, no slashes)
  if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
    return next(new HttpError("Invalid post name parameter.", 400));
  }

  const mdPath = path.join(
    __dirname,
    "../../content/posts",
    year,
    month,
    `${name}.md`,
  );

  try {
    const stat = fsSync.statSync(mdPath);
    const fileContent = await fs.readFile(mdPath, "utf8");

    // Generate ETag from content hash
    const hash = crypto.createHash("sha256").update(fileContent).digest("hex");
    const lastModified = stat.mtime.toUTCString();

    res.setHeader("ETag", hash);
    res.setHeader("Last-Modified", lastModified);

    // Check conditional request headers
    if (
      req.headers["if-none-match"] === hash ||
      req.headers["if-modified-since"] === lastModified
    ) {
      res.statusCode = 304;
      return res.end();
    }
    if (req.checkCacheHeaders({ etag: hash, lastModified })) {
      return;
    }

    const { data: frontmatter, content } = matter(fileContent);
    if (
      !frontmatter.published &&
      (node_env === "production" || node_env === "testing")
    ) {
      throw new Error("Attempted to access an unpublished page in production");
    }
    const htmlContent = marked(content);
    const context = {
      showSidebar: !res.locals.isPaper,
      title: frontmatter.title,
      date: frontmatter.date,
      author: frontmatter.author,
      content: htmlContent,
    };
    res.renderWithBaseContext("pages/post", context);
  } catch (err) {
    next(new HttpError("The requested blog post could not be found.", 404));
  }
};

exports.blogIndex = async (req, res) => {
  const postsDir = path.join(__dirname, "../../content/posts");
  const allPosts = await getAllPosts(postsDir, {
    includeUnpublished: req.query.drafts === "true",
  });

  const publishedPosts = allPosts.filter(
    (post) =>
      post.published || node_env === "production" || node_env === "testing",
  );
  // Sort posts descending by date
  publishedPosts.sort((a, b) => new Date(b.date) - new Date(a.date));

  const etagInput = publishedPosts.map((p) => p.id).join(",");
  const etag = `"${crypto.createHash("sha256").update(etagInput).digest("hex")}"`;

  const lastModified =
    publishedPosts.length > 0
      ? new Date(
          Math.max(...publishedPosts.map((p) => new Date(p.date).getTime())),
        ).toUTCString()
      : new Date().toUTCString();

  if (req.checkCacheHeaders({ etag, lastModified })) return;

  // Prepare context compatible with the blog-index.hbs layout
  // Add `templateContent` as excerpt or limited content if needed here
  // For now, use a simple excerpt from markdown or placeholder
  const posts = publishedPosts.map((post) => ({
    url: post.url,
    data: {
      title: post.title,
      date: post.date,
      tags: post.tags,
      published: post.published, // add this
    },
    templateContent: post.excerpt || "",
  }));

  res.renderWithBaseContext("pages/blog_index", {
    showSidebar: !res.locals.isPaper,
    collections: { posts },
  });
};