import fs from 'fs' import path from 'path' import matter from 'gray-matter' import { remark } from 'remark' import remarkGfm from 'remark-gfm' import { FrontMatter, Post } from './types/frontmatter' import { generateExcerpt } from './utils' import { remarkCopyImages } from './remark-copy-images' import { remarkInternalLinks } from './remark-internal-links' const POSTS_PATH = path.join(process.cwd(), 'content', 'blog') export function sanitizePath(inputPath: string): string { const normalized = path.normalize(inputPath).replace(/^(\.\.[/\\])+/, '') if (normalized.includes('..') || path.isAbsolute(normalized)) { throw new Error('Invalid path') } // CRITICAL: Verify resolved path stays within content directory const resolvedPath = path.resolve(POSTS_PATH, normalized) const allowedBasePath = path.resolve(POSTS_PATH) if (!resolvedPath.startsWith(allowedBasePath)) { throw new Error('Path traversal attempt detected') } return normalized } export function calculateReadingTime(content: string): number { const wordsPerMinute = 200 const words = content.trim().split(/\s+/).length return Math.ceil(words / wordsPerMinute) } export function validateFrontmatter(data: any): FrontMatter { if (!data.title || typeof data.title !== 'string') { throw new Error('Invalid title') } if (!data.description || typeof data.description !== 'string') { throw new Error('Invalid description') } if (!data.date || typeof data.date !== 'string') { throw new Error('Invalid date') } if (!data.author || typeof data.author !== 'string') { throw new Error('Invalid author') } if (!data.category || typeof data.category !== 'string') { throw new Error('Invalid category') } if (!Array.isArray(data.tags) || data.tags.length === 0 || data.tags.length > 3) { throw new Error('Tags must be array with 1-3 items') } return { title: data.title, description: data.description, date: data.date, author: data.author, category: data.category, tags: data.tags, image: data.image, draft: data.draft || false, } } export async function getPostBySlug(slug: string | string[]): Promise { const slugArray = Array.isArray(slug) ? slug : slug.split('/') const sanitized = slugArray.map(s => sanitizePath(s)) const fullPath = path.join(POSTS_PATH, ...sanitized) + '.md' if (!fs.existsSync(fullPath)) { return null } const fileContents = fs.readFileSync(fullPath, 'utf8') const { data, content } = matter(fileContents) const frontmatter = validateFrontmatter(data) const processed = await remark() .use(remarkGfm) .use(remarkCopyImages, { contentDir: 'content/blog', publicDir: 'public/blog', currentSlug: sanitized.join('/'), }) .use(remarkInternalLinks) .process(content) const processedContent = processed.toString() return { slug: sanitized.join('/'), frontmatter, content: processedContent, readingTime: calculateReadingTime(processedContent), excerpt: generateExcerpt(processedContent), } } export async function getAllPosts(includeContent = false): Promise { const posts: Post[] = [] async function walkDir(dir: string, prefix = ''): Promise { const files = fs.readdirSync(dir) for (const file of files) { const filePath = path.join(dir, file) const stat = fs.statSync(filePath) if (stat.isDirectory()) { await walkDir(filePath, prefix ? `${prefix}/${file}` : file) } else if (file.endsWith('.md')) { const slug = prefix ? `${prefix}/${file.replace(/\.md$/, '')}` : file.replace(/\.md$/, '') try { const post = await getPostBySlug(slug.split('/')) if (post && !post.frontmatter.draft) { posts.push(includeContent ? post : { ...post, content: '' }) } } catch (error) { console.error(`Error loading post ${slug}:`, error) } } } } if (fs.existsSync(POSTS_PATH)) { await walkDir(POSTS_PATH) } return posts.sort( (a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime() ) } export async function getRelatedPosts(currentSlug: string, limit = 3): Promise { const currentPost = await getPostBySlug(currentSlug) if (!currentPost) return [] const allPosts = await getAllPosts(false) const { category, tags } = currentPost.frontmatter const scored = allPosts .filter(post => post.slug !== currentSlug) .map(post => { let score = 0 if (post.frontmatter.category === category) score += 3 score += post.frontmatter.tags.filter(tag => tags.includes(tag)).length * 2 return { post, score } }) .filter(({ score }) => score > 0) .sort((a, b) => b.score - a.score) return scored.slice(0, limit).map(({ post }) => post) } export function getAllPostSlugs(): string[][] { const slugs: string[][] = [] function walkDir(dir: string, prefix: string[] = []): void { const files = fs.readdirSync(dir) for (const file of files) { const filePath = path.join(dir, file) const stat = fs.statSync(filePath) if (stat.isDirectory()) { walkDir(filePath, [...prefix, file]) } else if (file.endsWith('.md')) { slugs.push([...prefix, file.replace(/\.md$/, '')]) } } } if (fs.existsSync(POSTS_PATH)) { walkDir(POSTS_PATH) } return slugs }