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 }
|
||||
})
|
||||
}
|
||||
|
||||
Generated
+41
-39
@@ -9,10 +9,13 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"crypto-pro": "^2.3.0",
|
||||
"dompurify": "^3.4.11",
|
||||
"marked": "^12.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
@@ -1104,9 +1107,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1121,9 +1121,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1138,9 +1135,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1155,9 +1149,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1172,9 +1163,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1189,9 +1177,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1206,9 +1191,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1223,9 +1205,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1240,9 +1219,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1257,9 +1233,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1274,9 +1247,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1291,9 +1261,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1308,9 +1275,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1446,6 +1410,16 @@
|
||||
"@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": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -1480,6 +1454,13 @@
|
||||
"@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": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
@@ -1764,6 +1745,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.5.389",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz",
|
||||
@@ -2342,6 +2332,18 @@
|
||||
"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": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
|
||||
@@ -12,11 +12,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"crypto-pro": "^2.3.0",
|
||||
"dompurify": "^3.4.11",
|
||||
"marked": "^12.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"marked": "^12.0.0"
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
|
||||
@@ -2,10 +2,11 @@ import { useState, useEffect } from 'react'
|
||||
import { AuthPage } from './pages/AuthPage'
|
||||
import { CheckCodesPage } from './pages/CheckCodesPage'
|
||||
import { UploadUpdPage } from './pages/UploadUpdPage'
|
||||
import { PlansPage } from './pages/PlansPage'
|
||||
import { setOnUnauthorized, logout as apiLogout, getEnv, setEnv, getAuthStatus } from './services/api'
|
||||
import type { ApiEnv } from './types'
|
||||
|
||||
type Page = 'auth' | 'check' | 'upload'
|
||||
type Page = 'auth' | 'check' | 'upload' | 'plans'
|
||||
|
||||
export default function App() {
|
||||
const [page, setPage] = useState<Page>('auth')
|
||||
@@ -77,6 +78,10 @@ export default function App() {
|
||||
disabled={!authenticated}>
|
||||
Загрузка УПД
|
||||
</button>
|
||||
<button onClick={() => setPage('plans')}
|
||||
style={navBtnStyle(page === 'plans')}>
|
||||
Планы
|
||||
</button>
|
||||
</nav>
|
||||
<div style={{ marginLeft: 'auto', fontSize: 13, display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<label>
|
||||
@@ -119,6 +124,7 @@ export default function App() {
|
||||
{page === 'auth' && <AuthPage key={env} onAuth={handleAuth} />}
|
||||
{page === 'check' && <CheckCodesPage key={env} />}
|
||||
{page === 'upload' && <UploadUpdPage key={env} />}
|
||||
{page === 'plans' && <PlansPage key={env} />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }> {
|
||||
return request(`/api/plans/${filename}`)
|
||||
return request(`/api/plans/${encodeURIComponent(filename)}`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user