diff --git a/cloud/app/components/blog-post-page.tsx b/cloud/app/components/blog-post-page.tsx index 0a2abc5b96..9603ffb149 100644 --- a/cloud/app/components/blog-post-page.tsx +++ b/cloud/app/components/blog-post-page.tsx @@ -4,7 +4,6 @@ import { MDXRenderer } from "@/app/components/mdx/renderer"; import { CopyMarkdownButton } from "@/app/components/blocks/copy-markdown-button"; import LoadingContent from "@/app/components/blocks/loading-content"; import { ContentTOC } from "@/app/components/content-toc"; -// import { PagefindMeta } from "@/app/components/pagefind-meta"; import type { BlogContent } from "@/app/lib/content/types"; import ContentLayout from "@/app/components/content-layout"; import { useEffect, useState } from "react"; @@ -22,23 +21,17 @@ function BackToBlogLink() { } type BlogPostPageProps = { - post: BlogContent; - slug: string; - isLoading?: boolean; + content: BlogContent; }; -export function BlogPostPage({ - post, - slug, - isLoading = false, -}: BlogPostPageProps) { +export function BlogPostPage({ content }: BlogPostPageProps) { + const slug = content.meta.slug; + // todo(sebastian): disabled - did this work before? const [, setOgImage] = useState(undefined); // Find the first available image in the blog post directory useEffect(() => { - if (isLoading) return; - const findOgImage = async () => { try { const response = await fetch(`/assets/blog/${slug}/`); @@ -60,15 +53,13 @@ export function BlogPostPage({ }; void findOgImage(); - }, [slug, isLoading]); + }, [slug]); // Extract metadata for easier access - const { title, date, readTime, author, lastUpdated } = post.meta; + const { title, date, readTime, author, lastUpdated } = content.meta; // Main content - const mainContent = isLoading ? ( - - ) : ( + const mainContent = (
@@ -85,41 +76,25 @@ export function BlogPostPage({ )}
- {post.mdx ? ( + {content.mdx ? ( ) : ( )} - {/* todo(sebastian): re-enable when PagefindMeta is implemented */} - {/* {post.mdx ? ( - - - - ) : ( - - )} */}
); - // Right sidebar content - loading state or actual content - const rightSidebarContent = isLoading ? ( -
-
-
- ) : ( + // Right sidebar content + const rightSidebarContent = (
@@ -130,9 +105,8 @@ export function BlogPostPage({
- {/* todo(sebastian): Make sure the headings have IDs in the mdx content */}
@@ -141,17 +115,6 @@ export function BlogPostPage({ return ( <> - {/* */}
@@ -162,7 +125,7 @@ export function BlogPostPage({ {mainContent} diff --git a/cloud/app/components/docs-page.tsx b/cloud/app/components/docs-page.tsx index bf920d9c1b..17d19e92b3 100644 --- a/cloud/app/components/docs-page.tsx +++ b/cloud/app/components/docs-page.tsx @@ -1,6 +1,5 @@ import React from "react"; import ContentLayout from "@/app/components/content-layout"; -import LoadingContent from "@/app/components/blocks/loading-content"; import { ModelProviderProvider } from "@/app/components/mdx/elements/model-provider-provider"; import DocsTocSidebar from "@/app/components/docs-toc-sidebar"; import MainContent from "@/app/components/blocks/docs/main-content"; @@ -8,17 +7,15 @@ import DocsSidebar from "@/app/components/docs-sidebar"; import type { DocContent } from "@/app/lib/content/types"; type DocsPageProps = { - document?: DocContent; - isLoading?: boolean; + content: DocContent; }; /** * DocsPage component - Top-level documentation page component * * Handles metadata, layout and content rendering for all documentation pages - * Supports both loaded and loading states */ -const DocsPage: React.FC = ({ document, isLoading = false }) => { +const DocsPage: React.FC = ({ content }) => { return ( <> @@ -28,11 +25,7 @@ const DocsPage: React.FC = ({ document, isLoading = false }) => { - {isLoading ? ( - - ) : ( - document && - )} + = ({ document, isLoading = false }) => { mobileCollapsible={true} mobileTitle="On this page" > - {isLoading ? ( -
-
-
- ) : ( - document && - )} +
diff --git a/cloud/app/lib/content/content-loader.test.ts b/cloud/app/lib/content/content-loader.test.ts deleted file mode 100644 index 0c88de3a53..0000000000 --- a/cloud/app/lib/content/content-loader.test.ts +++ /dev/null @@ -1,664 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { LoaderFnContext, AnyRoute } from "@tanstack/react-router"; -import { - getAllBlogMeta, - getAllDocsMeta, -} from "@/app/lib/content/virtual-module"; -import { - blogPostContentLoader, - docsContentLoader, -} from "@/app/lib/content/content-loader"; -import { createProcessedMDX } from "@/app/lib/content/mdx-compile"; -import type { ProcessedMDX } from "@/app/lib/mdx/types"; - -// Mock the virtual module -vi.mock("virtual:content-meta", () => ({ - blogMetadata: [ - { - slug: "test-post", - title: "Test Post", - description: "A test blog post", - date: "2025-01-01", - author: "Test Author", - readTime: "5 min read", - lastUpdated: "", - path: "blog/test-post", - type: "blog" as const, - route: "/blog/test-post", - }, - { - slug: "another-post", - title: "Another Post", - description: "Another test blog post", - date: "2025-01-02", - author: "Test Author", - readTime: "3 min read", - lastUpdated: "", - path: "blog/another-post", - type: "blog" as const, - route: "/blog/another-post", - }, - ], - docsMetadata: [ - { - label: "Test Doc", - path: "docs/v1/learn/test-doc", - routePath: "/docs/v1/learn/test-doc", - slug: "test-doc", - type: "docs" as const, - searchWeight: 1.0, - }, - { - label: "Another Doc", - path: "docs/v1/learn/another-doc", - routePath: "/docs/v1/learn/another-doc", - slug: "another-doc", - type: "docs" as const, - searchWeight: 1.0, - }, - { - label: "Index Page", - path: "docs/v1/index", - routePath: "/docs/v1", - slug: "index", - type: "docs" as const, - searchWeight: 1.0, - }, - { - label: "Non-Versioned Doc", - path: "docs/learn/non-versioned-doc", - routePath: "/docs/learn/non-versioned-doc", - slug: "non-versioned-doc", - type: "docs" as const, - searchWeight: 1.0, - }, - { - label: "Non-Versioned Index", - path: "docs/index", - routePath: "/docs", - slug: "index", - type: "docs" as const, - searchWeight: 1.0, - }, - ], -})); - -// Test MDX content fixture -const testMDXContent = "# Test Content\n\nThis is test content."; - -/** - * Helper function to create a test module map. - * This avoids needing eslint-disable comments for 'any' types throughout the tests. - */ -function createTestModuleMap(): Map< - string, - () => Promise<{ mdx: ProcessedMDX }> -> { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return new Map Promise<{ mdx: any }>>(); -} - -describe("blogPostContentLoader", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - function createMockContext( - slug: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ): LoaderFnContext { - return { - params: { slug }, - abortController: new AbortController(), - preload: false, - deps: {}, - context: {}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment - location: {} as any, - navigate: async () => {}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment - parentMatchPromise: Promise.resolve({} as any), - cause: "enter" as const, - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment - route: {} as any, - }; - } - - it("should return undefined for non-existent blog post (fails allow list check)", async () => { - const loader = blogPostContentLoader(); - const context = createMockContext("non-existent-post"); - const result = await loader(context); - - // Should return undefined because it's not in the allow list - expect(result).toBeUndefined(); - }); - - it("should successfully load a valid blog post", async () => { - // Set up a custom module map with test content - const customModuleMap = createTestModuleMap(); - customModuleMap.set("test-post", () => - Promise.resolve({ - mdx: createProcessedMDX(testMDXContent, { - title: "Test Post", - description: "A test blog post", - date: "2025-01-01", - author: "Test Author", - }), - }), - ); - - // Pass custom module map for testing - const loader = blogPostContentLoader(customModuleMap); - const context = createMockContext("test-post"); - const result = await loader(context); - - expect(result).toBeDefined(); - expect(result?.meta.slug).toBe("test-post"); - expect(result?.meta.title).toBe("Test Post"); - expect(result?.content).toBe(testMDXContent); - expect(result?.mdx).toBeDefined(); - expect(result?.mdx.content).toBe(testMDXContent); - }); - - describe("appsec path traversal attacks", () => { - it("should reject path traversal with ../", async () => { - const loader = blogPostContentLoader(); - const context = createMockContext("../../../etc/passwd"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject path traversal with ..\\", async () => { - const loader = blogPostContentLoader(); - const context = createMockContext("..\\..\\..\\windows\\system32"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject path traversal with encoded ../", async () => { - const loader = blogPostContentLoader(); - const context = createMockContext( - "%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd", - ); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject path traversal with double-encoded ../", async () => { - const loader = blogPostContentLoader(); - // Double encoding: %25 = %, so %252e = %2e which decodes to . - const context = createMockContext( - "%252e%252e%252f%252e%252e%252fetc%252fpasswd", - ); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject path traversal with mixed slashes", async () => { - const loader = blogPostContentLoader(); - const context = createMockContext("..\\../etc/passwd"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject absolute paths", async () => { - const loader = blogPostContentLoader(); - const context = createMockContext("/etc/passwd"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject paths with forward slashes", async () => { - const loader = blogPostContentLoader(); - const context = createMockContext("blog/../etc/passwd"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject paths with backslashes", async () => { - const loader = blogPostContentLoader(); - const context = createMockContext("blog\\..\\etc\\passwd"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - }); - - describe("appsec invalid characters", () => { - it("should reject slugs with null bytes", async () => { - const loader = blogPostContentLoader(); - const context = createMockContext("test-post\0"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject slugs with special shell characters", async () => { - const loader = blogPostContentLoader(); - const maliciousSlugs = [ - "test-post; rm -rf /", - "test-post | cat /etc/passwd", - "test-post && cat /etc/passwd", - "test-post $(cat /etc/passwd)", - "test-post `cat /etc/passwd`", - ]; - - for (const slug of maliciousSlugs) { - const context = createMockContext(slug); - const result = await loader(context); - expect(result).toBeUndefined(); - } - }); - - it("should reject slugs with unicode characters that could be dangerous", async () => { - const loader = blogPostContentLoader(); - const context = createMockContext("test-post\u202e"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - }); - - describe("appsec allow list validation", () => { - it("should reject slugs not in the allow list", async () => { - const loader = blogPostContentLoader(); - const invalidSlugs = [ - "random-slug", - "prompt-testing", // not in our mock allow list - "fake-post", - "", - ]; - - for (const slug of invalidSlugs) { - const context = createMockContext(slug); - const result = await loader(context); - expect(result).toBeUndefined(); - } - }); - - it("should verify allow list is properly constructed", () => { - const meta = getAllBlogMeta(); - const validSlugSet = new Set(meta.map((post) => post.slug)); - - // Verify our mock data is in the set - expect(validSlugSet.has("test-post")).toBe(true); - expect(validSlugSet.has("another-post")).toBe(true); - expect(validSlugSet.size).toBe(2); - }); - }); - - describe("edge cases", () => { - it("should handle empty slug", async () => { - const loader = blogPostContentLoader(); - const context = createMockContext(""); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should handle very long slug", async () => { - const loader = blogPostContentLoader(); - const longSlug = "a".repeat(1000); - const context = createMockContext(longSlug); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should handle slug with only special characters", async () => { - const loader = blogPostContentLoader(); - const context = createMockContext("!!!@@@###$$$"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - }); -}); - -describe("docsContentLoader", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - function createMockContext( - splat: string | undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ): LoaderFnContext { - return { - params: { _splat: splat }, - abortController: new AbortController(), - preload: false, - deps: {}, - context: {}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment - location: {} as any, - navigate: async () => {}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment - parentMatchPromise: Promise.resolve({} as any), - cause: "enter" as const, - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment - route: {} as any, - }; - } - - it("should return undefined for non-existent doc (fails allow list check)", async () => { - const loader = docsContentLoader("v1"); - const context = createMockContext("learn/non-existent-doc"); - const result = await loader(context); - - // Should return undefined because it's not in the allow list - expect(result).toBeUndefined(); - }); - - it("should successfully load a valid doc", async () => { - // Set up a custom module map with test content - // Note: module map uses 'path' (v1/learn/test-doc), not 'routePath' - const customModuleMap = createTestModuleMap(); - customModuleMap.set("v1/learn/test-doc", () => - Promise.resolve({ - mdx: createProcessedMDX(testMDXContent, { - title: "Test Doc", - description: "A test doc", - }), - }), - ); - - // Pass custom module map for testing - const loader = docsContentLoader("v1", customModuleMap); - const context = createMockContext("learn/test-doc"); - const result = await loader(context); - - expect(result).toBeDefined(); - expect(result?.content).toBe(testMDXContent); - expect(result?.mdx.frontmatter?.title).toBe("Test Doc"); - }); - - it("should handle index page (empty _splat)", async () => { - const customModuleMap = createTestModuleMap(); - customModuleMap.set("v1/index", () => - Promise.resolve({ - mdx: createProcessedMDX(testMDXContent, { - title: "Index Page", - description: "Index page", - }), - }), - ); - - const loader = docsContentLoader("v1", customModuleMap); - const context = createMockContext(undefined); - const result = await loader(context); - - expect(result).toBeDefined(); - expect(result?.content).toBe(testMDXContent); - expect(result?.mdx.frontmatter?.title).toBe("Index Page"); - }); - - it("should handle different versions correctly", async () => { - const customModuleMap = createTestModuleMap(); - customModuleMap.set("v2/learn/test-doc", () => - Promise.resolve({ - mdx: createProcessedMDX(testMDXContent, { - title: "V2 Doc", - description: "V2 doc", - }), - }), - ); - - // Create a custom loader with v2 version - // Note: This test verifies version parameter is used correctly in routePath construction - const loader = docsContentLoader("v2", customModuleMap); - const context = createMockContext("learn/test-doc"); - const result = await loader(context); - - // Should fail because our mock docInfos only have v1 routes - // This verifies that version is correctly used in routePath matching - expect(result).toBeUndefined(); - }); - - it("should successfully load a non-versioned doc", async () => { - const customModuleMap = createTestModuleMap(); - customModuleMap.set("learn/non-versioned-doc", () => - Promise.resolve({ - mdx: createProcessedMDX(testMDXContent, { - title: "Non-Versioned Doc", - description: "A non-versioned doc", - }), - }), - ); - - // Loader without version parameter - const loader = docsContentLoader(undefined, customModuleMap); - const context = createMockContext("learn/non-versioned-doc"); - const result = await loader(context); - - expect(result).toBeDefined(); - expect(result?.content).toBe(testMDXContent); - expect(result?.mdx.frontmatter?.title).toBe("Non-Versioned Doc"); - }); - - it("should handle non-versioned index page (empty _splat)", async () => { - const customModuleMap = createTestModuleMap(); - customModuleMap.set("index", () => - Promise.resolve({ - mdx: createProcessedMDX(testMDXContent, { - title: "Non-Versioned Index", - description: "Non-versioned index page", - }), - }), - ); - - // Loader without version parameter - const loader = docsContentLoader(undefined, customModuleMap); - const context = createMockContext(undefined); - const result = await loader(context); - - expect(result).toBeDefined(); - expect(result?.content).toBe(testMDXContent); - expect(result?.mdx.frontmatter?.title).toBe("Non-Versioned Index"); - }); - - it("should not load versioned docs when version is not specified", async () => { - // Try to access v1 doc without specifying version - const loader = docsContentLoader(); - const context = createMockContext("learn/test-doc"); - const result = await loader(context); - - // Should fail because routePath won't match (/docs/learn/test-doc vs /docs/v1/learn/test-doc) - expect(result).toBeUndefined(); - }); - - it("should not load non-versioned docs when version is specified", async () => { - // Try to access non-versioned doc with v1 version specified - const loader = docsContentLoader("v1"); - const context = createMockContext("learn/non-versioned-doc"); - const result = await loader(context); - - // Should fail because routePath won't match (/docs/v1/learn/non-versioned-doc vs /docs/learn/non-versioned-doc) - expect(result).toBeUndefined(); - }); - - describe("appsec path traversal attacks", () => { - // Use empty module map to avoid loading real files during security tests - const emptyModuleMap = createTestModuleMap(); - - it("should reject path traversal with ../", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - const context = createMockContext("../../../etc/passwd"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject path traversal with ..\\", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - const context = createMockContext("..\\..\\..\\windows\\system32"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject path traversal with encoded ../", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - const context = createMockContext( - "%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd", - ); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject path traversal with double-encoded ../", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - // Double encoding: %25 = %, so %252e = %2e which decodes to . - const context = createMockContext( - "%252e%252e%252f%252e%252e%252fetc%252fpasswd", - ); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject path traversal with mixed slashes", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - const context = createMockContext("..\\../etc/passwd"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject absolute paths", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - const context = createMockContext("/etc/passwd"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject paths with forward slashes in _splat", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - const context = createMockContext("learn/../etc/passwd"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject paths with backslashes in _splat", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - const context = createMockContext("learn\\..\\etc\\passwd"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - }); - - describe("appsec invalid characters", () => { - // Use empty module map to avoid loading real files during security tests - const emptyModuleMap = createTestModuleMap(); - - it("should reject _splat with null bytes", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - const context = createMockContext("learn/test-doc\0"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject _splat with special shell characters", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - const maliciousSplats = [ - "learn/test-doc; rm -rf /", - "learn/test-doc | cat /etc/passwd", - "learn/test-doc && cat /etc/passwd", - "learn/test-doc $(cat /etc/passwd)", - "learn/test-doc `cat /etc/passwd`", - ]; - - for (const splat of maliciousSplats) { - const context = createMockContext(splat); - const result = await loader(context); - expect(result).toBeUndefined(); - } - }); - - it("should reject _splat with unicode characters that could be dangerous", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - const context = createMockContext("learn/test-doc\u202e"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - }); - - describe("appsec allow list validation", () => { - // Use empty module map to avoid loading real files during security tests - const emptyModuleMap = createTestModuleMap(); - - it("should reject _splat not in the allow list", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - const invalidSplats = [ - "random/path", - "learn/fake-doc", // not in our mock allow list - "v1/learn/test-doc", // includes version prefix (shouldn't) - "", - ]; - - for (const splat of invalidSplats) { - const context = createMockContext(splat); - const result = await loader(context); - expect(result).toBeUndefined(); - } - }); - - it("should verify allow list is properly constructed", () => { - const docInfos = getAllDocsMeta(); - const validPathSet = new Set(docInfos.map((doc) => doc.path)); - - // Verify our mock data is in the set (paths with "docs/" prefix) - expect(validPathSet.has("docs/v1/learn/test-doc")).toBe(true); - expect(validPathSet.has("docs/v1/learn/another-doc")).toBe(true); - expect(validPathSet.has("docs/v1/index")).toBe(true); - expect(validPathSet.has("docs/learn/non-versioned-doc")).toBe(true); - expect(validPathSet.has("docs/index")).toBe(true); - expect(validPathSet.size).toBe(5); - }); - }); - - describe("edge cases", () => { - // Use empty module map to avoid loading real files during security tests - const emptyModuleMap = createTestModuleMap(); - - it("should handle very long _splat", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - const longSplat = "a".repeat(1000); - const context = createMockContext(longSplat); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should handle _splat with only special characters", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - const context = createMockContext("!!!@@@###$$$"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should handle version mismatch", async () => { - // Try to access v1 doc with v2 loader - const loader = docsContentLoader("v2", emptyModuleMap); - const context = createMockContext("learn/test-doc"); - const result = await loader(context); - - // Should fail because routePath won't match - expect(result).toBeUndefined(); - }); - }); -}); diff --git a/cloud/app/lib/content/content-loader.ts b/cloud/app/lib/content/content-loader.ts deleted file mode 100644 index 036d019861..0000000000 --- a/cloud/app/lib/content/content-loader.ts +++ /dev/null @@ -1,236 +0,0 @@ -import type { LoaderFnContext } from "@tanstack/react-router"; -import type { - BlogContent, - BlogMeta, - DocContent, - DocMeta, - PolicyContent, - PolicyMeta, -} from "@/app/lib/content/types"; -import type { ProcessedMDX } from "@/app/lib/mdx/types"; -import { - BLOG_MODULE_MAP, - DOCS_MODULE_MAP, - POLICY_MODULE_MAP, - getAllBlogMeta, - getAllDocsMeta, - getAllPolicyMeta, - type ModuleMap, -} from "./virtual-module"; - -/** - * Config for making a content loader by type. - */ -interface ContentLoaderConfig< - TMeta, - TParams extends Record, - TContent, -> { - /** Function to get all metadata for this content type */ - getMeta: () => TMeta[]; - /** Extract the lookup key from route params */ - extractKey: (params: TParams) => string; - /** Find metadata matching the extracted key (validates against allow list) */ - findMeta: (metas: TMeta[], key: string) => TMeta | undefined; - /** Get the module map key from metadata (for looking up the MDX loader) */ - getModuleKey: (meta: TMeta, params: TParams) => string; - /** Build the final content object from metadata and loaded MDX */ - buildContent: (meta: TMeta, mdx: ProcessedMDX) => TContent; -} - -/** - * Create a generic, type-safe content loader for TanStack Router. - * - * @param moduleMap - Map from content key to module loader - * @param config - Content-type-specific configuration - */ -function createContentLoader< - TMeta, - TParams extends Record, - TContent, ->(moduleMap: ModuleMap, config: ContentLoaderConfig) { - return async function loader( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - context: LoaderFnContext, - ): Promise { - // Type assertion needed due to TanStack Router's complex param type expansion - const key = config.extractKey(context.params as TParams); - - const metas = config.getMeta(); - - // Validate key against allow list (security: prevents path traversal) - const meta = config.findMeta(metas, key); - if (!meta) { - return undefined; - } - - const moduleKey = config.getModuleKey(meta, context.params as TParams); - const moduleLoader = moduleMap.get(moduleKey); - if (!moduleLoader) { - return undefined; - } - - const module = await moduleLoader(); - return config.buildContent(meta, module.mdx); - }; -} - -/* ========== BLOG CONTENT LOADER =========== */ - -/** - * Type for the data returned by blogPostContentLoader. - */ -export type BlogPostLoaderData = BlogContent | undefined; - -/** - * Blog post content loader (supports custom module map for testing). - * - * @param moduleMap - Optional custom module map (for testing) - */ -export function blogPostContentLoader(moduleMap?: ModuleMap) { - const effectiveModuleMap = moduleMap ?? BLOG_MODULE_MAP; - - return createContentLoader( - effectiveModuleMap, - { - getMeta: getAllBlogMeta, - extractKey: (params) => params.slug, - findMeta: (metas, slug) => metas.find((m) => m.slug === slug), - getModuleKey: (meta) => meta.slug, - buildContent: (meta, mdx) => ({ meta, content: mdx.content, mdx }), - }, - ); -} - -/* ========== DOCS CONTENT LOADER =========== */ - -/** - * Type for the data returned by docsContentLoader. - */ -export type DocsLoaderData = DocContent | undefined; - -/** - * Docs content loader (supports custom module map for testing). - * - * @param version - Optional docs version (e.g., "v1"). If omitted, loads non-versioned docs. - * @param moduleMap - Optional custom module map (for testing) - */ -export function docsContentLoader(version?: string, moduleMap?: ModuleMap) { - const effectiveModuleMap = moduleMap ?? DOCS_MODULE_MAP; - - return createContentLoader( - effectiveModuleMap, - { - getMeta: getAllDocsMeta, - extractKey: (params) => { - // Build path with "docs/" prefix to match DocInfo.path format - const basePath = version ? `docs/${version}` : "docs"; - return `${basePath}${params._splat ? `/${params._splat}` : ""}`; - }, - findMeta: (metas, path) => { - // First try exact match - const exactMatch = metas.find((m) => m.path === path); - if (exactMatch) { - return exactMatch; - } - // For index pages, the URL won't include "/index" but the metadata path will - // e.g., URL "/docs/v1/guides" -> metadata path "docs/v1/guides/index" - return metas.find((m) => m.path === `${path}/index`); - }, - getModuleKey: (meta) => { - // Strip "docs/" prefix to get the module key (file path relative to content/docs/) - const prefix = "docs/"; - return meta.path.startsWith(prefix) - ? meta.path.slice(prefix.length) - : meta.path; - }, - buildContent: (meta, mdx) => ({ meta, content: mdx.content, mdx }), - }, - ); -} - -/* ========== POLICY CONTENT LOADER =========== */ - -/** - * Type for the data returned by policyContentLoader. - */ -export type PolicyLoaderData = PolicyContent | undefined; - -/** - * Generic policy content loader (supports custom module map for testing). - * - * @param subdirectory - Optional subdirectory within policy (e.g., "terms" for policy/terms/, - * or undefined for top-level policy files like privacy.mdx) - * @param moduleMap - Optional custom module map (for testing) - */ -export function policyContentLoader( - subdirectory: string | undefined, - moduleMap?: ModuleMap, -) { - const effectiveModuleMap = moduleMap ?? POLICY_MODULE_MAP; - - return createContentLoader( - effectiveModuleMap, - { - getMeta: getAllPolicyMeta, - extractKey: (params) => { - // Build path with "policy/" prefix, optionally including subdirectory - const basePath = subdirectory ? `policy/${subdirectory}` : "policy"; - return `${basePath}${params._splat ? `/${params._splat}` : ""}`; - }, - findMeta: (metas, path) => { - // Find metadata matching the path (e.g., "policy/privacy" or "policy/terms/service") - return metas.find((m) => m.path === path); - }, - getModuleKey: (meta) => { - // Strip "policy/" prefix to get the module key - const prefix = "policy/"; - return meta.path.startsWith(prefix) - ? meta.path.slice(prefix.length) - : meta.path; - }, - buildContent: (meta, mdx) => ({ meta, content: mdx.content, mdx }), - }, - ); -} - -/** - * Terms content loader - convenience wrapper for policy/terms/ subdirectory. - * - * @param moduleMap - Optional custom module map (for testing) - */ -export function termsContentLoader(moduleMap?: ModuleMap) { - return policyContentLoader("terms", moduleMap); -} - -/** - * Privacy content loader - convenience wrapper for top-level policy/privacy. - * This loader doesn't use _splat params since privacy is a single file. - * - * @param moduleMap - Optional custom module map (for testing) - */ -export function privacyContentLoader(moduleMap?: ModuleMap) { - const effectiveModuleMap = moduleMap ?? POLICY_MODULE_MAP; - - return createContentLoader, PolicyContent>( - effectiveModuleMap, - { - getMeta: getAllPolicyMeta, - extractKey: () => { - // Privacy is always at "policy/privacy" - return "policy/privacy"; - }, - findMeta: (metas, path) => { - return metas.find((m) => m.path === path); - }, - getModuleKey: (meta) => { - // Strip "policy/" prefix to get the module key - const prefix = "policy/"; - return meta.path.startsWith(prefix) - ? meta.path.slice(prefix.length) - : meta.path; - }, - buildContent: (meta, mdx) => ({ meta, content: mdx.content, mdx }), - }, - ); -} diff --git a/cloud/app/lib/content/route-config.test.tsx b/cloud/app/lib/content/route-config.test.tsx new file mode 100644 index 0000000000..a72a647d21 --- /dev/null +++ b/cloud/app/lib/content/route-config.test.tsx @@ -0,0 +1,969 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + BLOG_MODULE_MAP, + DOCS_MODULE_MAP, + POLICY_MODULE_MAP, + getAllBlogMeta, + getAllDocsMeta, + getAllPolicyMeta, +} from "@/app/lib/content/virtual-module"; +import { createContentRouteConfig } from "@/app/lib/content/route-config"; +import { createProcessedMDX } from "@/app/lib/content/mdx-compile"; +import type { ProcessedMDX } from "@/app/lib/mdx/types"; +import type { + BlogContent, + Content, + ContentMeta, + DocContent, +} from "@/app/lib/content/types"; + +// Mock TanStack Router +vi.mock("@tanstack/react-router", () => ({ + createFileRoute: vi.fn((path: string) => (options: unknown) => ({ + path, + options, + })), + redirect: vi.fn((config: unknown) => { + const error = new Error("Redirect"); + (error as unknown as { redirect: unknown }).redirect = config; + throw error; + }), + useLoaderData: vi.fn(), +})); + +// Mock NotFound component +vi.mock("@/app/components/not-found", () => ({ + NotFound: () => null, +})); + +// Mock the virtual module +vi.mock("virtual:content-meta", () => ({ + blogMetadata: [ + { + slug: "test-post", + title: "Test Post", + description: "A test blog post", + date: "2025-01-01", + author: "Test Author", + readTime: "5 min read", + lastUpdated: "", + path: "blog/test-post", + type: "blog" as const, + route: "/blog/test-post", + }, + { + slug: "another-post", + title: "Another Post", + description: "Another test blog post", + date: "2025-01-02", + author: "Test Author", + readTime: "3 min read", + lastUpdated: "", + path: "blog/another-post", + type: "blog" as const, + route: "/blog/another-post", + }, + ], + docsMetadata: [ + { + title: "Test Doc", + description: "A test doc", + path: "docs/v1/learn/test-doc", + routePath: "/docs/v1/learn/test-doc", + slug: "test-doc", + type: "docs" as const, + searchWeight: 1.0, + sectionPath: "docs>v1>learn", + }, + { + title: "Another Doc", + description: "Another test doc", + path: "docs/v1/learn/another-doc", + routePath: "/docs/v1/learn/another-doc", + slug: "another-doc", + type: "docs" as const, + searchWeight: 1.0, + sectionPath: "docs>v1>learn", + }, + { + title: "Index Page", + description: "Index page", + path: "docs/v1/index", + routePath: "/docs/v1", + slug: "index", + type: "docs" as const, + searchWeight: 1.0, + sectionPath: "docs>v1", + }, + { + title: "Non-Versioned Doc", + description: "A non-versioned doc", + path: "docs/learn/non-versioned-doc", + routePath: "/docs/learn/non-versioned-doc", + slug: "non-versioned-doc", + type: "docs" as const, + searchWeight: 1.0, + sectionPath: "docs>learn", + }, + { + title: "Non-Versioned Index", + description: "Non-versioned index page", + path: "docs/index", + routePath: "/docs", + slug: "index", + type: "docs" as const, + searchWeight: 1.0, + sectionPath: "docs", + }, + ], + policyMetadata: [], +})); + +// Test MDX content fixture +const testMDXContent = "# Test Content\n\nThis is test content."; + +/** + * Helper function to create a test module map. + */ +function createTestModuleMap(): Map< + string, + () => Promise<{ mdx: ProcessedMDX }> +> { + return new Map Promise<{ mdx: ProcessedMDX }>>(); +} + +/** + * Helper to get the loader from a route config created by createContentRouteConfig. + */ + +function getLoader( + routeConfig: ReturnType>, +) { + return routeConfig.loader; +} + +/** + * Helper to create a mock loader context. + */ +function createMockContext(params: Record) { + return { + params, + abortController: new AbortController(), + preload: false, + deps: {}, + context: {}, + location: {} as unknown, + navigate: async () => {}, + parentMatchPromise: Promise.resolve({} as unknown), + cause: "enter" as const, + route: {} as unknown, + }; +} + +// Dummy component for testing - accepts base Content type for flexibility +const DummyComponent = ({ content }: { content: Content }) => { + void content; + return null; +}; + +describe("createContentRouteConfig - blog", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return undefined for non-existent blog post (fails allow list check)", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ slug: "non-existent-post" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should successfully load a valid blog post", async () => { + const customModuleMap = createTestModuleMap(); + customModuleMap.set("test-post", () => + Promise.resolve({ + mdx: createProcessedMDX(testMDXContent, { + title: "Test Post", + description: "A test blog post", + date: "2025-01-01", + author: "Test Author", + }), + }), + ); + + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + _testModuleMap: customModuleMap, + }); + const loader = getLoader(route); + const context = createMockContext({ slug: "test-post" }); + const result = (await loader(context)) as BlogContent | undefined; + + expect(result).toBeDefined(); + expect(result?.meta.slug).toBe("test-post"); + expect(result?.meta.title).toBe("Test Post"); + expect(result?.content).toBe(testMDXContent); + expect(result?.mdx).toBeDefined(); + expect(result?.mdx.content).toBe(testMDXContent); + }); + + describe("appsec path traversal attacks", () => { + it("should reject path traversal with ../", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ slug: "../../../etc/passwd" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject path traversal with ..\\", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ + slug: "..\\..\\..\\windows\\system32", + }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject path traversal with encoded ../", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ + slug: "%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd", + }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject path traversal with double-encoded ../", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ + slug: "%252e%252e%252f%252e%252e%252fetc%252fpasswd", + }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject path traversal with mixed slashes", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ slug: "..\\../etc/passwd" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject absolute paths", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ slug: "/etc/passwd" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject paths with forward slashes", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ slug: "blog/../etc/passwd" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject paths with backslashes", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ slug: "blog\\..\\etc\\passwd" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + }); + + describe("appsec invalid characters", () => { + it("should reject slugs with null bytes", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ slug: "test-post\0" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject slugs with special shell characters", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const maliciousSlugs = [ + "test-post; rm -rf /", + "test-post | cat /etc/passwd", + "test-post && cat /etc/passwd", + "test-post $(cat /etc/passwd)", + "test-post `cat /etc/passwd`", + ]; + + for (const slug of maliciousSlugs) { + const context = createMockContext({ slug }); + const result = await loader(context); + expect(result).toBeUndefined(); + } + }); + + it("should reject slugs with unicode characters that could be dangerous", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ slug: "test-post\u202e" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + }); + + describe("appsec allow list validation", () => { + it("should reject slugs not in the allow list", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const invalidSlugs = ["random-slug", "prompt-testing", "fake-post", ""]; + + for (const slug of invalidSlugs) { + const context = createMockContext({ slug }); + const result = await loader(context); + expect(result).toBeUndefined(); + } + }); + + it("should verify allow list is properly constructed", () => { + const meta = getAllBlogMeta(); + const validSlugSet = new Set(meta.map((post) => post.slug)); + + expect(validSlugSet.has("test-post")).toBe(true); + expect(validSlugSet.has("another-post")).toBe(true); + expect(validSlugSet.size).toBe(2); + }); + }); + + describe("edge cases", () => { + it("should handle empty slug", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ slug: "" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should handle very long slug", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const longSlug = "a".repeat(1000); + const context = createMockContext({ slug: longSlug }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should handle slug with only special characters", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ slug: "!!!@@@###$$$" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + }); +}); + +describe("createContentRouteConfig - docs", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return undefined for non-existent doc (fails allow list check)", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "learn/non-existent-doc" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should successfully load a valid doc", async () => { + const customModuleMap = createTestModuleMap(); + customModuleMap.set("v1/learn/test-doc", () => + Promise.resolve({ + mdx: createProcessedMDX(testMDXContent, { + title: "Test Doc", + description: "A test doc", + }), + }), + ); + + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + _testModuleMap: customModuleMap, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "learn/test-doc" }); + const result = (await loader(context)) as DocContent | undefined; + + expect(result).toBeDefined(); + expect(result?.content).toBe(testMDXContent); + expect(result?.mdx.frontmatter?.title).toBe("Test Doc"); + }); + + it("should handle index page (empty _splat)", async () => { + const customModuleMap = createTestModuleMap(); + customModuleMap.set("v1/index", () => + Promise.resolve({ + mdx: createProcessedMDX(testMDXContent, { + title: "Index Page", + description: "Index page", + }), + }), + ); + + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + _testModuleMap: customModuleMap, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: undefined }); + const result = (await loader(context)) as DocContent | undefined; + + expect(result).toBeDefined(); + expect(result?.content).toBe(testMDXContent); + expect(result?.mdx.frontmatter?.title).toBe("Index Page"); + }); + + it("should handle different versions correctly", async () => { + const customModuleMap = createTestModuleMap(); + customModuleMap.set("v2/learn/test-doc", () => + Promise.resolve({ + mdx: createProcessedMDX(testMDXContent, { + title: "V2 Doc", + description: "V2 doc", + }), + }), + ); + + // Create a loader with v2 version - our mock metadata only has v1 routes + const route = createContentRouteConfig("/docs/v2/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v2", + component: DummyComponent, + _testModuleMap: customModuleMap, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "learn/test-doc" }); + const result = await loader(context); + + // Should fail because our mock docsMetadata only has v1 routes + expect(result).toBeUndefined(); + }); + + it("should successfully load a non-versioned doc", async () => { + const customModuleMap = createTestModuleMap(); + customModuleMap.set("learn/non-versioned-doc", () => + Promise.resolve({ + mdx: createProcessedMDX(testMDXContent, { + title: "Non-Versioned Doc", + description: "A non-versioned doc", + }), + }), + ); + + // Loader without version parameter + const route = createContentRouteConfig("/docs/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + component: DummyComponent, + _testModuleMap: customModuleMap, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "learn/non-versioned-doc" }); + const result = (await loader(context)) as DocContent | undefined; + + expect(result).toBeDefined(); + expect(result?.content).toBe(testMDXContent); + expect(result?.mdx.frontmatter?.title).toBe("Non-Versioned Doc"); + }); + + it("should handle non-versioned index page (empty _splat)", async () => { + const customModuleMap = createTestModuleMap(); + customModuleMap.set("index", () => + Promise.resolve({ + mdx: createProcessedMDX(testMDXContent, { + title: "Non-Versioned Index", + description: "Non-versioned index page", + }), + }), + ); + + // Loader without version parameter + const route = createContentRouteConfig("/docs/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + component: DummyComponent, + _testModuleMap: customModuleMap, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: undefined }); + const result = (await loader(context)) as DocContent | undefined; + + expect(result).toBeDefined(); + expect(result?.content).toBe(testMDXContent); + expect(result?.mdx.frontmatter?.title).toBe("Non-Versioned Index"); + }); + + it("should not load versioned docs when version is not specified", async () => { + // Try to access v1 doc without specifying version + const route = createContentRouteConfig("/docs/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "learn/test-doc" }); + const result = await loader(context); + + // Should fail because path won't match (docs/learn/test-doc vs docs/v1/learn/test-doc) + expect(result).toBeUndefined(); + }); + + it("should not load non-versioned docs when version is specified", async () => { + // Try to access non-versioned doc with v1 version specified + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "learn/non-versioned-doc" }); + const result = await loader(context); + + // Should fail because path won't match (docs/v1/learn/non-versioned-doc vs docs/learn/non-versioned-doc) + expect(result).toBeUndefined(); + }); + + describe("appsec path traversal attacks", () => { + it("should reject path traversal with ../", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "../../../etc/passwd" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject path traversal with ..\\", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ + _splat: "..\\..\\..\\windows\\system32", + }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject path traversal with encoded ../", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ + _splat: "%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd", + }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject path traversal with double-encoded ../", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ + _splat: "%252e%252e%252f%252e%252e%252fetc%252fpasswd", + }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject path traversal with mixed slashes", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "..\\../etc/passwd" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject absolute paths", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "/etc/passwd" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject paths with forward slashes in _splat", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "learn/../etc/passwd" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject paths with backslashes in _splat", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "learn\\..\\etc\\passwd" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + }); + + describe("appsec invalid characters", () => { + it("should reject _splat with null bytes", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "learn/test-doc\0" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject _splat with special shell characters", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const maliciousSplats = [ + "learn/test-doc; rm -rf /", + "learn/test-doc | cat /etc/passwd", + "learn/test-doc && cat /etc/passwd", + "learn/test-doc $(cat /etc/passwd)", + "learn/test-doc `cat /etc/passwd`", + ]; + + for (const splat of maliciousSplats) { + const context = createMockContext({ _splat: splat }); + const result = await loader(context); + expect(result).toBeUndefined(); + } + }); + + it("should reject _splat with unicode characters that could be dangerous", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "learn/test-doc\u202e" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + }); + + describe("appsec allow list validation", () => { + it("should reject _splat not in the allow list", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + // Note: empty string "" is NOT invalid - it triggers index page fallback + const invalidSplats = [ + "random/path", + "learn/fake-doc", + "v1/learn/test-doc", // includes version prefix (shouldn't) + ]; + + for (const splat of invalidSplats) { + const context = createMockContext({ _splat: splat }); + const result = await loader(context); + expect(result).toBeUndefined(); + } + }); + + it("should verify allow list is properly constructed", () => { + const docInfos = getAllDocsMeta(); + const validPathSet = new Set(docInfos.map((doc) => doc.path)); + + // Verify our mock data is in the set (paths with "docs/" prefix) + expect(validPathSet.has("docs/v1/learn/test-doc")).toBe(true); + expect(validPathSet.has("docs/v1/learn/another-doc")).toBe(true); + expect(validPathSet.has("docs/v1/index")).toBe(true); + expect(validPathSet.has("docs/learn/non-versioned-doc")).toBe(true); + expect(validPathSet.has("docs/index")).toBe(true); + expect(validPathSet.size).toBe(5); + }); + }); + + describe("edge cases", () => { + it("should handle very long _splat", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const longSplat = "a".repeat(1000); + const context = createMockContext({ _splat: longSplat }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should handle _splat with only special characters", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "!!!@@@###$$$" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should handle version mismatch", async () => { + // Try to access v1 doc with v2 loader + const route = createContentRouteConfig("/docs/v2/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v2", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "learn/test-doc" }); + const result = await loader(context); + + // Should fail because path won't match + expect(result).toBeUndefined(); + }); + }); +}); + +describe("createContentRouteConfig - redirectOnEmpty", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should redirect when _splat is empty and redirectOnEmpty is set", async () => { + const route = createContentRouteConfig("/terms/$", { + getMeta: getAllPolicyMeta, + moduleMap: POLICY_MODULE_MAP, + prefix: "policy", + subdirectory: "terms", + component: DummyComponent, + redirectOnEmptySplat: { to: "/terms/$", params: { _splat: "use" } }, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: undefined }); + + await expect(loader(context)).rejects.toThrow("Redirect"); + }); + + it("should not redirect when _splat has a value", async () => { + const route = createContentRouteConfig("/terms/$", { + getMeta: getAllPolicyMeta, + moduleMap: POLICY_MODULE_MAP, + prefix: "policy", + subdirectory: "terms", + component: DummyComponent, + redirectOnEmptySplat: { to: "/terms/$", params: { _splat: "use" } }, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "service" }); + + // Should not throw, just return undefined (no matching metadata in mock) + const result = await loader(context); + expect(result).toBeUndefined(); + }); +}); diff --git a/cloud/app/lib/content/route-config.tsx b/cloud/app/lib/content/route-config.tsx new file mode 100644 index 0000000000..f3b322e6b0 --- /dev/null +++ b/cloud/app/lib/content/route-config.tsx @@ -0,0 +1,190 @@ +import type React from "react"; +import { redirect, useLoaderData } from "@tanstack/react-router"; +import type { Content, ContentMeta } from "@/app/lib/content/types"; +import { NotFound } from "@/app/components/not-found"; +import type { ModuleMap } from "./virtual-module"; + +/* ========== CONTENT ROUTE OPTIONS =========== */ + +/** + * Options for creating a content route. + */ +export interface ContentRouteOptions { + /** Function to get all metadata for this content type */ + getMeta: () => TMeta[]; + /** Module map for this content type */ + moduleMap: ModuleMap; + /** Prefix for metadata paths (e.g., "blog", "docs", "policy") */ + prefix: string; + /** Version segment for versioned content (e.g., "v1") */ + version?: string; + /** Subdirectory within the content type (e.g., "terms" for policy/terms/) */ + subdirectory?: string; + /** Fixed path for single-file content (e.g., "privacy" for policy/privacy) */ + fixedPath?: string; + /** The page component to render when content is loaded */ + component: React.ComponentType<{ content: Content }>; + /** Title to show while loading (defaults to "Loading...") */ + loadingTitle?: string; + /** Redirect configuration for empty splat routes */ + redirectOnEmptySplat?: { to: string; params: Record }; + /** Custom module map for testing */ + _testModuleMap?: ModuleMap; +} + +/* ========== CONTENT ROUTE FACTORY =========== */ + +/** + * Create route configuration for a content route. + * + * Use with TanStack Router's `createFileRoute`: + * + * @example + * ```typescript + * // terms.$.tsx - Subdirectory with redirect on empty splat + * export const Route = createFileRoute("/terms/$")( + * createContentRouteConfig("/terms/$", { + * getMeta: getAllPolicyMeta, + * moduleMap: POLICY_MODULE_MAP, + * prefix: "policy", + * subdirectory: "terms", + * component: PolicyPage, + * redirectOnEmptySplat: { to: "/terms/$", params: { _splat: "use" } }, + * }) + * ); + * ``` + */ +export function createContentRouteConfig( + path: string, + options: ContentRouteOptions, +) { + const moduleMap = options._testModuleMap ?? options.moduleMap; + + // Create the component that will render the content + const contentComponent = createContentComponent(options.component, path); + + return { + ssr: false as const, + + head: (ctx: { loaderData?: Content | undefined }) => { + const meta = ctx.loaderData?.meta; + if (!meta) { + return { + meta: [ + { title: options.loadingTitle ?? "Loading..." }, + { name: "description", content: "Loading content" }, + ], + }; + } + return { + meta: [ + { title: `${meta.title} | Mirascope` }, + { name: "description", content: meta.description }, + ], + }; + }, + + loader: async (context: { + params: Record; + }): Promise | undefined> => { + // Handle redirectOnEmpty for splat routes + if ( + options.redirectOnEmptySplat && + !(context.params as { _splat?: string })._splat + ) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw redirect({ + to: options.redirectOnEmptySplat.to, + params: options.redirectOnEmptySplat.params, + replace: true, + }); + } + + // Build metadata path from route params and options + const metaPath = buildMetaPath(context.params, options); + + // Find metadata (with universal /index fallback) + const metas = options.getMeta(); + let meta = metas.find((m) => m.path === metaPath); + if (!meta) { + meta = metas.find((m) => m.path === `${metaPath}/index`); + } + + if (!meta) { + return undefined; + } + + // Get module key by stripping the content type prefix + const moduleKey = meta.path.startsWith(`${options.prefix}/`) + ? meta.path.slice(options.prefix.length + 1) + : meta.path; + + const moduleLoader = moduleMap.get(moduleKey); + if (!moduleLoader) { + return undefined; + } + + const module = await moduleLoader(); + return { + meta, + content: module.mdx.content, + mdx: module.mdx, + } as Content; + }, + + component: contentComponent, + }; +} + +/* ========== INTERNAL HELPERS =========== */ + +/** + * Build the metadata path from route params and content options. + */ +function buildMetaPath( + params: Record, + options: ContentRouteOptions, +): string { + // For fixed paths (like privacy), ignore params + if (options.fixedPath) { + let path = options.prefix; + if (options.subdirectory) { + path += `/${options.subdirectory}`; + } + return `${path}/${options.fixedPath}`; + } + + // Extract path suffix from params + const pathSuffix = params._splat ?? params.slug ?? ""; + + // Build the full metadata path + let metaPath = options.prefix; + if (options.version) { + metaPath += `/${options.version}`; + } + if (options.subdirectory) { + metaPath += `/${options.subdirectory}`; + } + if (pathSuffix) { + metaPath += `/${pathSuffix}`; + } + + return metaPath; +} + +/** + * Create a component that loads content and renders the page component. + */ +function createContentComponent( + PageComponent: React.ComponentType<{ content: Content }>, + path: string, +) { + return function ContentRouteComponent() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const content: Content = useLoaderData({ from: path as any }); + if (!content) { + return ; + } + return ; + }; +} diff --git a/cloud/app/routes/blog.$slug.tsx b/cloud/app/routes/blog.$slug.tsx index ecaed325a1..ad18f69b60 100644 --- a/cloud/app/routes/blog.$slug.tsx +++ b/cloud/app/routes/blog.$slug.tsx @@ -1,33 +1,16 @@ import { createFileRoute } from "@tanstack/react-router"; -import { NotFound } from "@/app/components/not-found"; -import { blogPostContentLoader } from "@/app/lib/content/content-loader"; +import { createContentRouteConfig } from "@/app/lib/content/route-config"; +import { + BLOG_MODULE_MAP, + getAllBlogMeta, +} from "@/app/lib/content/virtual-module"; import { BlogPostPage } from "@/app/components/blog-post-page"; -export const Route = createFileRoute("/blog/$slug")({ - ssr: false, - head: (ctx) => { - // todo(sebastian): simplify and add other SEO metadata - const meta = ctx.loaderData?.meta; - if (!meta) { - return {}; - } - return { - meta: [ - { title: meta.title }, - { name: "description", content: meta.description }, - ], - }; - }, - loader: blogPostContentLoader(), - component: BlogPost, -}); - -function BlogPost() { - const post = Route.useLoaderData(); - - if (!post) { - return ; - } - - return ; -} +export const Route = createFileRoute("/blog/$slug")( + createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: BlogPostPage, + }), +); diff --git a/cloud/app/routes/docs.v1.$.tsx b/cloud/app/routes/docs.v1.$.tsx index 2ce603b8d2..4736a4a4e0 100644 --- a/cloud/app/routes/docs.v1.$.tsx +++ b/cloud/app/routes/docs.v1.$.tsx @@ -1,39 +1,17 @@ import { createFileRoute } from "@tanstack/react-router"; +import { createContentRouteConfig } from "@/app/lib/content/route-config"; import { - docsContentLoader, - type DocsLoaderData, -} from "@/app/lib/content/content-loader"; + DOCS_MODULE_MAP, + getAllDocsMeta, +} from "@/app/lib/content/virtual-module"; import DocsPage from "@/app/components/docs-page"; -import { NotFound } from "@/app/components/not-found"; -export const Route = createFileRoute("/docs/v1/$")({ - ssr: false, - head: (ctx) => { - // todo(sebastian): simplify and add other SEO metadata - const meta = ctx.loaderData?.meta; - if (!meta) { - return { - meta: [ - { title: "Loading..." }, - { name: "description", content: "Loading documentation content" }, - ], - }; - } - return { - meta: [ - { title: meta.title }, - { name: "description", content: meta.description }, - ], - }; - }, - loader: docsContentLoader("v1"), - component: Document, -}); - -function Document() { - const doc: DocsLoaderData = Route.useLoaderData(); - if (!doc) { - return ; - } - return ; -} +export const Route = createFileRoute("/docs/v1/$")( + createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DocsPage, + }), +); diff --git a/cloud/app/routes/privacy.tsx b/cloud/app/routes/privacy.tsx index 8dda78867f..7b0c88176a 100644 --- a/cloud/app/routes/privacy.tsx +++ b/cloud/app/routes/privacy.tsx @@ -1,42 +1,17 @@ -import { createFileRoute, type LoaderFnContext } from "@tanstack/react-router"; -import { NotFound } from "@/app/components/not-found"; +import { createFileRoute } from "@tanstack/react-router"; +import { createContentRouteConfig } from "@/app/lib/content/route-config"; import { - privacyContentLoader, - type PolicyLoaderData, -} from "@/app/lib/content/content-loader"; + POLICY_MODULE_MAP, + getAllPolicyMeta, +} from "@/app/lib/content/virtual-module"; import PolicyPage from "@/app/components/policy-page"; -export const Route = createFileRoute("/privacy")({ - ssr: false, - head: (ctx) => { - // todo(sebastian): simplify and add other SEO metadata - const meta = ctx.loaderData?.meta; - if (!meta) { - return { - meta: [ - { title: "Loading..." }, - { name: "description", content: "Loading privacy content" }, - ], - }; - } - return { - meta: [ - { title: meta.title }, - { name: "description", content: meta.description }, - ], - }; - }, - loader: async ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ctx: LoaderFnContext>, - ): Promise => { - return privacyContentLoader()(ctx); - }, - component: () => { - const content: PolicyLoaderData = Route.useLoaderData(); - if (!content) { - return ; - } - return ; - }, -}); +export const Route = createFileRoute("/privacy")( + createContentRouteConfig("/privacy", { + getMeta: getAllPolicyMeta, + moduleMap: POLICY_MODULE_MAP, + prefix: "policy", + fixedPath: "privacy", + component: PolicyPage, + }), +); diff --git a/cloud/app/routes/terms.$.tsx b/cloud/app/routes/terms.$.tsx index 80e2c33265..c1da90637d 100644 --- a/cloud/app/routes/terms.$.tsx +++ b/cloud/app/routes/terms.$.tsx @@ -1,56 +1,18 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { createContentRouteConfig } from "@/app/lib/content/route-config"; import { - createFileRoute, - redirect, - type LoaderFnContext, -} from "@tanstack/react-router"; -import { NotFound } from "@/app/components/not-found"; -import { - termsContentLoader, - type PolicyLoaderData, -} from "@/app/lib/content/content-loader"; + POLICY_MODULE_MAP, + getAllPolicyMeta, +} from "@/app/lib/content/virtual-module"; import PolicyPage from "@/app/components/policy-page"; -export const Route = createFileRoute("/terms/$")({ - ssr: false, - head: (ctx) => { - // todo(sebastian): simplify and add other SEO metadata - const meta = ctx.loaderData?.meta; - if (!meta) { - return { - meta: [ - { title: "Loading..." }, - { name: "description", content: "Loading terms content" }, - ], - }; - } - return { - meta: [ - { title: meta.title }, - { name: "description", content: meta.description }, - ], - }; - }, - loader: async ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ctx: LoaderFnContext, - ): Promise => { - // Redirect on index case (no splat) - if (!ctx.params._splat) { - // eslint-disable-next-line @typescript-eslint/only-throw-error - throw redirect({ - to: "/terms/$", - params: { _splat: "use" }, - replace: true, - }); - } - return termsContentLoader()(ctx); - }, - component: () => { - const content: PolicyLoaderData = Route.useLoaderData(); - if (!content) { - return ; - } - // todo(sebastian): Port actual page - return ; - }, -}); +export const Route = createFileRoute("/terms/$")( + createContentRouteConfig("/terms/$", { + getMeta: getAllPolicyMeta, + moduleMap: POLICY_MODULE_MAP, + prefix: "policy", + subdirectory: "terms", + component: PolicyPage, + redirectOnEmptySplat: { to: "/terms/$", params: { _splat: "use" } }, + }), +);