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/app/[locale]/news/[slug]/page.tsx b/app/[locale]/news/[slug]/page.tsx index b81a9ae2..c2370d9d 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 postData: PostData = await getPostData(params.slug); + const slug = params.slug; + + if (!(await checkIfSlugIsValid(slug))) { + notFound(); + } + + const postData: PostData = await getPostData(slug); return ( - <> -
-
-

- -

-

- {postData.title} -

-
-
+
+
+

+ +

+

+ {postData.title} +

+
- +
); } diff --git a/lib/__tests__/news.test.ts b/lib/__tests__/news.test.ts new file mode 100644 index 00000000..f8212a6f --- /dev/null +++ b/lib/__tests__/news.test.ts @@ -0,0 +1,108 @@ +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: { + access: jest.fn(), + 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, "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[]); + + jest.spyOn(fs.promises, "readFile").mockImplementation((filePath) => { + const index = mockFileNames.indexOf(path.basename(filePath.toString())); + return Promise.resolve(mockFileContents[index]); + }); + }); + + 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 return false if the slug is in an invalid format", async () => { + const slug = "invalid/slug"; + const isValid = await newsLib.checkIfSlugIsValid(slug); + expect(isValid).toBe(false); + }); + }); + + 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(); + }); + }); +}); diff --git a/lib/news.ts b/lib/news.ts index d9563ce0..36b23775 100644 --- a/lib/news.ts +++ b/lib/news.ts @@ -2,48 +2,48 @@ 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"); -// ------------------------------------------------- -// 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); +export async function checkIfSlugIsValid(slug: string) { + if (!slug || typeof slug !== "string") { + return false; + } - const allPostsData = fileNames.map((filename) => { - const slug = filename.replace(/\.md$/, ""); + // Check that the slug does not contain any slashes to prevent directory traversal + if (slug.includes("/") || slug.includes("\\")) { + return false; + } - const fullPath = path.join(postsDirectory, filename); - const fileContents = fs.readFileSync(fullPath, "utf8"); + const fullPath = path.join(postsDirectory, `${slug}.md`); - const matterResult = matter(fileContents); + try { + await fs.promises.access(fullPath); + return true; + } catch { + return false; + } +} - return { - slug, - ...(matterResult.data as { date: string; title: string }), - }; - }); +export async function getSortedPostsData() { + const fileNames = await fs.promises.readdir(postsDirectory); + + const allPostsData = await Promise.all( + fileNames.map(async (filename) => { + const slug = filename.replace(/\.md$/, ""); + + const fullPath = path.join(postsDirectory, filename); + const fileContents = await fs.promises.readFile(fullPath, "utf8"); + + const matterResult = matter(fileContents); + + return { + slug, + ...(matterResult.data as { date: string; title: string }), + }; + }) + ); return allPostsData.sort((a, b) => { if (a.date < b.date) { @@ -54,25 +54,8 @@ 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); +export async function getAllPostSlugs() { + const fileNames = await fs.promises.readdir(postsDirectory); return fileNames.map((fileName) => { return { @@ -83,22 +66,18 @@ 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"); + + 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/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." + } } } 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(); +};