@lievclouds/dsh-cortex 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of @lievclouds/dsh-cortex might be problematic. Click here for more details.
- package/client.js +419 -419
- package/package.json +1 -1
- package/src/cortex-ui/dsh/api.ts +5 -2
- package/src/cortex-ui/renderer/components/vuln/ExportReportDialog.tsx +120 -0
- package/src/cortex-ui/renderer/components/vuln/VulnDetail.tsx +14 -3
- package/src/cortex-ui/renderer/i18n/messages/vuln.ts +14 -0
- package/src/cortex-ui/renderer/lib/transport/httpClient.ts +4 -1
- package/src/cortex-ui/renderer/pages/DashboardPage.tsx +15 -3
- package/src/cortex-ui/shared/ipc.ts +6 -2
- package/src/host/cortex/vulns.js +32 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lievclouds/dsh-cortex",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "Cortex workbench for DeepSeek Harness: the original Cortex UI (vulnerabilities, assets, internal operations, Memory Nebula, snapshots, retest) as a native DSH panel, plus 12 native agent tools so DSH conversations use the local Cortex SQLite without MCP.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
package/src/cortex-ui/dsh/api.ts
CHANGED
|
@@ -112,8 +112,11 @@ export function createCortexApi(): CortexApi {
|
|
|
112
112
|
patch(`/vulns/${encodeURIComponent(input.id)}/status`, { status: input.status }),
|
|
113
113
|
deleteVulnerability: (id) => del(`/vulns/${encodeURIComponent(id)}`),
|
|
114
114
|
deleteVulnerabilities: (ids) => post('/vulns/delete-many', { ids }),
|
|
115
|
-
exportVulnerabilities: async (ids) => {
|
|
116
|
-
const response = await post('/vulns/export', {
|
|
115
|
+
exportVulnerabilities: async (ids, outputDir) => {
|
|
116
|
+
const response = await post('/vulns/export', {
|
|
117
|
+
ids,
|
|
118
|
+
...(outputDir && outputDir.trim() !== '' ? { outputDir: outputDir.trim() } : {}),
|
|
119
|
+
})
|
|
117
120
|
const files = Array.isArray(response?.files) ? response.files : []
|
|
118
121
|
const savedPaths = Array.isArray(response?.savedPaths) ? response.savedPaths : []
|
|
119
122
|
return {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
|
2
|
+
import { Download, FolderOpen, Loader2 } from 'lucide-react'
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { Button } from '@renderer/components/ui/button'
|
|
5
|
+
import { useI18n } from '@renderer/i18n'
|
|
6
|
+
import { getPortalContainer } from '../../../dsh/portal'
|
|
7
|
+
|
|
8
|
+
const STORAGE_KEY = 'cortex.export.dir'
|
|
9
|
+
|
|
10
|
+
function isAbsoluteLike(value: string): boolean {
|
|
11
|
+
const trimmed = value.trim()
|
|
12
|
+
if (trimmed.startsWith('~')) return true
|
|
13
|
+
if (/^[A-Za-z]:[\\/]/.test(trimmed)) return true
|
|
14
|
+
if (trimmed.startsWith('\\\\') || trimmed.startsWith('//')) return true
|
|
15
|
+
return trimmed.startsWith('/')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ExportReportDialogProps {
|
|
19
|
+
open: boolean
|
|
20
|
+
/** Number of reports being exported, for the title/confirm label. */
|
|
21
|
+
count: number
|
|
22
|
+
pending: boolean
|
|
23
|
+
onOpenChange: (open: boolean) => void
|
|
24
|
+
onConfirm: (outputDir: string | undefined) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Export-path picker. The DSH WebView cannot open a native directory picker, so
|
|
29
|
+
* the user types an absolute directory (or leaves it empty for the default
|
|
30
|
+
* `~/.cortex/exports/<timestamp>/`); the Host writes the Markdown there.
|
|
31
|
+
*/
|
|
32
|
+
export function ExportReportDialog({
|
|
33
|
+
open,
|
|
34
|
+
count,
|
|
35
|
+
pending,
|
|
36
|
+
onOpenChange,
|
|
37
|
+
onConfirm
|
|
38
|
+
}: ExportReportDialogProps) {
|
|
39
|
+
const { t } = useI18n()
|
|
40
|
+
const [directory, setDirectory] = useState('')
|
|
41
|
+
const [invalid, setInvalid] = useState(false)
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!open) return
|
|
45
|
+
const stored = window.localStorage.getItem(STORAGE_KEY) ?? ''
|
|
46
|
+
setDirectory(stored)
|
|
47
|
+
setInvalid(false)
|
|
48
|
+
}, [open])
|
|
49
|
+
|
|
50
|
+
const confirm = (): void => {
|
|
51
|
+
const trimmed = directory.trim()
|
|
52
|
+
if (trimmed !== '' && !isAbsoluteLike(trimmed)) {
|
|
53
|
+
setInvalid(true)
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
if (trimmed !== '') window.localStorage.setItem(STORAGE_KEY, trimmed)
|
|
57
|
+
else window.localStorage.removeItem(STORAGE_KEY)
|
|
58
|
+
onConfirm(trimmed === '' ? undefined : trimmed)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange} modal={false}>
|
|
63
|
+
<DialogPrimitive.Portal container={getPortalContainer()}>
|
|
64
|
+
<DialogPrimitive.Overlay className="sheet-overlay fixed inset-0 z-[70] bg-[hsl(224_18%_10%/0.3)]" />
|
|
65
|
+
<DialogPrimitive.Content
|
|
66
|
+
data-testid="export-report-dialog"
|
|
67
|
+
className="fixed left-1/2 top-1/2 z-[70] w-[min(480px,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-background p-5 shadow-panel focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
68
|
+
>
|
|
69
|
+
<DialogPrimitive.Title className="text-[16px] font-medium tracking-tight text-foreground">
|
|
70
|
+
{t('vuln.export.title')}
|
|
71
|
+
</DialogPrimitive.Title>
|
|
72
|
+
<DialogPrimitive.Description className="mt-1.5 text-[12.5px] leading-relaxed text-muted-foreground">
|
|
73
|
+
{t('vuln.export.description', { count })}
|
|
74
|
+
</DialogPrimitive.Description>
|
|
75
|
+
|
|
76
|
+
<label
|
|
77
|
+
htmlFor="export-output-dir"
|
|
78
|
+
className="mt-4 flex items-center gap-1.5 text-[11.5px] font-medium text-foreground"
|
|
79
|
+
>
|
|
80
|
+
<FolderOpen aria-hidden="true" className="h-3.5 w-3.5 text-muted-foreground" />
|
|
81
|
+
{t('vuln.export.dirLabel')}
|
|
82
|
+
</label>
|
|
83
|
+
<input
|
|
84
|
+
id="export-output-dir"
|
|
85
|
+
data-testid="export-output-dir"
|
|
86
|
+
value={directory}
|
|
87
|
+
spellCheck={false}
|
|
88
|
+
disabled={pending}
|
|
89
|
+
placeholder={t('vuln.export.dirPlaceholder')}
|
|
90
|
+
onChange={(event) => {
|
|
91
|
+
setDirectory(event.target.value)
|
|
92
|
+
if (invalid) setInvalid(false)
|
|
93
|
+
}}
|
|
94
|
+
onKeyDown={(event) => {
|
|
95
|
+
if (event.key === 'Enter') confirm()
|
|
96
|
+
}}
|
|
97
|
+
className="mt-1.5 h-9 w-full rounded-md border border-border bg-background px-2.5 font-mono text-[11.5px] text-foreground placeholder:text-muted-foreground/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
98
|
+
/>
|
|
99
|
+
<p className={invalid ? 'mt-1.5 text-[10.5px] text-red-700' : 'mt-1.5 text-[10.5px] text-muted-foreground'}>
|
|
100
|
+
{invalid ? t('vuln.export.dirInvalid') : t('vuln.export.dirHint')}
|
|
101
|
+
</p>
|
|
102
|
+
|
|
103
|
+
<div className="mt-5 flex items-center justify-end gap-2">
|
|
104
|
+
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)} disabled={pending}>
|
|
105
|
+
{t('common.cancel')}
|
|
106
|
+
</Button>
|
|
107
|
+
<Button size="sm" onClick={confirm} disabled={pending} className="gap-1.5">
|
|
108
|
+
{pending ? (
|
|
109
|
+
<Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
|
|
110
|
+
) : (
|
|
111
|
+
<Download aria-hidden="true" className="h-3.5 w-3.5" />
|
|
112
|
+
)}
|
|
113
|
+
{pending ? t('vuln.detail.exporting') : t('vuln.export.confirm')}
|
|
114
|
+
</Button>
|
|
115
|
+
</div>
|
|
116
|
+
</DialogPrimitive.Content>
|
|
117
|
+
</DialogPrimitive.Portal>
|
|
118
|
+
</DialogPrimitive.Root>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
@@ -27,6 +27,7 @@ import { EmptyState } from '@renderer/components/common/EmptyState'
|
|
|
27
27
|
import { MarkdownText } from '@renderer/components/common/MarkdownText'
|
|
28
28
|
import { VulnSummary } from './VulnSummary'
|
|
29
29
|
import { ConfirmDeleteDialog } from './ConfirmDeleteDialog'
|
|
30
|
+
import { ExportReportDialog } from './ExportReportDialog'
|
|
30
31
|
import { apiUnavailableMessage, getApi, toErrorMessage } from '@renderer/lib/api'
|
|
31
32
|
import { getPrefetchedVulnerability } from '@renderer/lib/vulnPreload'
|
|
32
33
|
import { formatDateTime, formatVulnType } from '@renderer/lib/format'
|
|
@@ -137,6 +138,7 @@ export function VulnDetail({
|
|
|
137
138
|
const [confirmOpen, setConfirmOpen] = useState(false)
|
|
138
139
|
const [deleting, setDeleting] = useState(false)
|
|
139
140
|
const [exporting, setExporting] = useState(false)
|
|
141
|
+
const [exportOpen, setExportOpen] = useState(false)
|
|
140
142
|
const [activeTab, setActiveTab] = useState('steps')
|
|
141
143
|
|
|
142
144
|
const { t, lang, statusLabel } = useI18n()
|
|
@@ -252,7 +254,7 @@ export function VulnDetail({
|
|
|
252
254
|
}
|
|
253
255
|
}
|
|
254
256
|
|
|
255
|
-
const handleExport = async () => {
|
|
257
|
+
const handleExport = async (outputDir?: string) => {
|
|
256
258
|
if (!detail || loading) return
|
|
257
259
|
const api = getApi()
|
|
258
260
|
if (!api) {
|
|
@@ -261,7 +263,7 @@ export function VulnDetail({
|
|
|
261
263
|
}
|
|
262
264
|
setExporting(true)
|
|
263
265
|
try {
|
|
264
|
-
const result = await api.exportVulnerabilities([detail.id])
|
|
266
|
+
const result = await api.exportVulnerabilities([detail.id], outputDir)
|
|
265
267
|
if (!result.cancelled) {
|
|
266
268
|
const savedPath = result.savedPaths?.[0]
|
|
267
269
|
toast.success(
|
|
@@ -273,6 +275,7 @@ export function VulnDetail({
|
|
|
273
275
|
toast.error(t('vuln.toast.exportFailed', { error: toErrorMessage(cause, t) }))
|
|
274
276
|
} finally {
|
|
275
277
|
setExporting(false)
|
|
278
|
+
setExportOpen(false)
|
|
276
279
|
}
|
|
277
280
|
}
|
|
278
281
|
|
|
@@ -295,7 +298,7 @@ export function VulnDetail({
|
|
|
295
298
|
variant="ghost"
|
|
296
299
|
size="sm"
|
|
297
300
|
data-testid="export-vuln-md"
|
|
298
|
-
onClick={() =>
|
|
301
|
+
onClick={() => setExportOpen(true)}
|
|
299
302
|
disabled={loading || exporting}
|
|
300
303
|
className="h-7 gap-1.5 px-2 text-[11px]"
|
|
301
304
|
>
|
|
@@ -620,6 +623,14 @@ export function VulnDetail({
|
|
|
620
623
|
onConfirm={() => void handleDelete()}
|
|
621
624
|
/>
|
|
622
625
|
) : null}
|
|
626
|
+
|
|
627
|
+
<ExportReportDialog
|
|
628
|
+
open={exportOpen}
|
|
629
|
+
count={1}
|
|
630
|
+
pending={exporting}
|
|
631
|
+
onOpenChange={setExportOpen}
|
|
632
|
+
onConfirm={(outputDir) => void handleExport(outputDir)}
|
|
633
|
+
/>
|
|
623
634
|
</>
|
|
624
635
|
)
|
|
625
636
|
}
|
|
@@ -92,6 +92,13 @@ export const vulnMessages: MessageBundle = {
|
|
|
92
92
|
'vuln.toast.exportedMany': '已导出 {count} 份 Markdown 报告',
|
|
93
93
|
'vuln.toast.exported': 'Markdown 报告已导出',
|
|
94
94
|
'vuln.toast.exportedPath': '已保存到:{path}',
|
|
95
|
+
'vuln.export.title': '导出 Markdown 报告',
|
|
96
|
+
'vuln.export.description': '共 {count} 份报告。可指定导出目录,留空则使用默认目录。',
|
|
97
|
+
'vuln.export.dirLabel': '导出目录',
|
|
98
|
+
'vuln.export.dirPlaceholder': '例如 C:\\Users\\you\\Downloads',
|
|
99
|
+
'vuln.export.dirHint': '留空:~/.cortex/exports/<时间戳>/;支持绝对路径或 ~ 开头路径。',
|
|
100
|
+
'vuln.export.dirInvalid': '请输入绝对路径(如 C:\\Reports 或 ~/Reports)',
|
|
101
|
+
'vuln.export.confirm': '导出',
|
|
95
102
|
'vuln.toast.exportFailed': '导出失败:{error}',
|
|
96
103
|
'vuln.toast.deleted': '已删除 {id}',
|
|
97
104
|
'vuln.toast.bulkDeleted': '已删除 {count} 条漏洞',
|
|
@@ -203,6 +210,13 @@ export const vulnMessages: MessageBundle = {
|
|
|
203
210
|
'vuln.toast.exportedMany': 'Exported {count} Markdown reports',
|
|
204
211
|
'vuln.toast.exported': 'Markdown report exported',
|
|
205
212
|
'vuln.toast.exportedPath': 'Saved to: {path}',
|
|
213
|
+
'vuln.export.title': 'Export Markdown report',
|
|
214
|
+
'vuln.export.description': '{count} report(s). Choose an output directory, or leave empty to use the default.',
|
|
215
|
+
'vuln.export.dirLabel': 'Output directory',
|
|
216
|
+
'vuln.export.dirPlaceholder': 'e.g. C:\\Users\\you\\Downloads',
|
|
217
|
+
'vuln.export.dirHint': 'Empty: ~/.cortex/exports/<timestamp>/; absolute or ~-prefixed paths are accepted.',
|
|
218
|
+
'vuln.export.dirInvalid': 'Enter an absolute path (e.g. C:\\Reports or ~/Reports)',
|
|
219
|
+
'vuln.export.confirm': 'Export',
|
|
206
220
|
'vuln.toast.exportFailed': 'Export failed: {error}',
|
|
207
221
|
'vuln.toast.deleted': 'Deleted {id}',
|
|
208
222
|
'vuln.toast.bulkDeleted': 'Deleted {count} vulnerabilities',
|
|
@@ -261,7 +261,10 @@ export function createHttpApi(): CortexApi {
|
|
|
261
261
|
* saves them. Resolves the downloaded file names, or `cancelled` when there
|
|
262
262
|
* was nothing to export (`files: []`).
|
|
263
263
|
*/
|
|
264
|
-
exportVulnerabilities: async (
|
|
264
|
+
exportVulnerabilities: async (
|
|
265
|
+
ids: string[],
|
|
266
|
+
_outputDir?: string
|
|
267
|
+
): Promise<ExportReportsResult> => {
|
|
265
268
|
const response = await request<WebExportResponse>(WEB_API_PATHS.vulnerabilitiesExport, {
|
|
266
269
|
method: 'POST',
|
|
267
270
|
body: ids
|
|
@@ -15,6 +15,7 @@ import { PaginationBar } from '@renderer/components/vuln/PaginationBar'
|
|
|
15
15
|
import { VulnTable } from '@renderer/components/vuln/VulnTable'
|
|
16
16
|
import { VulnDetail } from '@renderer/components/vuln/VulnDetail'
|
|
17
17
|
import { BulkDeleteDialog } from '@renderer/components/vuln/BulkDeleteDialog'
|
|
18
|
+
import { ExportReportDialog } from '@renderer/components/vuln/ExportReportDialog'
|
|
18
19
|
import { InternalPage } from '@renderer/pages/InternalPage'
|
|
19
20
|
import { Button } from '@renderer/components/ui/button'
|
|
20
21
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
|
|
@@ -62,6 +63,7 @@ export function DashboardPage({
|
|
|
62
63
|
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
|
|
63
64
|
const [bulkDeleting, setBulkDeleting] = useState(false)
|
|
64
65
|
const [exporting, setExporting] = useState(false)
|
|
66
|
+
const [exportOpen, setExportOpen] = useState(false)
|
|
65
67
|
|
|
66
68
|
const hasLoadedList = useRef(false)
|
|
67
69
|
const searchInputRef = useRef<HTMLInputElement | null>(null)
|
|
@@ -302,17 +304,18 @@ export function DashboardPage({
|
|
|
302
304
|
[items]
|
|
303
305
|
)
|
|
304
306
|
|
|
305
|
-
const handleExportSelected = async () => {
|
|
307
|
+
const handleExportSelected = async (outputDir?: string) => {
|
|
306
308
|
const ids = [...selectedIds]
|
|
307
309
|
if (ids.length === 0) return
|
|
308
310
|
const api = getApi()
|
|
309
311
|
if (!api) {
|
|
310
312
|
toast.error(apiUnavailableMessage(t))
|
|
313
|
+
setExportOpen(false)
|
|
311
314
|
return
|
|
312
315
|
}
|
|
313
316
|
setExporting(true)
|
|
314
317
|
try {
|
|
315
|
-
const result = await api.exportVulnerabilities(ids)
|
|
318
|
+
const result = await api.exportVulnerabilities(ids, outputDir)
|
|
316
319
|
const savedPath = result.savedPaths?.[0]
|
|
317
320
|
if (!result.cancelled) {
|
|
318
321
|
const message =
|
|
@@ -328,6 +331,7 @@ export function DashboardPage({
|
|
|
328
331
|
toast.error(t('vuln.toast.exportFailed', { error: toErrorMessage(cause, t) }))
|
|
329
332
|
} finally {
|
|
330
333
|
setExporting(false)
|
|
334
|
+
setExportOpen(false)
|
|
331
335
|
}
|
|
332
336
|
}
|
|
333
337
|
|
|
@@ -443,7 +447,7 @@ export function DashboardPage({
|
|
|
443
447
|
<Button
|
|
444
448
|
variant="outline"
|
|
445
449
|
size="sm"
|
|
446
|
-
onClick={() =>
|
|
450
|
+
onClick={() => setExportOpen(true)}
|
|
447
451
|
disabled={exporting}
|
|
448
452
|
className="gap-1.5"
|
|
449
453
|
>
|
|
@@ -526,6 +530,14 @@ export function DashboardPage({
|
|
|
526
530
|
onOpenChange={setBulkConfirmOpen}
|
|
527
531
|
onConfirm={() => void handleBulkDelete()}
|
|
528
532
|
/>
|
|
533
|
+
|
|
534
|
+
<ExportReportDialog
|
|
535
|
+
open={exportOpen}
|
|
536
|
+
count={selectedCount}
|
|
537
|
+
pending={exporting}
|
|
538
|
+
onOpenChange={setExportOpen}
|
|
539
|
+
onConfirm={(outputDir) => void handleExportSelected(outputDir)}
|
|
540
|
+
/>
|
|
529
541
|
</>
|
|
530
542
|
)
|
|
531
543
|
}
|
|
@@ -158,8 +158,12 @@ export interface CortexApi extends PocApi {
|
|
|
158
158
|
updateVulnerabilityStatus(input: UpdateVulnStatusRequest): Promise<Vulnerability>
|
|
159
159
|
deleteVulnerability(id: string): Promise<DeleteVulnResult>
|
|
160
160
|
deleteVulnerabilities(ids: string[]): Promise<DeleteVulnsResult>
|
|
161
|
-
/**
|
|
162
|
-
|
|
161
|
+
/**
|
|
162
|
+
* Export one or more vulnerability reports as Markdown. `outputDir` is an
|
|
163
|
+
* absolute directory chosen by the user; omitted/empty means the Host default
|
|
164
|
+
* (`~/.cortex/exports/<timestamp>/`).
|
|
165
|
+
*/
|
|
166
|
+
exportVulnerabilities(ids: string[], outputDir?: string): Promise<ExportReportsResult>
|
|
163
167
|
listAssets(filters?: AssetFilters): Promise<AssetListItem[]>
|
|
164
168
|
getAssetDetail(hostPort: string): Promise<AssetContext | null>
|
|
165
169
|
getAssetRelatedVulnerabilities(hostPort: string): Promise<VulnerabilityListItem[]>
|
package/src/host/cortex/vulns.js
CHANGED
|
@@ -36,7 +36,7 @@ import * as dto from '../dto.js'
|
|
|
36
36
|
import { buildVulnReport, reportFilename } from '../reports.js'
|
|
37
37
|
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
38
38
|
import { homedir } from 'node:os'
|
|
39
|
-
import { join } from 'node:path'
|
|
39
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
40
40
|
|
|
41
41
|
const SEVERITIES = ['Critical', 'High', 'Medium', 'Low', 'Info']
|
|
42
42
|
const STATUSES = ['Confirmed', 'Inconclusive', 'Retest_Passed']
|
|
@@ -499,6 +499,26 @@ export async function handleDeleteMany({ req, res, holder, bumpRev }) {
|
|
|
499
499
|
export async function handleExport({ req, res, holder, bumpRev }) {
|
|
500
500
|
const body = await readJsonBody(req)
|
|
501
501
|
const ids = requireIdList(body, 'ids', MAX_EXPORT_IDS)
|
|
502
|
+
/** Optional user-selected output directory (absolute path, `~` allowed). */
|
|
503
|
+
let requestedDir
|
|
504
|
+
if (body.outputDir !== undefined) {
|
|
505
|
+
if (typeof body.outputDir !== 'string') {
|
|
506
|
+
throw badRequest('outputDir must be a string', { field: 'outputDir' })
|
|
507
|
+
}
|
|
508
|
+
const raw = body.outputDir.trim()
|
|
509
|
+
if (raw.length > 1000) {
|
|
510
|
+
throw badRequest('outputDir is too long', { field: 'outputDir', max: 1000 })
|
|
511
|
+
}
|
|
512
|
+
if (raw.length > 0) {
|
|
513
|
+
const expanded = raw.startsWith('~')
|
|
514
|
+
? join(homedir(), raw.replace(/^~[/\\]?/, ''))
|
|
515
|
+
: raw
|
|
516
|
+
if (!isAbsolute(expanded)) {
|
|
517
|
+
throw badRequest('outputDir must be an absolute path', { field: 'outputDir' })
|
|
518
|
+
}
|
|
519
|
+
requestedDir = resolve(expanded)
|
|
520
|
+
}
|
|
521
|
+
}
|
|
502
522
|
if (ids.length === 0) {
|
|
503
523
|
sendJson(res, 200, { cancelled: true, files: [], savedPaths: [] })
|
|
504
524
|
return
|
|
@@ -541,18 +561,25 @@ export async function handleExport({ req, res, holder, bumpRev }) {
|
|
|
541
561
|
.replace(/[-:]/g, '')
|
|
542
562
|
.replace(/\..+$/, '')
|
|
543
563
|
.replace('T', '-')
|
|
544
|
-
const exportDir = join(exportRoot, stamp)
|
|
564
|
+
const exportDir = requestedDir ?? join(exportRoot, stamp)
|
|
545
565
|
mkdirSync(exportDir, { recursive: true })
|
|
546
566
|
for (let index = 0; index < files.length; index += 1) {
|
|
547
567
|
const file = files[index]
|
|
548
568
|
const safeName = String(file.filename).replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_').slice(0, 120) || file.id
|
|
549
|
-
const
|
|
569
|
+
const baseName = safeName.endsWith('.md') ? safeName : `${safeName}.md`
|
|
570
|
+
const target = join(exportDir, files.length > 1 ? `${String(index + 1).padStart(2, '0')}-${baseName}` : baseName)
|
|
550
571
|
writeFileSync(target, file.content, 'utf8')
|
|
551
572
|
savedPaths.push(target)
|
|
552
573
|
}
|
|
553
|
-
} catch {
|
|
574
|
+
} catch (error) {
|
|
554
575
|
// Filesystem failure must not break the export response; the inline
|
|
555
|
-
// files still let the browser download attempt proceed.
|
|
576
|
+
// files still let the browser download attempt proceed. A selected
|
|
577
|
+
// directory failure is reported so the user can correct the path.
|
|
578
|
+
if (requestedDir !== undefined) {
|
|
579
|
+
throw badRequest(`cannot write to outputDir: ${error instanceof Error ? error.message : String(error)}`, {
|
|
580
|
+
field: 'outputDir',
|
|
581
|
+
})
|
|
582
|
+
}
|
|
556
583
|
}
|
|
557
584
|
}
|
|
558
585
|
// Export is a read-only projection: no revision bump.
|