diff --git a/apps/web/lib/apps/[slug]/__tests__/parseFrontmatter.test.ts b/apps/web/lib/apps/[slug]/__tests__/parseFrontmatter.test.ts new file mode 100644 index 00000000000000..9848533e3bf4b7 --- /dev/null +++ b/apps/web/lib/apps/[slug]/__tests__/parseFrontmatter.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { parseFrontmatter } from "../getStaticProps"; + +describe("parseFrontmatter", () => { + describe("valid frontmatter parsing", () => { + it("parses frontmatter with items array", () => { + const source = `--- +items: + - 1.jpg + - 2.png +--- +Some content here`; + + const result = parseFrontmatter(source); + + expect(result.data).toEqual({ items: ["1.jpg", "2.png"] }); + expect(result.content).toBe("Some content here"); + }); + + it("parses frontmatter with description only", () => { + const source = `--- +description: A simple description +--- +Content`; + + const result = parseFrontmatter(source); + + expect(result.data).toEqual({ description: "A simple description" }); + expect(result.content).toBe("Content"); + }); + + it("parses items with iframe objects", () => { + const source = `--- +items: + - iframe: { src: https://youtube.com/embed/abc } + - 1.jpg +--- +Content`; + + const result = parseFrontmatter(source); + + expect(result.data).toEqual({ + items: [{ iframe: { src: "https://youtube.com/embed/abc" } }, "1.jpg"], + }); + expect(result.content).toBe("Content"); + }); + }); + + describe("edge cases", () => { + it("returns empty data when no frontmatter present", () => { + const source = "Just plain text content"; + + const result = parseFrontmatter(source); + + expect(result.data).toEqual({}); + expect(result.content).toBe("Just plain text content"); + }); + + it("parses simple frontmatter correctly", () => { + const source = `--- +items: + - test.jpg +--- +Content`; + + const result = parseFrontmatter(source); + + expect(result.data).toEqual({ items: ["test.jpg"] }); + expect(result.content).toBe("Content"); + }); + + it("preserves blank line after frontmatter", () => { + const source = `--- +title: Test +--- + +Content`; + + const result = parseFrontmatter(source); + + expect(result.data).toEqual({ title: "Test" }); + expect(result.content).toBe("\nContent"); + }); + + it("returns empty data for non-object frontmatter (array root)", () => { + const source = `--- +- item1 +- item2 +--- +Content`; + + const result = parseFrontmatter(source); + + expect(result.data).toEqual({}); + expect(result.content).toBe("Content"); + }); + }); + + describe("security (JSON_SCHEMA protection)", () => { + it("returns empty data on unsafe YAML types", () => { + const source = `--- +date: !!js/date 2024-01-01 +--- +Content`; + + const result = parseFrontmatter(source); + + expect(result.data).toEqual({}); + expect(result.content).toBe("Content"); + }); + }); +}); diff --git a/apps/web/lib/apps/[slug]/getStaticProps.ts b/apps/web/lib/apps/[slug]/getStaticProps.ts index dcfa567454db93..551c7fb1b18e53 100644 --- a/apps/web/lib/apps/[slug]/getStaticProps.ts +++ b/apps/web/lib/apps/[slug]/getStaticProps.ts @@ -1,12 +1,51 @@ -import fs from "node:fs" -import matter from "gray-matter"; -import path from "node:path" +import fs from "node:fs"; +import path from "node:path"; + +import yaml from "js-yaml"; import { z } from "zod"; import { getAppWithMetadata } from "@calcom/app-store/_appRegistry"; import { getAppAssetFullPath } from "@calcom/app-store/getAppAssetFullPath"; import { IS_PRODUCTION } from "@calcom/lib/constants"; -import prisma from "@calcom/prisma"; +import { prisma } from "@calcom/prisma"; +import logger from "@calcom/lib/logger"; + +const log = logger.getSubLogger({ prefix: ["lib", "parseFrontmatter"] }); + +const FRONTMATTER_REGEX = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** + * Parses markdown content with YAML frontmatter + * Replaces gray-matter to use js-yaml 4.x directly (yaml.load is safe by default) + */ +export function parseFrontmatter(source: string): { data: Record; content: string } { + const match = source.match(FRONTMATTER_REGEX); + + if (!match) { + return { data: {}, content: source }; + } + + let data: Record = {}; + + try { + const parsed = yaml.load(match[1], { schema: yaml.JSON_SCHEMA }); + + if (isRecord(parsed)) { + data = parsed; + } + } catch (error) { + log.warn("Invalid YAML frontmatter", { error }); + } + + return { + data, + content: source.slice(match[0].length), + }; +} export const sourceSchema = z.object({ content: z.string(), @@ -65,7 +104,7 @@ export const getStaticProps = async (slug: string) => { source = appMeta.description; } - const result = matter(source); + const result = parseFrontmatter(source); const { content, data } = sourceSchema.parse({ content: result.content, data: result.data }); if (data.items) { data.items = data.items.map((item) => { diff --git a/apps/web/package.json b/apps/web/package.json index d9589cf3338754..e55ac7aca115ee 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -91,7 +91,6 @@ "classnames": "2.3.2", "dompurify": "3.3.1", "entities": "4.5.0", - "gray-matter": "4.0.3", "handlebars": "4.7.7", "i18next": "23.2.3", "ical.js": "1.5.0", diff --git a/yarn.lock b/yarn.lock index 9c37834daa154c..1ab69242871dd2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3546,7 +3546,6 @@ __metadata: env-cmd: "npm:10.1.0" glob: "npm:10.4.5" google-auth-library: "npm:9.15.0" - gray-matter: "npm:4.0.3" handlebars: "npm:4.7.7" i18next: "npm:23.2.3" ical.js: "npm:1.5.0"