From 92fa1880511ac39a373b740b38c8d12cb60963a9 Mon Sep 17 00:00:00 2001 From: Gabriel Graves Date: Mon, 12 Feb 2024 20:51:11 +0000 Subject: [PATCH 1/7] Refactor news.ts module to remove comments and make file read async --- lib/news.ts | 38 +------------------------------------- 1 file changed, 1 insertion(+), 37 deletions(-) diff --git a/lib/news.ts b/lib/news.ts index d9563ce0..7f1b295b 100644 --- a/lib/news.ts +++ b/lib/news.ts @@ -11,23 +11,6 @@ import remarkGfm from "remark-gfm"; const postsDirectory = path.join(process.cwd(), "news"); -// ------------------------------------------------- -// GET THE DATA OF ALL POSTS IN SORTED ORDER BY DATE -/* - Returns an array that looks like this: - [ - { - slug: 'ssg-ssr', - title: 'When to Use Static Generation v.s. Server-side Rendering', - date: '2020-01-01' - }, - { - slug: 'pre-rendering', - title: 'Two Forms of Pre-rendering', - date: '2020-01-02' - } - ] -*/ export function getSortedPostsData() { const fileNames = fs.readdirSync(postsDirectory); @@ -54,23 +37,6 @@ export function getSortedPostsData() { }); } -// ------------------------------------------------ -// GET THE SLUGS OF ALL POSTS FOR THE DYNAMIC ROUTING -/* - Returns an array that looks like this: - [ - { - params: { - slug: 'ssg-ssr' - } - }, - { - params: { - slug: 'pre-rendering' - } - } - ] -*/ export function getAllPostSlugs() { const fileNames = fs.readdirSync(postsDirectory); @@ -83,11 +49,9 @@ export function getAllPostSlugs() { }); } -// -------------------------------- -// GET THE DATA OF A SINGLE POST FROM THE SLUG export async function getPostData(slug: string) { const fullPath = path.join(postsDirectory, `${slug}.md`); - const fileContents = fs.readFileSync(fullPath, "utf8"); + const fileContents = await fs.promises.readFile(fullPath, "utf8"); const matterResult = matter(fileContents); From 806030ec6c3142201d0034ed49f1881f4831b1da Mon Sep 17 00:00:00 2001 From: Gabriel Graves Date: Mon, 12 Feb 2024 22:07:01 +0000 Subject: [PATCH 2/7] Refactor news.ts and add remarkUtils module --- lib/news.ts | 51 ++++++++++++++++++++------------------------ utils/remarkUtils.ts | 20 +++++++++++++++++ 2 files changed, 43 insertions(+), 28 deletions(-) create mode 100644 utils/remarkUtils.ts diff --git a/lib/news.ts b/lib/news.ts index 7f1b295b..75e9c1d3 100644 --- a/lib/news.ts +++ b/lib/news.ts @@ -2,31 +2,28 @@ import fs from "fs"; import path from "path"; import matter from "gray-matter"; -import { unified } from "unified"; -import remarkParse from "remark-parse"; -import remarkRehype from "remark-rehype"; -import rehypeStringify from "rehype-stringify"; -import rehypeRaw from "rehype-raw"; -import remarkGfm from "remark-gfm"; +import { processMarkdownAsHTML } from "@/utils/remarkUtils"; const postsDirectory = path.join(process.cwd(), "news"); -export function getSortedPostsData() { - const fileNames = fs.readdirSync(postsDirectory); +export async function getSortedPostsData() { + const fileNames = await fs.promises.readdir(postsDirectory); - const allPostsData = fileNames.map((filename) => { - const slug = filename.replace(/\.md$/, ""); + const allPostsData = await Promise.all( + fileNames.map(async (filename) => { + const slug = filename.replace(/\.md$/, ""); - const fullPath = path.join(postsDirectory, filename); - const fileContents = fs.readFileSync(fullPath, "utf8"); + const fullPath = path.join(postsDirectory, filename); + const fileContents = await fs.promises.readFile(fullPath, "utf8"); - const matterResult = matter(fileContents); + const matterResult = matter(fileContents); - return { - slug, - ...(matterResult.data as { date: string; title: string }), - }; - }); + return { + slug, + ...(matterResult.data as { date: string; title: string }), + }; + }) + ); return allPostsData.sort((a, b) => { if (a.date < b.date) { @@ -37,8 +34,8 @@ export function getSortedPostsData() { }); } -export function getAllPostSlugs() { - const fileNames = fs.readdirSync(postsDirectory); +export async function getAllPostSlugs() { + const fileNames = await fs.promises.readdir(postsDirectory); return fileNames.map((fileName) => { return { @@ -51,18 +48,16 @@ export function getAllPostSlugs() { export async function getPostData(slug: string) { const fullPath = path.join(postsDirectory, `${slug}.md`); + const fileContents = await fs.promises.readFile(fullPath, "utf8"); + if (!fileContents) { + throw new Error(`Post with slug "${slug}" does not exist.`); + } + const matterResult = matter(fileContents); - const processedContent = await unified() - .use(remarkParse) - .use(remarkGfm) - .use(remarkRehype, { allowDangerousHtml: true }) - .use(rehypeRaw) - .use(rehypeStringify) - .process(matterResult.content); - const contentHtml = processedContent.toString(); + const contentHtml = await processMarkdownAsHTML(matterResult.content); return { slug, diff --git a/utils/remarkUtils.ts b/utils/remarkUtils.ts new file mode 100644 index 00000000..b53bed9e --- /dev/null +++ b/utils/remarkUtils.ts @@ -0,0 +1,20 @@ +import { unified } from "unified"; +import remarkParse from "remark-parse"; +import remarkRehype from "remark-rehype"; +import rehypeStringify from "rehype-stringify"; +import rehypeRaw from "rehype-raw"; +import remarkGfm from "remark-gfm"; + +export const processMarkdown = (content: string) => { + return unified() + .use(remarkParse) + .use(remarkGfm) + .use(remarkRehype, { allowDangerousHtml: true }) + .use(rehypeRaw) + .use(rehypeStringify) + .process(content); +}; + +export const processMarkdownAsHTML = async (content: string) => { + return (await processMarkdown(content)).toString(); +}; From 4669766c23dc6d53185cf2d19d4c57a7d65adf1c Mon Sep 17 00:00:00 2001 From: Gabriel Graves Date: Mon, 12 Feb 2024 22:07:09 +0000 Subject: [PATCH 3/7] Add unit tests for news library --- lib/__tests__/news.test.ts | 76 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 lib/__tests__/news.test.ts diff --git a/lib/__tests__/news.test.ts b/lib/__tests__/news.test.ts new file mode 100644 index 00000000..d0958de0 --- /dev/null +++ b/lib/__tests__/news.test.ts @@ -0,0 +1,76 @@ +import * as newsLib from "../news"; + +// Mocks +import fs, { Dirent } from "fs"; +import path from "path"; +import "@/utils/remarkUtils"; + +jest.mock("@/utils/remarkUtils", () => ({ + processMarkdownAsHTML: jest + .fn() + .mockResolvedValue("

