From 51e3f3776a05c77b2295dbcb8f74d1c1bd63de63 Mon Sep 17 00:00:00 2001 From: kislovdm Date: Wed, 8 Jul 2026 18:28:43 +0300 Subject: [PATCH] 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 --- backend/src/routes/plans.ts | 29 +++++--- frontend/package-lock.json | 80 ++++++++++----------- frontend/package.json | 6 +- frontend/src/App.tsx | 8 ++- frontend/src/pages/PlansPage.tsx | 116 +++++++++++++++++++++++++++++++ frontend/src/services/api.ts | 2 +- 6 files changed, 189 insertions(+), 52 deletions(-) create mode 100644 frontend/src/pages/PlansPage.tsx diff --git a/backend/src/routes/plans.ts b/backend/src/routes/plans.ts index 4f87084..5651cb6 100644 --- a/backend/src/routes/plans.ts +++ b/backend/src/routes/plans.ts @@ -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 { 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 { 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 } }) } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f2ae5cd..1511d88 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 94721d2..42d5958 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4f76392..0755e29 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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('auth') @@ -77,6 +78,10 @@ export default function App() { disabled={!authenticated}> Загрузка УПД +
) diff --git a/frontend/src/pages/PlansPage.tsx b/frontend/src/pages/PlansPage.tsx new file mode 100644 index 0000000..7a314ec --- /dev/null +++ b/frontend/src/pages/PlansPage.tsx @@ -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(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(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 ( +
+ + +
+ {contentLoading &&

Загрузка содержимого...

} + {!contentLoading && !selected && ( +

+ Выберите план из списка слева +

+ )} + {!contentLoading && selected && contentError && ( +

Ошибка: {contentError}

+ )} + {!contentLoading && selected && !contentError && ( +
+ )} +
+
+ ) +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index b9bf2c6..8127fc1 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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)}`) }