feat(ui.client): add plans page with markdown rendering
- Add PlansPage component with DOMPurify-sanitized markdown rendering
- Add AbortController to prevent race conditions on fast switching
- Memoize marked.parse output
- Add key={env} to PlansPage for env-consistent remount
- Encode filename in API request to handle special chars
- Fix backend: fileURLToPath instead of import.meta.dirname
- Fix backend: try/catch on readdir, remove redundant stat calls
- Fix backend: block backslash path traversal
This commit is contained in:
@@ -1,19 +1,32 @@
|
||||
import { readdir, readFile, stat } from 'node:fs/promises'
|
||||
import { resolve, extname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
const PLANS_DIR = resolve(import.meta.dirname || __dirname, '../../plans')
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = resolve(__filename, '..')
|
||||
const PLANS_DIR = resolve(__dirname, '../../plans')
|
||||
|
||||
export async function plansRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get('/api/plans', async () => {
|
||||
const files = await readdir(PLANS_DIR)
|
||||
let files: string[]
|
||||
try {
|
||||
files = await readdir(PLANS_DIR)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const mdFiles: { name: string; path: string; size: number }[] = []
|
||||
|
||||
for (const file of files) {
|
||||
if (extname(file) !== '.md') continue
|
||||
const fullPath = resolve(PLANS_DIR, file)
|
||||
const stats = await stat(fullPath)
|
||||
mdFiles.push({ name: file, path: file, size: stats.size })
|
||||
try {
|
||||
const stats = await stat(fullPath)
|
||||
mdFiles.push({ name: file, path: file, size: stats.size })
|
||||
} catch {
|
||||
// skip files that can't be stat'd
|
||||
}
|
||||
}
|
||||
|
||||
mdFiles.sort((a, b) => b.name.localeCompare(a.name))
|
||||
@@ -23,19 +36,17 @@ export async function plansRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get<{ Params: { filename: string } }>('/api/plans/:filename', async (request, reply) => {
|
||||
const { filename } = request.params
|
||||
|
||||
if (extname(filename) !== '.md' || filename.includes('..') || filename.includes('/')) {
|
||||
if (extname(filename) !== '.md' || filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||
return reply.status(400).send({ error: 'Invalid filename' })
|
||||
}
|
||||
|
||||
const fullPath = resolve(PLANS_DIR, filename)
|
||||
|
||||
try {
|
||||
await stat(fullPath)
|
||||
const content = await readFile(fullPath, 'utf-8')
|
||||
return { name: filename, content }
|
||||
} catch {
|
||||
return reply.status(404).send({ error: 'File not found' })
|
||||
}
|
||||
|
||||
const content = await readFile(fullPath, 'utf-8')
|
||||
return { name: filename, content }
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user