Mocked HTML content

"), +})); + +jest.mock("fs", () => ({ + promises: { + readdir: jest.fn(), + readFile: jest.fn(), + }, +})); + +// Tests + +describe("News Library", () => { + beforeEach(() => { + const mockFileNames = ["post1.md", "post2.md"]; + const mockFileContents = [ + '---\ntitle: Post 1\ndate: "2022-01-01"\n---\n\nPost 1 content', + '---\ntitle: Post 2\ndate: "2022-01-02"\n---\n\nPost 2 content', + ]; + + jest + .spyOn(fs.promises, "readdir") + .mockResolvedValue(mockFileNames as unknown as Dirent[]); + + jest.spyOn(fs.promises, "readFile").mockImplementation((filePath) => { + const index = mockFileNames.indexOf(path.basename(filePath.toString())); + return Promise.resolve(mockFileContents[index]); + }); + }); + + describe("getSortedPostsData", () => { + it("should return an array of sorted post data", async () => { + const sortedPosts = await newsLib.getSortedPostsData(); + expect(sortedPosts).toStrictEqual([ + { slug: "post2", date: "2022-01-02", title: "Post 2" }, + { slug: "post1", date: "2022-01-01", title: "Post 1" }, + ]); + }); + }); + + describe("getAllPostSlugs", () => { + it("should return an array of all post slugs", async () => { + const postSlugs = await newsLib.getAllPostSlugs(); + expect(postSlugs).toStrictEqual([ + { params: { slug: "post1" } }, + { params: { slug: "post2" } }, + ]); + }); + }); + + describe("getPostData", () => { + it("should return the data of a specific post", async () => { + const slug = "post1"; + const postData = await newsLib.getPostData(slug); + expect(postData).toBeDefined(); + expect(postData.date.toString()).toBe("2022-01-01"); + expect(postData.slug).toBe(slug); + expect(postData.contentHtml).toBe("

