@karmaniverous/jeeves-server 3.0.0-0
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.
- package/.env.local +13 -0
- package/.env.local.template +13 -0
- package/.tsbuildinfo +1 -0
- package/CHANGELOG.md +450 -0
- package/about.md +82 -0
- package/client/README.md +73 -0
- package/client/eslint.config.js +23 -0
- package/client/index.html +14 -0
- package/client/package-lock.json +5181 -0
- package/client/package.json +60 -0
- package/client/public/vite.svg +1 -0
- package/client/src/App.tsx +22 -0
- package/client/src/components/AccountMenu.tsx +167 -0
- package/client/src/components/ActionDropdown.tsx +120 -0
- package/client/src/components/CodeEditor.tsx +143 -0
- package/client/src/components/CodeViewer.tsx +113 -0
- package/client/src/components/ConfirmDialog.tsx +32 -0
- package/client/src/components/DirectoryRow.tsx +62 -0
- package/client/src/components/DirectoryTable.tsx +42 -0
- package/client/src/components/DownloadDropdown.tsx +116 -0
- package/client/src/components/DriveList.tsx +54 -0
- package/client/src/components/EmbeddedDiagramPanzoom.ts +28 -0
- package/client/src/components/FileContentView.tsx +155 -0
- package/client/src/components/InlineSvgPanzoom.ts +60 -0
- package/client/src/components/LazyDiagram.ts +93 -0
- package/client/src/components/LinkDropdown.tsx +134 -0
- package/client/src/components/MarkdownView.tsx +115 -0
- package/client/src/components/MermaidViewer.tsx +21 -0
- package/client/src/components/PlantUmlViewer.tsx +21 -0
- package/client/src/components/SearchModal.tsx +424 -0
- package/client/src/components/SvgViewer.tsx +107 -0
- package/client/src/components/TabBar.tsx +96 -0
- package/client/src/components/layout/Header.tsx +270 -0
- package/client/src/components/panzoom.ts +203 -0
- package/client/src/components/renderableUtils.ts +15 -0
- package/client/src/components/runner/JobTable.tsx +153 -0
- package/client/src/components/runner/RunHistory.tsx +140 -0
- package/client/src/components/runner/StatsBar.tsx +43 -0
- package/client/src/components/runner/StatusPill.tsx +27 -0
- package/client/src/components/runner/jobTableUtils.ts +65 -0
- package/client/src/components/scrollUtils.ts +39 -0
- package/client/src/components/ui/alert-dialog.tsx +107 -0
- package/client/src/components/ui/button.tsx +40 -0
- package/client/src/components/ui/dropdown-menu.tsx +79 -0
- package/client/src/components/ui/input.tsx +26 -0
- package/client/src/components/useActionState.ts +43 -0
- package/client/src/hooks/useFileBrowser.ts +102 -0
- package/client/src/hooks/useFileData.ts +78 -0
- package/client/src/hooks/useScrollAnchor.ts +70 -0
- package/client/src/hooks/useShareSettings.ts +22 -0
- package/client/src/hooks/useTopBar.ts +27 -0
- package/client/src/index.css +281 -0
- package/client/src/lib/AuthContext.ts +27 -0
- package/client/src/lib/api.ts +239 -0
- package/client/src/lib/auth.tsx +50 -0
- package/client/src/lib/codeBlockCm6.ts +129 -0
- package/client/src/lib/codeBlockCopy.ts +43 -0
- package/client/src/lib/codemirror.ts +77 -0
- package/client/src/lib/runner-api.ts +172 -0
- package/client/src/lib/svg.ts +50 -0
- package/client/src/lib/theme.ts +34 -0
- package/client/src/lib/utils.ts +6 -0
- package/client/src/main.tsx +11 -0
- package/client/src/pages/FileBrowser.tsx +135 -0
- package/client/src/pages/Home.tsx +46 -0
- package/client/src/pages/Runner.tsx +151 -0
- package/client/src/pages/RunnerJob.tsx +170 -0
- package/client/tsconfig.app.json +32 -0
- package/client/tsconfig.json +7 -0
- package/client/tsconfig.node.json +26 -0
- package/client/vite.config.ts +35 -0
- package/content/privacy.md +61 -0
- package/content/terms.md +41 -0
- package/dist/client/assets/CodeEditor-0XHVI8Nu.js +1 -0
- package/dist/client/assets/CodeViewer-CykMVsfX.js +1 -0
- package/dist/client/assets/index--MBieNJA.js +1 -0
- package/dist/client/assets/index-BENeXQI_.js +1 -0
- package/dist/client/assets/index-BbBpoOxz.js +1 -0
- package/dist/client/assets/index-BdV9g5AM.js +6 -0
- package/dist/client/assets/index-BjAilRri.js +2 -0
- package/dist/client/assets/index-BqbhWo2I.js +3 -0
- package/dist/client/assets/index-CVbycZ0H.js +1 -0
- package/dist/client/assets/index-Cs5oz2oJ.js +5 -0
- package/dist/client/assets/index-D8KZVveX.js +1 -0
- package/dist/client/assets/index-DC4HMHxY.js +13 -0
- package/dist/client/assets/index-DbMebkkd.css +1 -0
- package/dist/client/assets/index-DcY2RXqX.js +1 -0
- package/dist/client/assets/index-Duy-tZYV.js +1 -0
- package/dist/client/assets/index-Dw7rDFmE.js +7 -0
- package/dist/client/assets/index-FlCUvrjv.js +2 -0
- package/dist/client/assets/index-K6OVmfhg.js +1 -0
- package/dist/client/assets/index-LjwgzZ7F.js +62 -0
- package/dist/client/assets/index-MLwyFRN0.js +1 -0
- package/dist/client/assets/index-OpqBpSjn.js +1 -0
- package/dist/client/assets/index-SsHei0HE.js +1 -0
- package/dist/client/assets/index-uQa2yckk.js +1 -0
- package/dist/client/assets/index-udkXoIER.js +1 -0
- package/dist/client/index.html +15 -0
- package/dist/client/vite.svg +1 -0
- package/dist/src/auth/google.js +57 -0
- package/dist/src/auth/keys.js +185 -0
- package/dist/src/auth/resolve.js +102 -0
- package/dist/src/auth/session.js +57 -0
- package/dist/src/cli/commands/config.js +100 -0
- package/dist/src/cli/commands/config.test.js +84 -0
- package/dist/src/cli/commands/service.js +93 -0
- package/dist/src/cli/commands/start.js +24 -0
- package/dist/src/cli/index.js +20 -0
- package/dist/src/config/index.js +90 -0
- package/dist/src/config/loadConfig.test.js +127 -0
- package/dist/src/config/resolve.js +134 -0
- package/dist/src/config/resolve.test.js +148 -0
- package/dist/src/config/schema.js +159 -0
- package/dist/src/config/substituteEnvVars.js +45 -0
- package/dist/src/config/substituteEnvVars.test.js +51 -0
- package/dist/src/config/types.js +5 -0
- package/dist/src/routes/api/auth-status.js +56 -0
- package/dist/src/routes/api/diagrams.js +35 -0
- package/dist/src/routes/api/directory.js +93 -0
- package/dist/src/routes/api/drives.js +15 -0
- package/dist/src/routes/api/export.js +218 -0
- package/dist/src/routes/api/fileContent.js +286 -0
- package/dist/src/routes/api/index.js +33 -0
- package/dist/src/routes/api/linkInfo.js +71 -0
- package/dist/src/routes/api/linkInfo.test.js +104 -0
- package/dist/src/routes/api/middleware.js +117 -0
- package/dist/src/routes/api/raw.js +38 -0
- package/dist/src/routes/api/runner.js +59 -0
- package/dist/src/routes/api/search.js +236 -0
- package/dist/src/routes/api/sharing.js +203 -0
- package/dist/src/routes/api/status.js +68 -0
- package/dist/src/routes/api/status.test.js +62 -0
- package/dist/src/routes/auth.js +99 -0
- package/dist/src/routes/event.js +77 -0
- package/dist/src/routes/event.test.js +206 -0
- package/dist/src/routes/health.js +10 -0
- package/dist/src/routes/keys.js +129 -0
- package/dist/src/routes/path/index.js +17 -0
- package/dist/src/routes/static.js +30 -0
- package/dist/src/server.js +90 -0
- package/dist/src/services/deepShareLinks.js +163 -0
- package/dist/src/services/diagramCache.js +104 -0
- package/dist/src/services/embeddedDiagrams.js +136 -0
- package/dist/src/services/eventLog.js +55 -0
- package/dist/src/services/eventLog.test.js +113 -0
- package/dist/src/services/eventQueue.js +154 -0
- package/dist/src/services/eventQueue.test.js +104 -0
- package/dist/src/services/export.js +220 -0
- package/dist/src/services/exportCache.js +196 -0
- package/dist/src/services/markdown.js +147 -0
- package/dist/src/services/mermaid.js +97 -0
- package/dist/src/services/plantuml.js +145 -0
- package/dist/src/services/puppeteer.js +156 -0
- package/dist/src/util/breadcrumbs.js +22 -0
- package/dist/src/util/crypto.js +56 -0
- package/dist/src/util/crypto.test.js +99 -0
- package/dist/src/util/fileDetection.js +66 -0
- package/dist/src/util/fileDetection.test.js +89 -0
- package/dist/src/util/formatters.js +43 -0
- package/dist/src/util/formatters.test.js +83 -0
- package/dist/src/util/packageVersion.js +25 -0
- package/dist/src/util/platform.js +148 -0
- package/dist/src/util/state.js +46 -0
- package/dist/vitest.config.js +12 -0
- package/favicon.svg +3 -0
- package/guides/access-decision-flow.mmd +24 -0
- package/guides/access-decision-flow.svg +1 -0
- package/guides/api-integration.md +236 -0
- package/guides/deployment.md +287 -0
- package/guides/event-gateway.md +204 -0
- package/guides/event-gateway.mmd +17 -0
- package/guides/event-gateway.svg +1 -0
- package/guides/exports.md +239 -0
- package/guides/setup.md +313 -0
- package/guides/sharing.md +204 -0
- package/jeeves-server.config.template.json +25 -0
- package/package.json +124 -0
- package/scripts/download-plantuml.js +70 -0
- package/src/auth/google.ts +93 -0
- package/src/auth/keys.ts +252 -0
- package/src/auth/resolve.ts +157 -0
- package/src/auth/session.ts +77 -0
- package/src/cli/commands/config.test.ts +107 -0
- package/src/cli/commands/config.ts +113 -0
- package/src/cli/commands/service.ts +129 -0
- package/src/cli/commands/start.ts +27 -0
- package/src/cli/index.ts +25 -0
- package/src/config/index.ts +113 -0
- package/src/config/loadConfig.test.ts +155 -0
- package/src/config/resolve.test.ts +192 -0
- package/src/config/resolve.ts +173 -0
- package/src/config/schema.ts +179 -0
- package/src/config/substituteEnvVars.test.ts +64 -0
- package/src/config/substituteEnvVars.ts +52 -0
- package/src/config/types.ts +129 -0
- package/src/routes/api/auth-status.ts +85 -0
- package/src/routes/api/diagrams.ts +53 -0
- package/src/routes/api/directory.ts +123 -0
- package/src/routes/api/drives.ts +23 -0
- package/src/routes/api/export.ts +314 -0
- package/src/routes/api/fileContent.ts +414 -0
- package/src/routes/api/index.ts +37 -0
- package/src/routes/api/linkInfo.test.ts +132 -0
- package/src/routes/api/linkInfo.ts +83 -0
- package/src/routes/api/middleware.ts +156 -0
- package/src/routes/api/raw.ts +54 -0
- package/src/routes/api/runner.ts +107 -0
- package/src/routes/api/search.ts +321 -0
- package/src/routes/api/sharing.ts +259 -0
- package/src/routes/api/status.test.ts +72 -0
- package/src/routes/api/status.ts +82 -0
- package/src/routes/auth.ts +143 -0
- package/src/routes/event.test.ts +248 -0
- package/src/routes/event.ts +109 -0
- package/src/routes/health.ts +13 -0
- package/src/routes/keys.ts +192 -0
- package/src/routes/path/index.ts +24 -0
- package/src/routes/static.ts +54 -0
- package/src/server.ts +104 -0
- package/src/services/deepShareLinks.ts +203 -0
- package/src/services/diagramCache.ts +128 -0
- package/src/services/embeddedDiagrams.ts +168 -0
- package/src/services/eventLog.test.ts +144 -0
- package/src/services/eventLog.ts +68 -0
- package/src/services/eventQueue.test.ts +127 -0
- package/src/services/eventQueue.ts +196 -0
- package/src/services/export.ts +267 -0
- package/src/services/exportCache.ts +216 -0
- package/src/services/markdown.ts +189 -0
- package/src/services/mermaid.ts +113 -0
- package/src/services/plantuml.ts +172 -0
- package/src/services/puppeteer.ts +188 -0
- package/src/types/fastify.d.ts +13 -0
- package/src/types/jsonmap.d.ts +10 -0
- package/src/types/plantuml-encoder.d.ts +4 -0
- package/src/util/breadcrumbs.ts +33 -0
- package/src/util/crypto.test.ts +132 -0
- package/src/util/crypto.ts +79 -0
- package/src/util/fileDetection.test.ts +115 -0
- package/src/util/fileDetection.ts +70 -0
- package/src/util/formatters.test.ts +105 -0
- package/src/util/formatters.ts +44 -0
- package/src/util/packageVersion.ts +30 -0
- package/src/util/platform.ts +178 -0
- package/src/util/state.ts +55 -0
- package/test-docs/diagram-retry-test.md +18 -0
- package/test-docs/embedded-diagrams.md +52 -0
- package/test-docs/lazy-diagrams-test.md +333 -0
- package/test-docs/page-a.md +7 -0
- package/test-docs/page-b.md +7 -0
- package/test-docs/page-c.md +7 -0
- package/test-docs/sub/page-d.md +7 -0
- package/test-docs/test-diagram.puml +13 -0
- package/test-docs/validate-deep-share.js +318 -0
- package/tsconfig.json +37 -0
- package/tsdoc.json +13 -0
- package/vendor/.plantuml-version +1 -0
- package/vendor/plantuml.jar +0 -0
- package/vitest.config.js +12 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagram rendering API route.
|
|
3
|
+
*
|
|
4
|
+
* Handles: /api/diagram/:type/:hash
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
getDiagramSource,
|
|
11
|
+
renderDiagramToSvg,
|
|
12
|
+
} from '../../services/embeddedDiagrams.js';
|
|
13
|
+
|
|
14
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
15
|
+
export const diagramsRoutes: FastifyPluginAsync = async (fastify) => {
|
|
16
|
+
fastify.get<{ Params: { type: string; hash: string } }>(
|
|
17
|
+
'/api/diagram/:type/:hash',
|
|
18
|
+
async (request, reply) => {
|
|
19
|
+
const { type, hash: hashWithExt } = request.params;
|
|
20
|
+
const hash = hashWithExt.replace(/\.svg$/, '');
|
|
21
|
+
|
|
22
|
+
if (!['mermaid', 'plantuml'].includes(type)) {
|
|
23
|
+
return reply.code(400).send({ error: 'Invalid diagram type' });
|
|
24
|
+
}
|
|
25
|
+
if (!/^[a-f0-9]{64}$/.test(hash)) {
|
|
26
|
+
return reply.code(400).send({ error: 'Invalid hash' });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const entry = getDiagramSource(hash);
|
|
30
|
+
if (!entry) {
|
|
31
|
+
return reply
|
|
32
|
+
.code(404)
|
|
33
|
+
.send({ error: 'Diagram source not found (may have expired)' });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const svg = await renderDiagramToSvg(
|
|
37
|
+
type,
|
|
38
|
+
entry.source,
|
|
39
|
+
entry.contextDir,
|
|
40
|
+
);
|
|
41
|
+
if (!svg) {
|
|
42
|
+
return reply
|
|
43
|
+
.code(500)
|
|
44
|
+
.send({ error: 'Renderer returned empty result' });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return reply
|
|
48
|
+
.header('Content-Type', 'image/svg+xml')
|
|
49
|
+
.header('Cache-Control', 'public, max-age=86400, immutable')
|
|
50
|
+
.send(svg);
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directory listing API route.
|
|
3
|
+
*
|
|
4
|
+
* Handles: GET /api/path/*
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
_directoryVisibleUnderScopes,
|
|
14
|
+
_pathMatchesPatterns,
|
|
15
|
+
_pathMatchesScopes,
|
|
16
|
+
} from '../../auth/keys.js';
|
|
17
|
+
import { getConfig } from '../../config/index.js';
|
|
18
|
+
import { filterBreadcrumbsForOutsider } from '../../util/breadcrumbs.js';
|
|
19
|
+
import {
|
|
20
|
+
breadcrumbParts,
|
|
21
|
+
fsPathToUrl,
|
|
22
|
+
getRoots,
|
|
23
|
+
urlPathToFs,
|
|
24
|
+
} from '../../util/platform.js';
|
|
25
|
+
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
27
|
+
export const directoryRoutes: FastifyPluginAsync = async (fastify) => {
|
|
28
|
+
const roots = getRoots(getConfig().roots);
|
|
29
|
+
|
|
30
|
+
fastify.get<{ Params: { '*': string } }>(
|
|
31
|
+
'/api/path/*',
|
|
32
|
+
async (request, reply) => {
|
|
33
|
+
const reqPath = request.params['*'];
|
|
34
|
+
if (!reqPath) return reply.redirect('/api/drives');
|
|
35
|
+
|
|
36
|
+
const fsPath = urlPathToFs(reqPath, roots);
|
|
37
|
+
if (!fsPath) return reply.code(404).send({ error: 'Invalid path' });
|
|
38
|
+
const resolved = path.resolve(fsPath);
|
|
39
|
+
|
|
40
|
+
if (!fs.existsSync(resolved)) {
|
|
41
|
+
return reply.code(404).send({ error: 'Not found', path: resolved });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const stats = fs.statSync(resolved);
|
|
45
|
+
if (!stats.isDirectory()) {
|
|
46
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
47
|
+
return reply.send({
|
|
48
|
+
type: 'file',
|
|
49
|
+
path: reqPath,
|
|
50
|
+
ext,
|
|
51
|
+
size: stats.size,
|
|
52
|
+
mtime: stats.mtime.toISOString().split('T')[0],
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const isInsider = request.accessMode === 'insider';
|
|
57
|
+
const insiderScopes = request.insiderScopes ?? null;
|
|
58
|
+
|
|
59
|
+
const allEntries = fs.readdirSync(resolved, { withFileTypes: true });
|
|
60
|
+
|
|
61
|
+
const entries = insiderScopes
|
|
62
|
+
? allEntries.filter((entry) => {
|
|
63
|
+
const entryPath = path.join(resolved, entry.name);
|
|
64
|
+
const entryUrlPath = fsPathToUrl(entryPath, roots);
|
|
65
|
+
if (insiderScopes.deny.length > 0) {
|
|
66
|
+
if (_pathMatchesPatterns(entryUrlPath, insiderScopes.deny))
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
if (entry.isDirectory()) {
|
|
70
|
+
return _directoryVisibleUnderScopes(
|
|
71
|
+
entryUrlPath,
|
|
72
|
+
insiderScopes.allow,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return _pathMatchesScopes(entryUrlPath, insiderScopes);
|
|
76
|
+
})
|
|
77
|
+
: allEntries;
|
|
78
|
+
|
|
79
|
+
const sorted = entries.sort((a, b) => {
|
|
80
|
+
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
81
|
+
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
82
|
+
return a.name.localeCompare(b.name);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const result = sorted.map((entry) => {
|
|
86
|
+
const entryPath = path.join(resolved, entry.name);
|
|
87
|
+
let size: number | null = null;
|
|
88
|
+
let mtime: string | null = null;
|
|
89
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
90
|
+
try {
|
|
91
|
+
const entryStats = fs.statSync(entryPath);
|
|
92
|
+
mtime = entryStats.mtime.toISOString().split('T')[0];
|
|
93
|
+
if (!entry.isDirectory()) size = entryStats.size;
|
|
94
|
+
} catch {
|
|
95
|
+
/* ignore */
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
name: entry.name,
|
|
99
|
+
type: entry.isDirectory() ? 'directory' : 'file',
|
|
100
|
+
ext,
|
|
101
|
+
size,
|
|
102
|
+
mtime,
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const breadcrumbs = breadcrumbParts(resolved, roots);
|
|
107
|
+
const matchedPath = request.authMatchedPath ?? null;
|
|
108
|
+
const filteredBreadcrumbs = filterBreadcrumbsForOutsider(
|
|
109
|
+
breadcrumbs,
|
|
110
|
+
isInsider,
|
|
111
|
+
matchedPath,
|
|
112
|
+
true,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
return reply.send({
|
|
116
|
+
path: reqPath,
|
|
117
|
+
entries: result,
|
|
118
|
+
breadcrumbs: filteredBreadcrumbs,
|
|
119
|
+
isInsider,
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drive listing API route.
|
|
3
|
+
*
|
|
4
|
+
* Handles: GET /api/drives
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
|
|
8
|
+
|
|
9
|
+
import { getConfig } from '../../config/index.js';
|
|
10
|
+
import { getRoots } from '../../util/platform.js';
|
|
11
|
+
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
13
|
+
export const drivesRoutes: FastifyPluginAsync = async (fastify) => {
|
|
14
|
+
const roots = getRoots(getConfig().roots);
|
|
15
|
+
|
|
16
|
+
fastify.get(
|
|
17
|
+
'/api/drives',
|
|
18
|
+
async (_request: FastifyRequest, reply: FastifyReply) => {
|
|
19
|
+
const drives = roots.map((r) => ({ letter: r.id, label: r.label }));
|
|
20
|
+
return reply.send(drives);
|
|
21
|
+
},
|
|
22
|
+
);
|
|
23
|
+
};
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export API routes.
|
|
3
|
+
*
|
|
4
|
+
* Handles: /api/export/*, /api/mermaid-export/*, /api/plantuml-export/*
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
import archiver from 'archiver';
|
|
11
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
12
|
+
|
|
13
|
+
import { getConfig } from '../../config/index.js';
|
|
14
|
+
import {
|
|
15
|
+
cacheDiagramBuffer,
|
|
16
|
+
getCachedDiagramBuffer,
|
|
17
|
+
} from '../../services/diagramCache.js';
|
|
18
|
+
import { appendEvent } from '../../services/eventQueue.js';
|
|
19
|
+
import { type ExportFormat, exportPage } from '../../services/export.js';
|
|
20
|
+
import {
|
|
21
|
+
cacheExport,
|
|
22
|
+
clearDiagramCacheForFile,
|
|
23
|
+
clearExportCache,
|
|
24
|
+
clearStandaloneDiagramCache,
|
|
25
|
+
getCachedExport,
|
|
26
|
+
} from '../../services/exportCache.js';
|
|
27
|
+
import { renderMermaidToFile } from '../../services/mermaid.js';
|
|
28
|
+
import {
|
|
29
|
+
getPlantUmlFormats,
|
|
30
|
+
renderPlantUmlToBuffer,
|
|
31
|
+
} from '../../services/plantuml.js';
|
|
32
|
+
import { DIAGRAM_CONTENT_TYPES } from '../../util/fileDetection.js';
|
|
33
|
+
import { getDirSize, getRoots, urlPathToFs } from '../../util/platform.js';
|
|
34
|
+
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
36
|
+
export const exportRoutes: FastifyPluginAsync = async (fastify) => {
|
|
37
|
+
const roots = getRoots(getConfig().roots);
|
|
38
|
+
|
|
39
|
+
// GET /api/export/*
|
|
40
|
+
fastify.get<{ Params: { '*': string }; Querystring: { format?: string } }>(
|
|
41
|
+
'/api/export/*',
|
|
42
|
+
async (request, reply) => {
|
|
43
|
+
const reqPath = request.params['*'];
|
|
44
|
+
if (!reqPath) return reply.code(400).send({ error: 'Path required' });
|
|
45
|
+
|
|
46
|
+
const exportFsPath = urlPathToFs(reqPath, roots);
|
|
47
|
+
if (!exportFsPath) return reply.code(404).send({ error: 'Invalid path' });
|
|
48
|
+
const resolved = path.resolve(exportFsPath);
|
|
49
|
+
|
|
50
|
+
if (!fs.existsSync(resolved))
|
|
51
|
+
return reply.code(404).send({ error: 'Not found', path: resolved });
|
|
52
|
+
|
|
53
|
+
const format = request.query.format ?? 'pdf';
|
|
54
|
+
const stats = fs.statSync(resolved);
|
|
55
|
+
|
|
56
|
+
// ZIP export for directories
|
|
57
|
+
if (stats.isDirectory()) {
|
|
58
|
+
if (format !== 'zip')
|
|
59
|
+
return reply
|
|
60
|
+
.code(400)
|
|
61
|
+
.send({ error: 'Directories only support ZIP export' });
|
|
62
|
+
const isInsider = request.accessMode === 'insider';
|
|
63
|
+
if (!isInsider)
|
|
64
|
+
return reply
|
|
65
|
+
.code(403)
|
|
66
|
+
.send({ error: 'ZIP export requires insider access' });
|
|
67
|
+
|
|
68
|
+
const config = getConfig();
|
|
69
|
+
const totalSize = getDirSize(resolved);
|
|
70
|
+
const maxSizeBytes = config.maxZipSizeMb * 1024 * 1024;
|
|
71
|
+
if (totalSize > maxSizeBytes) {
|
|
72
|
+
return reply.code(413).send({
|
|
73
|
+
error: `Directory too large for ZIP export (${String(Math.round(totalSize / 1024 / 1024))}MB, max ${String(config.maxZipSizeMb)}MB)`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const dirName = path.basename(resolved);
|
|
78
|
+
const archive = archiver('zip', { zlib: { level: 6 } });
|
|
79
|
+
reply.header('Content-Type', 'application/zip');
|
|
80
|
+
reply.header(
|
|
81
|
+
'Content-Disposition',
|
|
82
|
+
`attachment; filename="${dirName}.zip"`,
|
|
83
|
+
);
|
|
84
|
+
reply.send(archive);
|
|
85
|
+
archive.directory(resolved, dirName);
|
|
86
|
+
void archive.finalize();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// PDF/DOCX export for files
|
|
91
|
+
if (format !== 'pdf' && format !== 'docx') {
|
|
92
|
+
return reply
|
|
93
|
+
.code(400)
|
|
94
|
+
.send({ error: 'Files support pdf or docx export' });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
98
|
+
if (ext !== '.md')
|
|
99
|
+
return reply
|
|
100
|
+
.code(400)
|
|
101
|
+
.send({ error: 'Only markdown files support PDF/DOCX export' });
|
|
102
|
+
|
|
103
|
+
const config = getConfig();
|
|
104
|
+
const internalKey = config.internalInsiderKey;
|
|
105
|
+
const { port } = config;
|
|
106
|
+
const exportKey = (request.query as { key?: string }).key || internalKey;
|
|
107
|
+
if (!exportKey)
|
|
108
|
+
return reply
|
|
109
|
+
.code(500)
|
|
110
|
+
.send({ error: 'Export unavailable — no internal key configured' });
|
|
111
|
+
|
|
112
|
+
const exportUrl = `http://localhost:${String(port)}/browse/${reqPath}?key=${exportKey}&render_diagrams=1&plain_code=1`;
|
|
113
|
+
const fileName = path.basename(resolved);
|
|
114
|
+
const baseName = fileName.replace(/\.md$/i, '');
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const contentType =
|
|
118
|
+
format === 'pdf'
|
|
119
|
+
? 'application/pdf'
|
|
120
|
+
: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
|
121
|
+
const fileExt = format === 'pdf' ? 'pdf' : 'docx';
|
|
122
|
+
|
|
123
|
+
// Check export cache
|
|
124
|
+
const cached = getCachedExport(resolved, format);
|
|
125
|
+
if (cached) {
|
|
126
|
+
return await reply
|
|
127
|
+
.header('Content-Type', contentType)
|
|
128
|
+
.header(
|
|
129
|
+
'Content-Disposition',
|
|
130
|
+
`attachment; filename="${baseName}.${fileExt}"`,
|
|
131
|
+
)
|
|
132
|
+
.header('Content-Length', cached.length)
|
|
133
|
+
.send(cached);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const buffer = await exportPage({
|
|
137
|
+
url: exportUrl,
|
|
138
|
+
fileName,
|
|
139
|
+
format: format as ExportFormat,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Cache the result
|
|
143
|
+
cacheExport(resolved, format, buffer);
|
|
144
|
+
|
|
145
|
+
return await reply
|
|
146
|
+
.header('Content-Type', contentType)
|
|
147
|
+
.header(
|
|
148
|
+
'Content-Disposition',
|
|
149
|
+
`attachment; filename="${baseName}.${fileExt}"`,
|
|
150
|
+
)
|
|
151
|
+
.header('Content-Length', buffer.length)
|
|
152
|
+
.send(buffer);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
appendEvent({ kind: `${format}_export_error`, error: String(err) });
|
|
155
|
+
return reply.code(500).send({
|
|
156
|
+
error: `${format.toUpperCase()} export failed`,
|
|
157
|
+
details: String(err),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// DELETE /api/export-cache/* — clear all caches for a file (insider-only)
|
|
164
|
+
fastify.delete<{ Params: { '*': string } }>(
|
|
165
|
+
'/api/export-cache/*',
|
|
166
|
+
async (request, reply) => {
|
|
167
|
+
if (request.accessMode !== 'insider') {
|
|
168
|
+
return reply.code(403).send({ error: 'Insider access required' });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const reqPath = request.params['*'];
|
|
172
|
+
if (!reqPath) return reply.code(400).send({ error: 'Path required' });
|
|
173
|
+
|
|
174
|
+
const fsPath = urlPathToFs(reqPath, roots);
|
|
175
|
+
if (!fsPath) return reply.code(404).send({ error: 'Invalid path' });
|
|
176
|
+
const resolved = path.resolve(fsPath);
|
|
177
|
+
|
|
178
|
+
const { getDiagramCacheDir } =
|
|
179
|
+
await import('../../services/diagramCache.js');
|
|
180
|
+
const diagCacheDir = getDiagramCacheDir();
|
|
181
|
+
const exportCount = clearExportCache(resolved);
|
|
182
|
+
const embeddedCount = clearDiagramCacheForFile(resolved, diagCacheDir);
|
|
183
|
+
const standaloneCount = clearStandaloneDiagramCache(
|
|
184
|
+
resolved,
|
|
185
|
+
diagCacheDir,
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
return reply.send({
|
|
189
|
+
cleared: {
|
|
190
|
+
exports: exportCount,
|
|
191
|
+
diagrams: embeddedCount + standaloneCount,
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// GET /api/mermaid-export/*
|
|
198
|
+
fastify.get<{ Params: { '*': string }; Querystring: { format?: string } }>(
|
|
199
|
+
'/api/mermaid-export/*',
|
|
200
|
+
async (request, reply) => {
|
|
201
|
+
const reqPath = request.params['*'];
|
|
202
|
+
if (!reqPath) return reply.code(400).send({ error: 'Path required' });
|
|
203
|
+
|
|
204
|
+
const mmdFsPath = urlPathToFs(reqPath, roots);
|
|
205
|
+
if (!mmdFsPath) return reply.code(404).send({ error: 'Invalid path' });
|
|
206
|
+
const resolved = path.resolve(mmdFsPath);
|
|
207
|
+
|
|
208
|
+
if (
|
|
209
|
+
!fs.existsSync(resolved) ||
|
|
210
|
+
!resolved.toLowerCase().endsWith('.mmd')
|
|
211
|
+
) {
|
|
212
|
+
return reply.code(404).send({ error: 'Mermaid file not found' });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const mermaidFormats = ['svg', 'png', 'pdf'];
|
|
216
|
+
const format = mermaidFormats.includes(request.query.format ?? '')
|
|
217
|
+
? request.query.format!
|
|
218
|
+
: 'svg';
|
|
219
|
+
const source = fs.readFileSync(resolved, 'utf8');
|
|
220
|
+
|
|
221
|
+
// Check cache
|
|
222
|
+
const cachedBuffer = getCachedDiagramBuffer('mermaid', source, format);
|
|
223
|
+
if (cachedBuffer) {
|
|
224
|
+
const downloadName = `${path.basename(resolved, '.mmd')}.${format}`;
|
|
225
|
+
return reply
|
|
226
|
+
.header(
|
|
227
|
+
'Content-Type',
|
|
228
|
+
DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream',
|
|
229
|
+
)
|
|
230
|
+
.header(
|
|
231
|
+
'Content-Disposition',
|
|
232
|
+
`attachment; filename="${downloadName}"`,
|
|
233
|
+
)
|
|
234
|
+
.send(cachedBuffer);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const outFile = await renderMermaidToFile(resolved, format);
|
|
238
|
+
if (!outFile)
|
|
239
|
+
return reply.code(500).send({ error: 'Mermaid render failed' });
|
|
240
|
+
|
|
241
|
+
const content = fs.readFileSync(outFile);
|
|
242
|
+
cacheDiagramBuffer('mermaid', source, content, format);
|
|
243
|
+
const downloadName = path.basename(outFile);
|
|
244
|
+
|
|
245
|
+
return reply
|
|
246
|
+
.header(
|
|
247
|
+
'Content-Type',
|
|
248
|
+
DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream',
|
|
249
|
+
)
|
|
250
|
+
.header('Content-Disposition', `attachment; filename="${downloadName}"`)
|
|
251
|
+
.send(content);
|
|
252
|
+
},
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// GET /api/plantuml-export/*
|
|
256
|
+
fastify.get<{ Params: { '*': string }; Querystring: { format?: string } }>(
|
|
257
|
+
'/api/plantuml-export/*',
|
|
258
|
+
async (request, reply) => {
|
|
259
|
+
const reqPath = request.params['*'];
|
|
260
|
+
if (!reqPath) return reply.code(400).send({ error: 'Path required' });
|
|
261
|
+
|
|
262
|
+
const pumlFsPath = urlPathToFs(reqPath, roots);
|
|
263
|
+
if (!pumlFsPath) return reply.code(404).send({ error: 'Invalid path' });
|
|
264
|
+
const resolved = path.resolve(pumlFsPath);
|
|
265
|
+
|
|
266
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
267
|
+
if (
|
|
268
|
+
!fs.existsSync(resolved) ||
|
|
269
|
+
!['.puml', '.plantuml', '.pu'].includes(ext)
|
|
270
|
+
) {
|
|
271
|
+
return reply.code(404).send({ error: 'PlantUML file not found' });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const supported = getPlantUmlFormats();
|
|
275
|
+
const format = supported.includes(request.query.format ?? '')
|
|
276
|
+
? request.query.format!
|
|
277
|
+
: 'svg';
|
|
278
|
+
const source = fs.readFileSync(resolved, 'utf8');
|
|
279
|
+
|
|
280
|
+
const cachedBuffer = getCachedDiagramBuffer('plantuml', source, format);
|
|
281
|
+
if (cachedBuffer) {
|
|
282
|
+
const baseName = path.basename(resolved, ext);
|
|
283
|
+
return reply
|
|
284
|
+
.header(
|
|
285
|
+
'Content-Type',
|
|
286
|
+
DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream',
|
|
287
|
+
)
|
|
288
|
+
.header(
|
|
289
|
+
'Content-Disposition',
|
|
290
|
+
`attachment; filename="${baseName}.${format}"`,
|
|
291
|
+
)
|
|
292
|
+
.send(cachedBuffer);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const buffer = await renderPlantUmlToBuffer(resolved, format);
|
|
296
|
+
if (!buffer)
|
|
297
|
+
return reply.code(500).send({ error: 'PlantUML render failed' });
|
|
298
|
+
|
|
299
|
+
cacheDiagramBuffer('plantuml', source, buffer, format);
|
|
300
|
+
const baseName = path.basename(resolved, ext);
|
|
301
|
+
|
|
302
|
+
return reply
|
|
303
|
+
.header(
|
|
304
|
+
'Content-Type',
|
|
305
|
+
DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream',
|
|
306
|
+
)
|
|
307
|
+
.header(
|
|
308
|
+
'Content-Disposition',
|
|
309
|
+
`attachment; filename="${baseName}.${format}"`,
|
|
310
|
+
)
|
|
311
|
+
.send(buffer);
|
|
312
|
+
},
|
|
313
|
+
);
|
|
314
|
+
};
|