@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,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File content API routes.
|
|
3
|
+
*
|
|
4
|
+
* Handles: GET /api/file/*, PUT /api/file/*
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
|
|
11
|
+
|
|
12
|
+
import { getConfig } from '../../config/index.js';
|
|
13
|
+
import { rewriteLinksForDeepShare } from '../../services/deepShareLinks.js';
|
|
14
|
+
import { getOrRenderDiagram } from '../../services/diagramCache.js';
|
|
15
|
+
import {
|
|
16
|
+
renderEmbeddedDiagrams,
|
|
17
|
+
setDiagramContext,
|
|
18
|
+
} from '../../services/embeddedDiagrams.js';
|
|
19
|
+
import { registerDiagramHashes } from '../../services/exportCache.js';
|
|
20
|
+
import { parseMarkdown } from '../../services/markdown.js';
|
|
21
|
+
import { renderMermaidSvg } from '../../services/mermaid.js';
|
|
22
|
+
import { renderPlantUmlSvg } from '../../services/plantuml.js';
|
|
23
|
+
import { filterBreadcrumbsForOutsider } from '../../util/breadcrumbs.js';
|
|
24
|
+
import { looksLikeText } from '../../util/fileDetection.js';
|
|
25
|
+
import { breadcrumbParts, getRoots, urlPathToFs } from '../../util/platform.js';
|
|
26
|
+
|
|
27
|
+
/** Image extensions recognized for type detection. */
|
|
28
|
+
const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico'];
|
|
29
|
+
|
|
30
|
+
/** PlantUML file extensions. */
|
|
31
|
+
const PLANTUML_EXTS = ['.puml', '.plantuml', '.pu'];
|
|
32
|
+
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
34
|
+
export const fileContentRoutes: FastifyPluginAsync = async (fastify) => {
|
|
35
|
+
const roots = getRoots(getConfig().roots);
|
|
36
|
+
|
|
37
|
+
// GET /api/file/*
|
|
38
|
+
fastify.get<{ Params: { '*': string }; Querystring: { raw?: string } }>(
|
|
39
|
+
'/api/file/*',
|
|
40
|
+
async (request, reply) => {
|
|
41
|
+
const rawOnly = request.query.raw === '1';
|
|
42
|
+
const reqPath = request.params['*'];
|
|
43
|
+
if (!reqPath) return reply.code(400).send({ error: 'Path required' });
|
|
44
|
+
|
|
45
|
+
const fsFilePath = urlPathToFs(reqPath, roots);
|
|
46
|
+
if (!fsFilePath) return reply.code(404).send({ error: 'Invalid path' });
|
|
47
|
+
const resolved = path.resolve(fsFilePath);
|
|
48
|
+
|
|
49
|
+
if (!fs.existsSync(resolved))
|
|
50
|
+
return reply.code(404).send({ error: 'Not found' });
|
|
51
|
+
|
|
52
|
+
const stats = fs.statSync(resolved);
|
|
53
|
+
if (stats.isDirectory())
|
|
54
|
+
return reply
|
|
55
|
+
.code(400)
|
|
56
|
+
.send({ error: 'Use /api/path/ for directories' });
|
|
57
|
+
|
|
58
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
59
|
+
const fileName = path.basename(resolved);
|
|
60
|
+
const isInsider = request.accessMode === 'insider';
|
|
61
|
+
const matchedPath = request.authMatchedPath ?? null;
|
|
62
|
+
const breadcrumbs = filterBreadcrumbsForOutsider(
|
|
63
|
+
breadcrumbParts(resolved, roots),
|
|
64
|
+
isInsider,
|
|
65
|
+
matchedPath,
|
|
66
|
+
false,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// Markdown
|
|
70
|
+
if (ext === '.md') {
|
|
71
|
+
return handleMarkdown(
|
|
72
|
+
request,
|
|
73
|
+
reply,
|
|
74
|
+
resolved,
|
|
75
|
+
reqPath,
|
|
76
|
+
rawOnly,
|
|
77
|
+
fileName,
|
|
78
|
+
breadcrumbs,
|
|
79
|
+
isInsider,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Mermaid
|
|
84
|
+
if (ext === '.mmd') {
|
|
85
|
+
const content = fs.readFileSync(resolved, 'utf8');
|
|
86
|
+
if (rawOnly)
|
|
87
|
+
return reply.send({
|
|
88
|
+
type: 'mermaid',
|
|
89
|
+
content,
|
|
90
|
+
fileName,
|
|
91
|
+
breadcrumbs,
|
|
92
|
+
isInsider,
|
|
93
|
+
});
|
|
94
|
+
const renderedMermaid = await getOrRenderDiagram(
|
|
95
|
+
'mermaid',
|
|
96
|
+
content,
|
|
97
|
+
() => renderMermaidSvg(resolved),
|
|
98
|
+
);
|
|
99
|
+
return reply.send({
|
|
100
|
+
type: 'mermaid',
|
|
101
|
+
content,
|
|
102
|
+
html: renderedMermaid,
|
|
103
|
+
fileName,
|
|
104
|
+
breadcrumbs,
|
|
105
|
+
isInsider,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// PlantUML
|
|
110
|
+
if (PLANTUML_EXTS.includes(ext)) {
|
|
111
|
+
const content = fs.readFileSync(resolved, 'utf8');
|
|
112
|
+
if (rawOnly)
|
|
113
|
+
return reply.send({
|
|
114
|
+
type: 'plantuml',
|
|
115
|
+
content,
|
|
116
|
+
fileName,
|
|
117
|
+
breadcrumbs,
|
|
118
|
+
isInsider,
|
|
119
|
+
});
|
|
120
|
+
const renderedPuml = await getOrRenderDiagram('plantuml', content, () =>
|
|
121
|
+
renderPlantUmlSvg(resolved),
|
|
122
|
+
);
|
|
123
|
+
return reply.send({
|
|
124
|
+
type: 'plantuml',
|
|
125
|
+
content,
|
|
126
|
+
html: renderedPuml,
|
|
127
|
+
fileName,
|
|
128
|
+
breadcrumbs,
|
|
129
|
+
isInsider,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// SVG
|
|
134
|
+
if (ext === '.svg') {
|
|
135
|
+
const content = fs.readFileSync(resolved, 'utf8');
|
|
136
|
+
return reply.send({
|
|
137
|
+
type: 'svg',
|
|
138
|
+
content,
|
|
139
|
+
fileName,
|
|
140
|
+
breadcrumbs,
|
|
141
|
+
isInsider,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Text files
|
|
146
|
+
const buffer = fs.readFileSync(resolved);
|
|
147
|
+
if (looksLikeText(buffer)) {
|
|
148
|
+
// Try watcher render for non-natively-renderable text files
|
|
149
|
+
if (!rawOnly) {
|
|
150
|
+
const renderResult = await tryWatcherRender(resolved);
|
|
151
|
+
if (renderResult && renderResult.renderAs === 'md') {
|
|
152
|
+
const { html, headings } = await renderMarkdownContent(
|
|
153
|
+
renderResult.content,
|
|
154
|
+
request,
|
|
155
|
+
resolved,
|
|
156
|
+
reqPath,
|
|
157
|
+
isInsider,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
return await reply.send({
|
|
161
|
+
type: 'markdown',
|
|
162
|
+
content: buffer.toString('utf8'),
|
|
163
|
+
html,
|
|
164
|
+
headings,
|
|
165
|
+
fileName,
|
|
166
|
+
breadcrumbs,
|
|
167
|
+
isInsider,
|
|
168
|
+
renderAs: renderResult.renderAs,
|
|
169
|
+
matchedRules: renderResult.rules,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return handleText(
|
|
175
|
+
reply,
|
|
176
|
+
buffer,
|
|
177
|
+
ext,
|
|
178
|
+
rawOnly,
|
|
179
|
+
fileName,
|
|
180
|
+
breadcrumbs,
|
|
181
|
+
isInsider,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Images
|
|
186
|
+
if (IMAGE_EXTS.includes(ext)) {
|
|
187
|
+
return reply.send({ type: 'image', fileName, breadcrumbs, isInsider });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Binary
|
|
191
|
+
return reply.send({
|
|
192
|
+
type: 'binary',
|
|
193
|
+
fileName,
|
|
194
|
+
size: stats.size,
|
|
195
|
+
breadcrumbs,
|
|
196
|
+
isInsider,
|
|
197
|
+
});
|
|
198
|
+
},
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// PUT /api/file/*
|
|
202
|
+
fastify.put(
|
|
203
|
+
'/api/file/*',
|
|
204
|
+
async (request: FastifyRequest, reply: FastifyReply) => {
|
|
205
|
+
if (request.accessMode !== 'insider') {
|
|
206
|
+
return reply.code(403).send({ error: 'Insider access required' });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const reqPath = (request.params as { '*': string })['*'];
|
|
210
|
+
const fsPath = urlPathToFs(reqPath, roots);
|
|
211
|
+
if (!fsPath) return reply.code(404).send({ error: 'Invalid path' });
|
|
212
|
+
const resolved = path.resolve(fsPath);
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const stat = await fs.promises.stat(resolved);
|
|
216
|
+
if (!stat.isFile())
|
|
217
|
+
return await reply
|
|
218
|
+
.code(400)
|
|
219
|
+
.send({ error: 'Can only write to files' });
|
|
220
|
+
} catch {
|
|
221
|
+
return reply.code(404).send({ error: 'File not found' });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const body = request.body as { content?: string } | null;
|
|
225
|
+
if (!body || typeof body.content !== 'string') {
|
|
226
|
+
return reply
|
|
227
|
+
.code(400)
|
|
228
|
+
.send({ error: 'Request body must include "content" string' });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
await fs.promises.writeFile(resolved, body.content, 'utf8');
|
|
233
|
+
return await reply.send({
|
|
234
|
+
ok: true,
|
|
235
|
+
path: resolved,
|
|
236
|
+
size: Buffer.byteLength(body.content, 'utf8'),
|
|
237
|
+
});
|
|
238
|
+
} catch (err) {
|
|
239
|
+
return reply
|
|
240
|
+
.code(500)
|
|
241
|
+
.send({ error: `Write failed: ${(err as Error).message}` });
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
/** Watcher render response shape. */
|
|
248
|
+
interface WatcherRenderResponse {
|
|
249
|
+
renderAs: string;
|
|
250
|
+
content: string;
|
|
251
|
+
rules: string[];
|
|
252
|
+
metadata: Record<string, unknown>;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Try to render a file via the watcher's render endpoint.
|
|
257
|
+
* Returns null if watcher is not configured, unreachable, or no rules match.
|
|
258
|
+
*/
|
|
259
|
+
async function tryWatcherRender(
|
|
260
|
+
fsPath: string,
|
|
261
|
+
): Promise<WatcherRenderResponse | null> {
|
|
262
|
+
const config = getConfig();
|
|
263
|
+
if (!config.watcherUrl) return null;
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const res = await fetch(`${config.watcherUrl}/render`, {
|
|
267
|
+
method: 'POST',
|
|
268
|
+
headers: { 'Content-Type': 'application/json' },
|
|
269
|
+
body: JSON.stringify({ path: fsPath }),
|
|
270
|
+
signal: AbortSignal.timeout(5000),
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
if (!res.ok) return null;
|
|
274
|
+
|
|
275
|
+
const data = (await res.json()) as WatcherRenderResponse;
|
|
276
|
+
if (data.rules.length === 0) return null;
|
|
277
|
+
|
|
278
|
+
return data;
|
|
279
|
+
} catch (err) {
|
|
280
|
+
console.warn('Watcher render failed for ' + fsPath + ':', err);
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Shared markdown rendering pipeline: parse → diagram hashes → optional
|
|
287
|
+
* diagram rendering → optional deep share link rewriting.
|
|
288
|
+
*/
|
|
289
|
+
async function renderMarkdownContent(
|
|
290
|
+
markdownSource: string,
|
|
291
|
+
request: FastifyRequest,
|
|
292
|
+
resolved: string,
|
|
293
|
+
reqPath: string,
|
|
294
|
+
isInsider: boolean,
|
|
295
|
+
): Promise<{
|
|
296
|
+
html: string;
|
|
297
|
+
headings: { level: number; text: string; slug: string }[];
|
|
298
|
+
}> {
|
|
299
|
+
const urlDir = reqPath.includes('/')
|
|
300
|
+
? reqPath.substring(0, reqPath.lastIndexOf('/'))
|
|
301
|
+
: '';
|
|
302
|
+
const fsDir = path.dirname(resolved);
|
|
303
|
+
setDiagramContext(fsDir);
|
|
304
|
+
const { headings, html: parsedHtml } = parseMarkdown(markdownSource, {
|
|
305
|
+
linkWindowsPaths: true,
|
|
306
|
+
basePath: urlDir,
|
|
307
|
+
});
|
|
308
|
+
let html = parsedHtml;
|
|
309
|
+
|
|
310
|
+
// Register diagram hashes for cache-clear reverse index
|
|
311
|
+
const diagramHashMatches = [
|
|
312
|
+
...html.matchAll(/data-diagram-hash="([a-f0-9]{64})"/g),
|
|
313
|
+
];
|
|
314
|
+
if (diagramHashMatches.length > 0) {
|
|
315
|
+
registerDiagramHashes(
|
|
316
|
+
resolved,
|
|
317
|
+
diagramHashMatches.map((m) => m[1]),
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if ((request.query as { render_diagrams?: string }).render_diagrams === '1') {
|
|
322
|
+
html = await renderEmbeddedDiagrams(html, fsDir);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const deepShare = request.deepShareParams;
|
|
326
|
+
const seed = request.authSeed;
|
|
327
|
+
if (!isInsider && deepShare && seed) {
|
|
328
|
+
const maxDepth = parseInt(deepShare.d, 10);
|
|
329
|
+
const dirs = deepShare.dirs === '1';
|
|
330
|
+
const currentPath = `/${reqPath}`;
|
|
331
|
+
if (!isNaN(maxDepth) && maxDepth > 0) {
|
|
332
|
+
html = rewriteLinksForDeepShare(
|
|
333
|
+
html,
|
|
334
|
+
seed,
|
|
335
|
+
currentPath,
|
|
336
|
+
maxDepth,
|
|
337
|
+
dirs,
|
|
338
|
+
deepShare.s,
|
|
339
|
+
(request.query as { exp?: string }).exp,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return { html, headings };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Handle markdown file content. */
|
|
348
|
+
async function handleMarkdown(
|
|
349
|
+
request: FastifyRequest,
|
|
350
|
+
reply: FastifyReply,
|
|
351
|
+
resolved: string,
|
|
352
|
+
reqPath: string,
|
|
353
|
+
rawOnly: boolean,
|
|
354
|
+
fileName: string,
|
|
355
|
+
breadcrumbs: { label: string; path: string }[],
|
|
356
|
+
isInsider: boolean,
|
|
357
|
+
) {
|
|
358
|
+
const markdown = fs.readFileSync(resolved, 'utf8');
|
|
359
|
+
if (rawOnly)
|
|
360
|
+
return reply.send({
|
|
361
|
+
type: 'markdown',
|
|
362
|
+
content: markdown,
|
|
363
|
+
fileName,
|
|
364
|
+
breadcrumbs,
|
|
365
|
+
isInsider,
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const { html, headings } = await renderMarkdownContent(
|
|
369
|
+
markdown,
|
|
370
|
+
request,
|
|
371
|
+
resolved,
|
|
372
|
+
reqPath,
|
|
373
|
+
isInsider,
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
return reply.send({
|
|
377
|
+
type: 'markdown',
|
|
378
|
+
content: markdown,
|
|
379
|
+
html,
|
|
380
|
+
headings,
|
|
381
|
+
fileName,
|
|
382
|
+
breadcrumbs,
|
|
383
|
+
isInsider,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Handle text file content with optional syntax highlighting. */
|
|
388
|
+
function handleText(
|
|
389
|
+
reply: FastifyReply,
|
|
390
|
+
buffer: Buffer,
|
|
391
|
+
ext: string,
|
|
392
|
+
rawOnly: boolean,
|
|
393
|
+
fileName: string,
|
|
394
|
+
breadcrumbs: { label: string; path: string }[],
|
|
395
|
+
isInsider: boolean,
|
|
396
|
+
) {
|
|
397
|
+
const textContent = buffer.toString('utf8');
|
|
398
|
+
if (rawOnly)
|
|
399
|
+
return reply.send({
|
|
400
|
+
type: 'text',
|
|
401
|
+
content: textContent,
|
|
402
|
+
fileName,
|
|
403
|
+
breadcrumbs,
|
|
404
|
+
isInsider,
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
return reply.send({
|
|
408
|
+
type: 'text',
|
|
409
|
+
content: textContent,
|
|
410
|
+
fileName,
|
|
411
|
+
breadcrumbs,
|
|
412
|
+
isInsider,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API route registrar — composes all API sub-plugins.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
6
|
+
|
|
7
|
+
import { authStatusRoutes } from './auth-status.js';
|
|
8
|
+
import { diagramsRoutes } from './diagrams.js';
|
|
9
|
+
import { directoryRoutes } from './directory.js';
|
|
10
|
+
import { drivesRoutes } from './drives.js';
|
|
11
|
+
import { exportRoutes } from './export.js';
|
|
12
|
+
import { fileContentRoutes } from './fileContent.js';
|
|
13
|
+
import { linkInfoRoutes } from './linkInfo.js';
|
|
14
|
+
import { addAuthMiddleware } from './middleware.js';
|
|
15
|
+
import { rawRoutes } from './raw.js';
|
|
16
|
+
import { runnerRoutes } from './runner.js';
|
|
17
|
+
import { searchRoutes } from './search.js';
|
|
18
|
+
import { sharingRoutes } from './sharing.js';
|
|
19
|
+
import { statusRoutes } from './status.js';
|
|
20
|
+
|
|
21
|
+
export const apiRoute: FastifyPluginAsync = async (fastify) => {
|
|
22
|
+
// Add auth hook directly to this context (not as a child plugin)
|
|
23
|
+
// so it applies to all routes registered below.
|
|
24
|
+
addAuthMiddleware(fastify);
|
|
25
|
+
await fastify.register(drivesRoutes);
|
|
26
|
+
await fastify.register(directoryRoutes);
|
|
27
|
+
await fastify.register(fileContentRoutes);
|
|
28
|
+
await fastify.register(linkInfoRoutes);
|
|
29
|
+
await fastify.register(rawRoutes);
|
|
30
|
+
await fastify.register(exportRoutes);
|
|
31
|
+
await fastify.register(diagramsRoutes);
|
|
32
|
+
await fastify.register(runnerRoutes);
|
|
33
|
+
await fastify.register(searchRoutes);
|
|
34
|
+
await fastify.register(sharingRoutes);
|
|
35
|
+
await fastify.register(authStatusRoutes);
|
|
36
|
+
await fastify.register(statusRoutes);
|
|
37
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
|
|
7
|
+
// Must set tmpDir before mocks reference it
|
|
8
|
+
let tmpDir: string;
|
|
9
|
+
|
|
10
|
+
vi.mock('../../config/index.js', () => ({
|
|
11
|
+
getConfig: () => ({ roots: {} }),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock('../../services/plantuml.js', () => ({
|
|
15
|
+
getPlantUmlFormats: () => ['svg', 'png', 'pdf'],
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock('../../util/platform.js', () => ({
|
|
19
|
+
getRoots: () => ({}),
|
|
20
|
+
urlPathToFs: (reqPath: string) => {
|
|
21
|
+
// Simple mock: treat first segment as root name, rest as path
|
|
22
|
+
const parts = reqPath.split('/');
|
|
23
|
+
if (parts[0] === 'test') return path.join(tmpDir, ...parts.slice(1));
|
|
24
|
+
return null;
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const { linkInfoRoutes } = await import('./linkInfo.js');
|
|
29
|
+
|
|
30
|
+
describe('GET /api/link-info', () => {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jeeves-linkinfo-'));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
async function callHandler(
|
|
40
|
+
urlPath: string,
|
|
41
|
+
accessMode = 'insider',
|
|
42
|
+
): Promise<Record<string, unknown>> {
|
|
43
|
+
const routes: Record<
|
|
44
|
+
string,
|
|
45
|
+
(req: unknown, reply: unknown) => Promise<unknown>
|
|
46
|
+
> = {};
|
|
47
|
+
const fakeFastify = {
|
|
48
|
+
get: (
|
|
49
|
+
routePath: string,
|
|
50
|
+
handler: (req: unknown, reply: unknown) => Promise<unknown>,
|
|
51
|
+
) => {
|
|
52
|
+
routes[routePath] = handler;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
await linkInfoRoutes(fakeFastify as never, {});
|
|
56
|
+
const handler = routes['/api/link-info/*'];
|
|
57
|
+
|
|
58
|
+
let result: unknown;
|
|
59
|
+
const fakeReply = {
|
|
60
|
+
code: () => ({
|
|
61
|
+
send: (d: unknown) => {
|
|
62
|
+
result = d;
|
|
63
|
+
return d;
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
send: (d: unknown) => {
|
|
67
|
+
result = d;
|
|
68
|
+
return d;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
const fakeRequest = {
|
|
72
|
+
params: { '*': urlPath },
|
|
73
|
+
accessMode,
|
|
74
|
+
};
|
|
75
|
+
await handler(fakeRequest, fakeReply);
|
|
76
|
+
return result as Record<string, unknown>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
it('returns exists: false for non-existent path', async () => {
|
|
80
|
+
const res = await callHandler('test/doesnotexist.md');
|
|
81
|
+
expect(res).toEqual({ exists: false });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('returns directory links with ZIP for insiders', async () => {
|
|
85
|
+
fs.mkdirSync(path.join(tmpDir, 'folder'));
|
|
86
|
+
const res = await callHandler('test/folder');
|
|
87
|
+
expect(res.exists).toBe(true);
|
|
88
|
+
expect(res.isDirectory).toBe(true);
|
|
89
|
+
expect(res.rawUrl).toBe(null);
|
|
90
|
+
expect(res.exportLinks).toEqual([
|
|
91
|
+
{ format: 'zip', url: '/api/export/test/folder?format=zip' },
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('omits ZIP for directories if outsider', async () => {
|
|
96
|
+
fs.mkdirSync(path.join(tmpDir, 'folder'));
|
|
97
|
+
const res = await callHandler('test/folder', 'outsider');
|
|
98
|
+
expect(res.exportLinks).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('returns markdown export links (pdf + docx)', async () => {
|
|
102
|
+
fs.writeFileSync(path.join(tmpDir, 'doc.md'), '# hello');
|
|
103
|
+
const res = await callHandler('test/doc.md');
|
|
104
|
+
expect(res.rawUrl).toBe('/api/raw/test/doc.md');
|
|
105
|
+
const links = res.exportLinks as { format: string }[];
|
|
106
|
+
expect(links.map((l) => l.format)).toEqual(['pdf', 'docx']);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('returns mermaid export links', async () => {
|
|
110
|
+
fs.writeFileSync(path.join(tmpDir, 'diag.mmd'), 'graph TD; A-->B');
|
|
111
|
+
const res = await callHandler('test/diag.mmd');
|
|
112
|
+
const links = res.exportLinks as { format: string }[];
|
|
113
|
+
expect(links.map((l) => l.format)).toEqual(['svg', 'png', 'pdf']);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('returns plantuml export links', async () => {
|
|
117
|
+
fs.writeFileSync(
|
|
118
|
+
path.join(tmpDir, 'diag.puml'),
|
|
119
|
+
'@startuml\nA->B\n@enduml',
|
|
120
|
+
);
|
|
121
|
+
const res = await callHandler('test/diag.puml');
|
|
122
|
+
const links = res.exportLinks as { format: string }[];
|
|
123
|
+
expect(links.map((l) => l.format)).toEqual(['svg', 'png', 'pdf']);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('returns no export links for plain files', async () => {
|
|
127
|
+
fs.writeFileSync(path.join(tmpDir, 'data.json'), '{}');
|
|
128
|
+
const res = await callHandler('test/data.json');
|
|
129
|
+
expect(res.rawUrl).toBe('/api/raw/test/data.json');
|
|
130
|
+
expect(res.exportLinks).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link availability query endpoint.
|
|
3
|
+
* Returns what views and exports are available for a given path.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
10
|
+
|
|
11
|
+
import { getConfig } from '../../config/index.js';
|
|
12
|
+
import { getPlantUmlFormats } from '../../services/plantuml.js';
|
|
13
|
+
import { getRoots, urlPathToFs } from '../../util/platform.js';
|
|
14
|
+
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
16
|
+
export const linkInfoRoutes: FastifyPluginAsync = async (fastify) => {
|
|
17
|
+
const roots = getRoots(getConfig().roots);
|
|
18
|
+
|
|
19
|
+
fastify.get<{ Params: { '*': string } }>(
|
|
20
|
+
'/api/link-info/*',
|
|
21
|
+
async (request, reply) => {
|
|
22
|
+
const reqPath = request.params['*'];
|
|
23
|
+
if (!reqPath) return reply.code(400).send({ error: 'Path required' });
|
|
24
|
+
|
|
25
|
+
const fsPath = urlPathToFs(reqPath, roots);
|
|
26
|
+
if (!fsPath) return reply.code(404).send({ error: 'Invalid path' });
|
|
27
|
+
const resolved = path.resolve(fsPath);
|
|
28
|
+
|
|
29
|
+
let stats: ReturnType<typeof fs.statSync>;
|
|
30
|
+
try {
|
|
31
|
+
stats = fs.statSync(resolved);
|
|
32
|
+
} catch {
|
|
33
|
+
return reply.send({ exists: false });
|
|
34
|
+
}
|
|
35
|
+
const isDirectory = stats.isDirectory();
|
|
36
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
37
|
+
|
|
38
|
+
const pageUrl = `/browse/${reqPath}`;
|
|
39
|
+
let rawUrl: string | null = null;
|
|
40
|
+
const exportLinks: { format: string; url: string }[] = [];
|
|
41
|
+
|
|
42
|
+
if (isDirectory) {
|
|
43
|
+
if (request.accessMode === 'insider') {
|
|
44
|
+
exportLinks.push({
|
|
45
|
+
format: 'zip',
|
|
46
|
+
url: `/api/export/${reqPath}?format=zip`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
rawUrl = `/api/raw/${reqPath}`;
|
|
51
|
+
|
|
52
|
+
if (ext === '.md' || ext === '.markdown') {
|
|
53
|
+
exportLinks.push(
|
|
54
|
+
{ format: 'pdf', url: `/api/export/${reqPath}?format=pdf` },
|
|
55
|
+
{ format: 'docx', url: `/api/export/${reqPath}?format=docx` },
|
|
56
|
+
);
|
|
57
|
+
} else if (ext === '.mmd') {
|
|
58
|
+
for (const fmt of ['svg', 'png', 'pdf']) {
|
|
59
|
+
exportLinks.push({
|
|
60
|
+
format: fmt,
|
|
61
|
+
url: `/api/mermaid-export/${reqPath}?format=${fmt}`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
} else if (['.puml', '.plantuml', '.pu'].includes(ext)) {
|
|
65
|
+
for (const fmt of getPlantUmlFormats()) {
|
|
66
|
+
exportLinks.push({
|
|
67
|
+
format: fmt,
|
|
68
|
+
url: `/api/plantuml-export/${reqPath}?format=${fmt}`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return reply.send({
|
|
75
|
+
exists: true,
|
|
76
|
+
isDirectory,
|
|
77
|
+
pageUrl,
|
|
78
|
+
rawUrl,
|
|
79
|
+
exportLinks,
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
};
|