Mocked HTML content

"); + }); + + it("should throw an error if the post does not exist", async () => { + const slug = "non-existent-post"; + await expect(newsLib.getPostData(slug)).rejects.toThrow(); + }); + }); +}); From 6cb9806e2f16d13d0e1e088288af6764f185e859 Mon Sep 17 00:00:00 2001 From: Gabriel Graves Date: Mon, 12 Feb 2024 22:55:05 +0000 Subject: [PATCH 4/7] Add slug validation and not found handling --- app/[locale]/news/[slug]/page.tsx | 47 ++++++++++++++++++++----------- lib/__tests__/news.test.ts | 31 ++++++++++++++++++++ lib/news.ts | 20 +++++++++++++ 3 files changed, 81 insertions(+), 17 deletions(-) diff --git a/app/[locale]/news/[slug]/page.tsx b/app/[locale]/news/[slug]/page.tsx index b81a9ae2..40a52b3e 100644 --- a/app/[locale]/news/[slug]/page.tsx +++ b/app/[locale]/news/[slug]/page.tsx @@ -1,6 +1,7 @@ import Date from "@/components/Date"; -import { getPostData } from "@/lib/news"; +import { checkIfSlugIsValid, getPostData } from "@/lib/news"; +import { notFound } from "next/navigation"; export type Params = { slug: string; @@ -17,7 +18,15 @@ export type PostData = { }; export async function generateMetadata({ params }: Props) { - const postData: PostData = await getPostData(params.slug); + const slug = params.slug; + + if (!(await checkIfSlugIsValid(slug))) { + return { + title: "Not Found", + }; + } + + const postData: PostData = await getPostData(slug); return { title: postData.title, @@ -25,24 +34,28 @@ export async function generateMetadata({ params }: Props) { } export default async function Post({ params }: Props) { + const slug = params.slug; + + if (!(await checkIfSlugIsValid(slug))) { + notFound(); + } + const postData: PostData = await getPostData(params.slug); return ( - <> -
-
-

- -

-

- {postData.title} -

-
-
+
+
+

+ +

+

+ {postData.title} +

