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 { readdir, readFile, stat } from 'node:fs/promises'
import { resolve, extname } from 'node:path' import { resolve, extname } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { FastifyInstance } from 'fastify' 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> { export async function plansRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/plans', async () => { 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 }[] = [] const mdFiles: { name: string; path: string; size: number }[] = []
for (const file of files) { for (const file of files) {
if (extname(file) !== '.md') continue if (extname(file) !== '.md') continue
const fullPath = resolve(PLANS_DIR, file) const fullPath = resolve(PLANS_DIR, file)
const stats = await stat(fullPath) try {
mdFiles.push({ name: file, path: file, size: stats.size }) 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)) 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) => { app.get<{ Params: { filename: string } }>('/api/plans/:filename', async (request, reply) => {
const { filename } = request.params 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' }) return reply.status(400).send({ error: 'Invalid filename' })
} }
const fullPath = resolve(PLANS_DIR, filename) const fullPath = resolve(PLANS_DIR, filename)
try { try {
await stat(fullPath) const content = await readFile(fullPath, 'utf-8')
return { name: filename, content }
} catch { } catch {
return reply.status(404).send({ error: 'File not found' }) return reply.status(404).send({ error: 'File not found' })
} }
const content = await readFile(fullPath, 'utf-8')
return { name: filename, content }
}) })
} }
+41 -39
View File
@@ -9,10 +9,13 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"crypto-pro": "^2.3.0", "crypto-pro": "^2.3.0",
"dompurify": "^3.4.11",
"marked": "^12.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/dompurify": "^3.0.5",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
@@ -1104,9 +1107,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1121,9 +1121,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1138,9 +1135,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1155,9 +1149,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1172,9 +1163,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1189,9 +1177,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1206,9 +1191,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1223,9 +1205,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1240,9 +1219,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1257,9 +1233,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1274,9 +1247,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1291,9 +1261,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1308,9 +1275,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1446,6 +1410,16 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1480,6 +1454,13 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1764,6 +1745,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dompurify": {
"version": "3.4.11",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.389", "version": "1.5.389",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz",
@@ -2342,6 +2332,18 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/marked": {
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz",
"integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+4 -2
View File
@@ -12,11 +12,13 @@
}, },
"dependencies": { "dependencies": {
"crypto-pro": "^2.3.0", "crypto-pro": "^2.3.0",
"dompurify": "^3.4.11",
"marked": "^12.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0"
"marked": "^12.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/dompurify": "^3.0.5",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
+7 -1
View File
@@ -2,10 +2,11 @@ import { useState, useEffect } from 'react'
import { AuthPage } from './pages/AuthPage' import { AuthPage } from './pages/AuthPage'
import { CheckCodesPage } from './pages/CheckCodesPage' import { CheckCodesPage } from './pages/CheckCodesPage'
import { UploadUpdPage } from './pages/UploadUpdPage' import { UploadUpdPage } from './pages/UploadUpdPage'
import { PlansPage } from './pages/PlansPage'
import { setOnUnauthorized, logout as apiLogout, getEnv, setEnv, getAuthStatus } from './services/api' import { setOnUnauthorized, logout as apiLogout, getEnv, setEnv, getAuthStatus } from './services/api'
import type { ApiEnv } from './types' import type { ApiEnv } from './types'
type Page = 'auth' | 'check' | 'upload' type Page = 'auth' | 'check' | 'upload' | 'plans'
export default function App() { export default function App() {
const [page, setPage] = useState<Page>('auth') const [page, setPage] = useState<Page>('auth')
@@ -77,6 +78,10 @@ export default function App() {
disabled={!authenticated}> disabled={!authenticated}>
Загрузка УПД Загрузка УПД
</button> </button>
<button onClick={() => setPage('plans')}
style={navBtnStyle(page === 'plans')}>
Планы
</button>
</nav> </nav>
<div style={{ marginLeft: 'auto', fontSize: 13, display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ marginLeft: 'auto', fontSize: 13, display: 'flex', alignItems: 'center', gap: 12 }}>
<label> <label>
@@ -119,6 +124,7 @@ export default function App() {
{page === 'auth' && <AuthPage key={env} onAuth={handleAuth} />} {page === 'auth' && <AuthPage key={env} onAuth={handleAuth} />}
{page === 'check' && <CheckCodesPage key={env} />} {page === 'check' && <CheckCodesPage key={env} />}
{page === 'upload' && <UploadUpdPage key={env} />} {page === 'upload' && <UploadUpdPage key={env} />}
{page === 'plans' && <PlansPage key={env} />}
</main> </main>
</div> </div>
) )
+116
View File
@@ -0,0 +1,116 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import { getPlansList, getPlanContent } from '../services/api'
export function PlansPage() {
const [plans, setPlans] = useState<{ name: string; path: string; size: number }[]>([])
const [selected, setSelected] = useState<string | null>(null)
const [content, setContent] = useState('')
const [listLoading, setListLoading] = useState(true)
const [contentLoading, setContentLoading] = useState(false)
const [listError, setListError] = useState('')
const [contentError, setContentError] = useState('')
const abortRef = useRef<AbortController | null>(null)
useEffect(() => {
setListLoading(true)
getPlansList()
.then(setPlans)
.catch(e => setListError(e.message))
.finally(() => setListLoading(false))
}, [])
useEffect(() => {
return () => abortRef.current?.abort()
}, [])
const handleSelect = useCallback(async (filename: string) => {
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
setSelected(filename)
setContentLoading(true)
setContentError('')
try {
const res = await getPlanContent(filename)
if (!controller.signal.aborted) {
setContent(res.content)
}
} catch (e: any) {
if (!controller.signal.aborted) {
setContentError(e.message)
}
} finally {
if (!controller.signal.aborted) {
setContentLoading(false)
}
}
}, [])
const rawHtml = useMemo(() => {
if (!content) return ''
return marked.parse(content, { async: false }) as string
}, [content])
const sanitizedHtml = useMemo(() => {
return DOMPurify.sanitize(rawHtml)
}, [rawHtml])
return (
<div style={{ display: 'flex', gap: 24, minHeight: '70vh' }}>
<aside style={{ width: 280, flexShrink: 0 }}>
<h2 style={{ fontSize: 16, marginTop: 0 }}>Планы</h2>
{listLoading && <p style={{ color: '#888' }}>Загрузка...</p>}
{!listLoading && plans.length === 0 && !listError && (
<p style={{ color: '#888' }}>Нет файлов планов</p>
)}
{listError && <p style={{ color: '#c62828' }}>{listError}</p>}
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{plans.map(p => (
<li key={p.name} style={{ marginBottom: 4 }}>
<button
onClick={() => handleSelect(p.name)}
style={{
display: 'block',
width: '100%',
textAlign: 'left',
background: selected === p.name ? '#e3f2fd' : 'transparent',
border: selected === p.name ? '1px solid #90caf9' : '1px solid transparent',
borderRadius: 4,
padding: '8px 12px',
cursor: 'pointer',
fontSize: 13,
color: '#1565c0',
fontFamily: 'monospace',
}}
>
{p.name}
</button>
</li>
))}
</ul>
</aside>
<main style={{ flex: 1, minWidth: 0 }}>
{contentLoading && <p style={{ color: '#888' }}>Загрузка содержимого...</p>}
{!contentLoading && !selected && (
<p style={{ color: '#888', marginTop: 40, textAlign: 'center' }}>
Выберите план из списка слева
</p>
)}
{!contentLoading && selected && contentError && (
<p style={{ color: '#c62828' }}>Ошибка: {contentError}</p>
)}
{!contentLoading && selected && !contentError && (
<div
className="plan-content"
style={{ lineHeight: 1.7, fontSize: 14 }}
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
/>
)}
</main>
</div>
)
}
+1 -1
View File
@@ -102,5 +102,5 @@ export async function getPlansList(): Promise<{ name: string; path: string; size
} }
export async function getPlanContent(filename: string): Promise<{ name: string; content: string }> { export async function getPlanContent(filename: string): Promise<{ name: string; content: string }> {
return request(`/api/plans/${filename}`) return request(`/api/plans/${encodeURIComponent(filename)}`)
} }