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:
kislovdm
2026-07-08 18:28:43 +03:00
parent 8f0f0cd36e
commit 51e3f3776a
6 changed files with 189 additions and 52 deletions
+20 -9
View File
@@ -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 }
})
}