+
- +
); } diff --git a/lib/__tests__/news.test.ts b/lib/__tests__/news.test.ts index d0958de0..ec4f2e22 100644 --- a/lib/__tests__/news.test.ts +++ b/lib/__tests__/news.test.ts @@ -13,6 +13,7 @@ jest.mock("@/utils/remarkUtils", () => ({ jest.mock("fs", () => ({ promises: { + access: jest.fn(), readdir: jest.fn(), readFile: jest.fn(), }, @@ -28,6 +29,11 @@ describe("News Library", () => { '---\ntitle: Post 2\ndate: "2022-01-02"\n---\n\nPost 2 content', ]; + jest.spyOn(fs.promises, "access").mockImplementation((filePath) => { + const index = mockFileNames.indexOf(path.basename(filePath.toString())); + return index >= 0 ? Promise.resolve() : Promise.reject(); + }); + jest .spyOn(fs.promises, "readdir") .mockResolvedValue(mockFileNames as unknown as Dirent[]); @@ -38,6 +44,31 @@ describe("News Library", () => { }); }); + describe("checkIfSlugIsValid", () => { + it("should return true if the slug is valid", async () => { + const slug = "post1"; + const isValid = await newsLib.checkIfSlugIsValid(slug); + expect(isValid).toBe(true); + }); + + it("should return false if the slug is blank", async () => { + const slug = ""; + const isValid = await newsLib.checkIfSlugIsValid(slug); + expect(isValid).toBe(false); + }); + + it("should return false if the slug is invalid", async () => { + const slug = "invalid-slug"; + const isValid = await newsLib.checkIfSlugIsValid(slug); + expect(isValid).toBe(false); + }); + + it("should throw an error if the slug is in an invalid format", async () => { + const slug = "invalid/slug"; + await expect(newsLib.checkIfSlugIsValid(slug)).rejects.toThrow(); + }); + }); + describe("getSortedPostsData", () => { it("should return an array of sorted post data", async () => { const sortedPosts = await newsLib.getSortedPostsData(); diff --git a/lib/news.ts b/lib/news.ts index 75e9c1d3..861c9941 100644 --- a/lib/news.ts +++ b/lib/news.ts @@ -6,6 +6,26 @@ import { processMarkdownAsHTML } from "@/utils/remarkUtils"; const postsDirectory = path.join(process.cwd(), "news"); +export async function checkIfSlugIsValid(slug: string) { + if (!slug || typeof slug !== "string") { + return false; + } + + // Check that the slug does not contain any slashes to prevent directory traversal + if (slug.includes("/") || slug.includes("\\")) { + throw new Error("Invalid slug format."); + } + + const fullPath = path.join(postsDirectory, `${slug}.md`); + + try { + await fs.promises.access(fullPath); + return true; + } catch { + return false; + } +} + export async function getSortedPostsData() { const fileNames = await fs.promises.readdir(postsDirectory); From 1fb66df198f8d97d0f70cf5c0ca14a44bb00c438 Mon Sep 17 00:00:00 2001 From: Gabriel Graves Date: Mon, 12 Feb 2024 22:55:23 +0000 Subject: [PATCH 5/7] Add SlugNotFound component and update en.json for news page --- app/[locale]/news/[slug]/not-found.tsx | 21 +++++++++++++++++++++ messages/en.json | 6 ++++++ 2 files changed, 27 insertions(+) create mode 100644 app/[locale]/news/[slug]/not-found.tsx diff --git a/app/[locale]/news/[slug]/not-found.tsx b/app/[locale]/news/[slug]/not-found.tsx new file mode 100644 index 00000000..92d0cc14 --- /dev/null +++ b/app/[locale]/news/[slug]/not-found.tsx @@ -0,0 +1,21 @@ +import Link from "next/link"; +import { useTranslations } from "next-intl"; + +export default function SlugNotFound() { + const t = useTranslations("news.notFound"); + + return ( +
+
+

+ {t("title")} +

+
+ {t.rich("description", { + newsLink: (chunks) => {chunks}, + })} +
+
+
+ ); +} diff --git a/messages/en.json b/messages/en.json index e6587237..257932c8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -66,5 +66,11 @@ "description": "Migrate from other Enterprise Linux distributions without sweating it. We provide an easy-to-use migration script, free of charge." } } + }, + "news": { + "notFound": { + "title": "Not Found", + "description": "The news post you are looking for does not exist. You can find all our news posts on the news page." + } } } From c92092b36bd8b21c4227d656008ebbff12f542fa Mon Sep 17 00:00:00 2001 From: Gabriel Graves Date: Mon, 12 Feb 2024 23:02:25 +0000 Subject: [PATCH 6/7] Fix referenced variable passed to getPostData function --- app/[locale]/news/[slug]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/[locale]/news/[slug]/page.tsx b/app/[locale]/news/[slug]/page.tsx index 40a52b3e..c2370d9d 100644 --- a/app/[locale]/news/[slug]/page.tsx +++ b/app/[locale]/news/[slug]/page.tsx @@ -40,7 +40,7 @@ export default async function Post({ params }: Props) { notFound(); } - const postData: PostData = await getPostData(params.slug); + const postData: PostData = await getPostData(slug); return (
From 361c5fcf69cfdae4c422ec89b9fdeac04d2ee44c Mon Sep 17 00:00:00 2001 From: Gabriel Graves Date: Mon, 12 Feb 2024 23:05:35 +0000 Subject: [PATCH 7/7] Refactor slug validation logic to return false instead of throwing an error --- lib/__tests__/news.test.ts | 5 +++-- lib/news.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/__tests__/news.test.ts b/lib/__tests__/news.test.ts index ec4f2e22..f8212a6f 100644 --- a/lib/__tests__/news.test.ts +++ b/lib/__tests__/news.test.ts @@ -63,9 +63,10 @@ describe("News Library", () => { expect(isValid).toBe(false); }); - it("should throw an error if the slug is in an invalid format", async () => { + it("should return false if the slug is in an invalid format", async () => { const slug = "invalid/slug"; - await expect(newsLib.checkIfSlugIsValid(slug)).rejects.toThrow(); + const isValid = await newsLib.checkIfSlugIsValid(slug); + expect(isValid).toBe(false); }); }); diff --git a/lib/news.ts b/lib/news.ts index 861c9941..36b23775 100644 --- a/lib/news.ts +++ b/lib/news.ts @@ -13,7 +13,7 @@ export async function checkIfSlugIsValid(slug: string) { // Check that the slug does not contain any slashes to prevent directory traversal if (slug.includes("/") || slug.includes("\\")) { - throw new Error("Invalid slug format."); + return false; } const fullPath = path.join(postsDirectory, `${slug}.md`);