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, locale?: string): 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, locale: data.locale || locale || 'en', image: data.image, draft: data.draft || false, } } export async function getPostBySlug( slug: string | string[], locale: string = 'en' ): Promise { const slugArray = Array.isArray(slug) ? slug : slug.split('/') const sanitized = slugArray.map(s => sanitizePath(s)) const fullPath = path.join(POSTS_PATH, locale, ...sanitized) + '.md' if (!fs.existsSync(fullPath)) { return null } const fileContents = fs.readFileSync(fullPath, 'utf8') const { data, content } = matter(fileContents) const frontmatter = validateFrontmatter(data, locale) const processed = await remark() .use(remarkGfm) .use(remarkCopyImages, { contentDir: 'content/blog', publicDir: 'public/blog', currentSlug: sanitized.join('/'), }) .use(remarkInternalLinks, { locale }) .process(content) const processedContent = processed.toString() return { slug: sanitized.join('/'), locale, frontmatter, content: processedContent, readingTime: calculateReadingTime(processedContent), excerpt: generateExcerpt(processedContent), } } export async function getAllPosts(locale: string = 'en', includeContent = false): Promise { const posts: Post[] = [] const localeDir = path.join(POSTS_PATH, locale) if (!fs.existsSync(localeDir)) { console.warn(`Locale directory not found: ${localeDir}`) return [] } 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('/'), locale) if (post && !post.frontmatter.draft) { posts.push(includeContent ? post : { ...post, content: '' }) } } catch (error) { console.error(`Error loading post ${slug}:`, error) } } } } await walkDir(localeDir) return posts.sort( (a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime() ) } export async function getRelatedPosts( currentSlug: string, locale: string = 'en', limit = 3 ): Promise { const currentPost = await getPostBySlug(currentSlug, locale) if (!currentPost) return [] const allPosts = await getAllPosts(locale, 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(locale: string = 'en'): string[][] { const slugs: string[][] = [] const localeDir = path.join(POSTS_PATH, locale) if (!fs.existsSync(localeDir)) { return [] } 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$/, '')]) } } } walkDir(localeDir) return slugs } export async function getAvailableLocales(slug: string): Promise { const locales = ['en', 'ro'] const available: string[] = [] for (const locale of locales) { const post = await getPostBySlug(slug, locale) if (post) { available.push(locale) } } return available } export async function getPostCount(locale: string): Promise { const posts = await getAllPosts(locale, false) return posts.length }