@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.
Files changed (260) hide show
  1. package/.env.local +13 -0
  2. package/.env.local.template +13 -0
  3. package/.tsbuildinfo +1 -0
  4. package/CHANGELOG.md +450 -0
  5. package/about.md +82 -0
  6. package/client/README.md +73 -0
  7. package/client/eslint.config.js +23 -0
  8. package/client/index.html +14 -0
  9. package/client/package-lock.json +5181 -0
  10. package/client/package.json +60 -0
  11. package/client/public/vite.svg +1 -0
  12. package/client/src/App.tsx +22 -0
  13. package/client/src/components/AccountMenu.tsx +167 -0
  14. package/client/src/components/ActionDropdown.tsx +120 -0
  15. package/client/src/components/CodeEditor.tsx +143 -0
  16. package/client/src/components/CodeViewer.tsx +113 -0
  17. package/client/src/components/ConfirmDialog.tsx +32 -0
  18. package/client/src/components/DirectoryRow.tsx +62 -0
  19. package/client/src/components/DirectoryTable.tsx +42 -0
  20. package/client/src/components/DownloadDropdown.tsx +116 -0
  21. package/client/src/components/DriveList.tsx +54 -0
  22. package/client/src/components/EmbeddedDiagramPanzoom.ts +28 -0
  23. package/client/src/components/FileContentView.tsx +155 -0
  24. package/client/src/components/InlineSvgPanzoom.ts +60 -0
  25. package/client/src/components/LazyDiagram.ts +93 -0
  26. package/client/src/components/LinkDropdown.tsx +134 -0
  27. package/client/src/components/MarkdownView.tsx +115 -0
  28. package/client/src/components/MermaidViewer.tsx +21 -0
  29. package/client/src/components/PlantUmlViewer.tsx +21 -0
  30. package/client/src/components/SearchModal.tsx +424 -0
  31. package/client/src/components/SvgViewer.tsx +107 -0
  32. package/client/src/components/TabBar.tsx +96 -0
  33. package/client/src/components/layout/Header.tsx +270 -0
  34. package/client/src/components/panzoom.ts +203 -0
  35. package/client/src/components/renderableUtils.ts +15 -0
  36. package/client/src/components/runner/JobTable.tsx +153 -0
  37. package/client/src/components/runner/RunHistory.tsx +140 -0
  38. package/client/src/components/runner/StatsBar.tsx +43 -0
  39. package/client/src/components/runner/StatusPill.tsx +27 -0
  40. package/client/src/components/runner/jobTableUtils.ts +65 -0
  41. package/client/src/components/scrollUtils.ts +39 -0
  42. package/client/src/components/ui/alert-dialog.tsx +107 -0
  43. package/client/src/components/ui/button.tsx +40 -0
  44. package/client/src/components/ui/dropdown-menu.tsx +79 -0
  45. package/client/src/components/ui/input.tsx +26 -0
  46. package/client/src/components/useActionState.ts +43 -0
  47. package/client/src/hooks/useFileBrowser.ts +102 -0
  48. package/client/src/hooks/useFileData.ts +78 -0
  49. package/client/src/hooks/useScrollAnchor.ts +70 -0
  50. package/client/src/hooks/useShareSettings.ts +22 -0
  51. package/client/src/hooks/useTopBar.ts +27 -0
  52. package/client/src/index.css +281 -0
  53. package/client/src/lib/AuthContext.ts +27 -0
  54. package/client/src/lib/api.ts +239 -0
  55. package/client/src/lib/auth.tsx +50 -0
  56. package/client/src/lib/codeBlockCm6.ts +129 -0
  57. package/client/src/lib/codeBlockCopy.ts +43 -0
  58. package/client/src/lib/codemirror.ts +77 -0
  59. package/client/src/lib/runner-api.ts +172 -0
  60. package/client/src/lib/svg.ts +50 -0
  61. package/client/src/lib/theme.ts +34 -0
  62. package/client/src/lib/utils.ts +6 -0
  63. package/client/src/main.tsx +11 -0
  64. package/client/src/pages/FileBrowser.tsx +135 -0
  65. package/client/src/pages/Home.tsx +46 -0
  66. package/client/src/pages/Runner.tsx +151 -0
  67. package/client/src/pages/RunnerJob.tsx +170 -0
  68. package/client/tsconfig.app.json +32 -0
  69. package/client/tsconfig.json +7 -0
  70. package/client/tsconfig.node.json +26 -0
  71. package/client/vite.config.ts +35 -0
  72. package/content/privacy.md +61 -0
  73. package/content/terms.md +41 -0
  74. package/dist/client/assets/CodeEditor-0XHVI8Nu.js +1 -0
  75. package/dist/client/assets/CodeViewer-CykMVsfX.js +1 -0
  76. package/dist/client/assets/index--MBieNJA.js +1 -0
  77. package/dist/client/assets/index-BENeXQI_.js +1 -0
  78. package/dist/client/assets/index-BbBpoOxz.js +1 -0
  79. package/dist/client/assets/index-BdV9g5AM.js +6 -0
  80. package/dist/client/assets/index-BjAilRri.js +2 -0
  81. package/dist/client/assets/index-BqbhWo2I.js +3 -0
  82. package/dist/client/assets/index-CVbycZ0H.js +1 -0
  83. package/dist/client/assets/index-Cs5oz2oJ.js +5 -0
  84. package/dist/client/assets/index-D8KZVveX.js +1 -0
  85. package/dist/client/assets/index-DC4HMHxY.js +13 -0
  86. package/dist/client/assets/index-DbMebkkd.css +1 -0
  87. package/dist/client/assets/index-DcY2RXqX.js +1 -0
  88. package/dist/client/assets/index-Duy-tZYV.js +1 -0
  89. package/dist/client/assets/index-Dw7rDFmE.js +7 -0
  90. package/dist/client/assets/index-FlCUvrjv.js +2 -0
  91. package/dist/client/assets/index-K6OVmfhg.js +1 -0
  92. package/dist/client/assets/index-LjwgzZ7F.js +62 -0
  93. package/dist/client/assets/index-MLwyFRN0.js +1 -0
  94. package/dist/client/assets/index-OpqBpSjn.js +1 -0
  95. package/dist/client/assets/index-SsHei0HE.js +1 -0
  96. package/dist/client/assets/index-uQa2yckk.js +1 -0
  97. package/dist/client/assets/index-udkXoIER.js +1 -0
  98. package/dist/client/index.html +15 -0
  99. package/dist/client/vite.svg +1 -0
  100. package/dist/src/auth/google.js +57 -0
  101. package/dist/src/auth/keys.js +185 -0
  102. package/dist/src/auth/resolve.js +102 -0
  103. package/dist/src/auth/session.js +57 -0
  104. package/dist/src/cli/commands/config.js +100 -0
  105. package/dist/src/cli/commands/config.test.js +84 -0
  106. package/dist/src/cli/commands/service.js +93 -0
  107. package/dist/src/cli/commands/start.js +24 -0
  108. package/dist/src/cli/index.js +20 -0
  109. package/dist/src/config/index.js +90 -0
  110. package/dist/src/config/loadConfig.test.js +127 -0
  111. package/dist/src/config/resolve.js +134 -0
  112. package/dist/src/config/resolve.test.js +148 -0
  113. package/dist/src/config/schema.js +159 -0
  114. package/dist/src/config/substituteEnvVars.js +45 -0
  115. package/dist/src/config/substituteEnvVars.test.js +51 -0
  116. package/dist/src/config/types.js +5 -0
  117. package/dist/src/routes/api/auth-status.js +56 -0
  118. package/dist/src/routes/api/diagrams.js +35 -0
  119. package/dist/src/routes/api/directory.js +93 -0
  120. package/dist/src/routes/api/drives.js +15 -0
  121. package/dist/src/routes/api/export.js +218 -0
  122. package/dist/src/routes/api/fileContent.js +286 -0
  123. package/dist/src/routes/api/index.js +33 -0
  124. package/dist/src/routes/api/linkInfo.js +71 -0
  125. package/dist/src/routes/api/linkInfo.test.js +104 -0
  126. package/dist/src/routes/api/middleware.js +117 -0
  127. package/dist/src/routes/api/raw.js +38 -0
  128. package/dist/src/routes/api/runner.js +59 -0
  129. package/dist/src/routes/api/search.js +236 -0
  130. package/dist/src/routes/api/sharing.js +203 -0
  131. package/dist/src/routes/api/status.js +68 -0
  132. package/dist/src/routes/api/status.test.js +62 -0
  133. package/dist/src/routes/auth.js +99 -0
  134. package/dist/src/routes/event.js +77 -0
  135. package/dist/src/routes/event.test.js +206 -0
  136. package/dist/src/routes/health.js +10 -0
  137. package/dist/src/routes/keys.js +129 -0
  138. package/dist/src/routes/path/index.js +17 -0
  139. package/dist/src/routes/static.js +30 -0
  140. package/dist/src/server.js +90 -0
  141. package/dist/src/services/deepShareLinks.js +163 -0
  142. package/dist/src/services/diagramCache.js +104 -0
  143. package/dist/src/services/embeddedDiagrams.js +136 -0
  144. package/dist/src/services/eventLog.js +55 -0
  145. package/dist/src/services/eventLog.test.js +113 -0
  146. package/dist/src/services/eventQueue.js +154 -0
  147. package/dist/src/services/eventQueue.test.js +104 -0
  148. package/dist/src/services/export.js +220 -0
  149. package/dist/src/services/exportCache.js +196 -0
  150. package/dist/src/services/markdown.js +147 -0
  151. package/dist/src/services/mermaid.js +97 -0
  152. package/dist/src/services/plantuml.js +145 -0
  153. package/dist/src/services/puppeteer.js +156 -0
  154. package/dist/src/util/breadcrumbs.js +22 -0
  155. package/dist/src/util/crypto.js +56 -0
  156. package/dist/src/util/crypto.test.js +99 -0
  157. package/dist/src/util/fileDetection.js +66 -0
  158. package/dist/src/util/fileDetection.test.js +89 -0
  159. package/dist/src/util/formatters.js +43 -0
  160. package/dist/src/util/formatters.test.js +83 -0
  161. package/dist/src/util/packageVersion.js +25 -0
  162. package/dist/src/util/platform.js +148 -0
  163. package/dist/src/util/state.js +46 -0
  164. package/dist/vitest.config.js +12 -0
  165. package/favicon.svg +3 -0
  166. package/guides/access-decision-flow.mmd +24 -0
  167. package/guides/access-decision-flow.svg +1 -0
  168. package/guides/api-integration.md +236 -0
  169. package/guides/deployment.md +287 -0
  170. package/guides/event-gateway.md +204 -0
  171. package/guides/event-gateway.mmd +17 -0
  172. package/guides/event-gateway.svg +1 -0
  173. package/guides/exports.md +239 -0
  174. package/guides/setup.md +313 -0
  175. package/guides/sharing.md +204 -0
  176. package/jeeves-server.config.template.json +25 -0
  177. package/package.json +124 -0
  178. package/scripts/download-plantuml.js +70 -0
  179. package/src/auth/google.ts +93 -0
  180. package/src/auth/keys.ts +252 -0
  181. package/src/auth/resolve.ts +157 -0
  182. package/src/auth/session.ts +77 -0
  183. package/src/cli/commands/config.test.ts +107 -0
  184. package/src/cli/commands/config.ts +113 -0
  185. package/src/cli/commands/service.ts +129 -0
  186. package/src/cli/commands/start.ts +27 -0
  187. package/src/cli/index.ts +25 -0
  188. package/src/config/index.ts +113 -0
  189. package/src/config/loadConfig.test.ts +155 -0
  190. package/src/config/resolve.test.ts +192 -0
  191. package/src/config/resolve.ts +173 -0
  192. package/src/config/schema.ts +179 -0
  193. package/src/config/substituteEnvVars.test.ts +64 -0
  194. package/src/config/substituteEnvVars.ts +52 -0
  195. package/src/config/types.ts +129 -0
  196. package/src/routes/api/auth-status.ts +85 -0
  197. package/src/routes/api/diagrams.ts +53 -0
  198. package/src/routes/api/directory.ts +123 -0
  199. package/src/routes/api/drives.ts +23 -0
  200. package/src/routes/api/export.ts +314 -0
  201. package/src/routes/api/fileContent.ts +414 -0
  202. package/src/routes/api/index.ts +37 -0
  203. package/src/routes/api/linkInfo.test.ts +132 -0
  204. package/src/routes/api/linkInfo.ts +83 -0
  205. package/src/routes/api/middleware.ts +156 -0
  206. package/src/routes/api/raw.ts +54 -0
  207. package/src/routes/api/runner.ts +107 -0
  208. package/src/routes/api/search.ts +321 -0
  209. package/src/routes/api/sharing.ts +259 -0
  210. package/src/routes/api/status.test.ts +72 -0
  211. package/src/routes/api/status.ts +82 -0
  212. package/src/routes/auth.ts +143 -0
  213. package/src/routes/event.test.ts +248 -0
  214. package/src/routes/event.ts +109 -0
  215. package/src/routes/health.ts +13 -0
  216. package/src/routes/keys.ts +192 -0
  217. package/src/routes/path/index.ts +24 -0
  218. package/src/routes/static.ts +54 -0
  219. package/src/server.ts +104 -0
  220. package/src/services/deepShareLinks.ts +203 -0
  221. package/src/services/diagramCache.ts +128 -0
  222. package/src/services/embeddedDiagrams.ts +168 -0
  223. package/src/services/eventLog.test.ts +144 -0
  224. package/src/services/eventLog.ts +68 -0
  225. package/src/services/eventQueue.test.ts +127 -0
  226. package/src/services/eventQueue.ts +196 -0
  227. package/src/services/export.ts +267 -0
  228. package/src/services/exportCache.ts +216 -0
  229. package/src/services/markdown.ts +189 -0
  230. package/src/services/mermaid.ts +113 -0
  231. package/src/services/plantuml.ts +172 -0
  232. package/src/services/puppeteer.ts +188 -0
  233. package/src/types/fastify.d.ts +13 -0
  234. package/src/types/jsonmap.d.ts +10 -0
  235. package/src/types/plantuml-encoder.d.ts +4 -0
  236. package/src/util/breadcrumbs.ts +33 -0
  237. package/src/util/crypto.test.ts +132 -0
  238. package/src/util/crypto.ts +79 -0
  239. package/src/util/fileDetection.test.ts +115 -0
  240. package/src/util/fileDetection.ts +70 -0
  241. package/src/util/formatters.test.ts +105 -0
  242. package/src/util/formatters.ts +44 -0
  243. package/src/util/packageVersion.ts +30 -0
  244. package/src/util/platform.ts +178 -0
  245. package/src/util/state.ts +55 -0
  246. package/test-docs/diagram-retry-test.md +18 -0
  247. package/test-docs/embedded-diagrams.md +52 -0
  248. package/test-docs/lazy-diagrams-test.md +333 -0
  249. package/test-docs/page-a.md +7 -0
  250. package/test-docs/page-b.md +7 -0
  251. package/test-docs/page-c.md +7 -0
  252. package/test-docs/sub/page-d.md +7 -0
  253. package/test-docs/test-diagram.puml +13 -0
  254. package/test-docs/validate-deep-share.js +318 -0
  255. package/tsconfig.json +37 -0
  256. package/tsdoc.json +13 -0
  257. package/vendor/.plantuml-version +1 -0
  258. package/vendor/plantuml.jar +0 -0
  259. package/vitest.config.js +12 -0
  260. package/vitest.config.ts +13 -0
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Directory listing API route.
3
+ *
4
+ * Handles: GET /api/path/*
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { _directoryVisibleUnderScopes, _pathMatchesPatterns, _pathMatchesScopes, } from '../../auth/keys.js';
9
+ import { getConfig } from '../../config/index.js';
10
+ import { filterBreadcrumbsForOutsider } from '../../util/breadcrumbs.js';
11
+ import { breadcrumbParts, fsPathToUrl, getRoots, urlPathToFs, } from '../../util/platform.js';
12
+ // eslint-disable-next-line @typescript-eslint/require-await
13
+ export const directoryRoutes = async (fastify) => {
14
+ const roots = getRoots(getConfig().roots);
15
+ fastify.get('/api/path/*', async (request, reply) => {
16
+ const reqPath = request.params['*'];
17
+ if (!reqPath)
18
+ return reply.redirect('/api/drives');
19
+ const fsPath = urlPathToFs(reqPath, roots);
20
+ if (!fsPath)
21
+ return reply.code(404).send({ error: 'Invalid path' });
22
+ const resolved = path.resolve(fsPath);
23
+ if (!fs.existsSync(resolved)) {
24
+ return reply.code(404).send({ error: 'Not found', path: resolved });
25
+ }
26
+ const stats = fs.statSync(resolved);
27
+ if (!stats.isDirectory()) {
28
+ const ext = path.extname(resolved).toLowerCase();
29
+ return reply.send({
30
+ type: 'file',
31
+ path: reqPath,
32
+ ext,
33
+ size: stats.size,
34
+ mtime: stats.mtime.toISOString().split('T')[0],
35
+ });
36
+ }
37
+ const isInsider = request.accessMode === 'insider';
38
+ const insiderScopes = request.insiderScopes ?? null;
39
+ const allEntries = fs.readdirSync(resolved, { withFileTypes: true });
40
+ const entries = insiderScopes
41
+ ? allEntries.filter((entry) => {
42
+ const entryPath = path.join(resolved, entry.name);
43
+ const entryUrlPath = fsPathToUrl(entryPath, roots);
44
+ if (insiderScopes.deny.length > 0) {
45
+ if (_pathMatchesPatterns(entryUrlPath, insiderScopes.deny))
46
+ return false;
47
+ }
48
+ if (entry.isDirectory()) {
49
+ return _directoryVisibleUnderScopes(entryUrlPath, insiderScopes.allow);
50
+ }
51
+ return _pathMatchesScopes(entryUrlPath, insiderScopes);
52
+ })
53
+ : allEntries;
54
+ const sorted = entries.sort((a, b) => {
55
+ if (a.isDirectory() && !b.isDirectory())
56
+ return -1;
57
+ if (!a.isDirectory() && b.isDirectory())
58
+ return 1;
59
+ return a.name.localeCompare(b.name);
60
+ });
61
+ const result = sorted.map((entry) => {
62
+ const entryPath = path.join(resolved, entry.name);
63
+ let size = null;
64
+ let mtime = null;
65
+ const ext = path.extname(entry.name).toLowerCase();
66
+ try {
67
+ const entryStats = fs.statSync(entryPath);
68
+ mtime = entryStats.mtime.toISOString().split('T')[0];
69
+ if (!entry.isDirectory())
70
+ size = entryStats.size;
71
+ }
72
+ catch {
73
+ /* ignore */
74
+ }
75
+ return {
76
+ name: entry.name,
77
+ type: entry.isDirectory() ? 'directory' : 'file',
78
+ ext,
79
+ size,
80
+ mtime,
81
+ };
82
+ });
83
+ const breadcrumbs = breadcrumbParts(resolved, roots);
84
+ const matchedPath = request.authMatchedPath ?? null;
85
+ const filteredBreadcrumbs = filterBreadcrumbsForOutsider(breadcrumbs, isInsider, matchedPath, true);
86
+ return reply.send({
87
+ path: reqPath,
88
+ entries: result,
89
+ breadcrumbs: filteredBreadcrumbs,
90
+ isInsider,
91
+ });
92
+ });
93
+ };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Drive listing API route.
3
+ *
4
+ * Handles: GET /api/drives
5
+ */
6
+ import { getConfig } from '../../config/index.js';
7
+ import { getRoots } from '../../util/platform.js';
8
+ // eslint-disable-next-line @typescript-eslint/require-await
9
+ export const drivesRoutes = async (fastify) => {
10
+ const roots = getRoots(getConfig().roots);
11
+ fastify.get('/api/drives', async (_request, reply) => {
12
+ const drives = roots.map((r) => ({ letter: r.id, label: r.label }));
13
+ return reply.send(drives);
14
+ });
15
+ };
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Export API routes.
3
+ *
4
+ * Handles: /api/export/*, /api/mermaid-export/*, /api/plantuml-export/*
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import archiver from 'archiver';
9
+ import { getConfig } from '../../config/index.js';
10
+ import { cacheDiagramBuffer, getCachedDiagramBuffer, } from '../../services/diagramCache.js';
11
+ import { appendEvent } from '../../services/eventQueue.js';
12
+ import { exportPage } from '../../services/export.js';
13
+ import { cacheExport, clearDiagramCacheForFile, clearExportCache, clearStandaloneDiagramCache, getCachedExport, } from '../../services/exportCache.js';
14
+ import { renderMermaidToFile } from '../../services/mermaid.js';
15
+ import { getPlantUmlFormats, renderPlantUmlToBuffer, } from '../../services/plantuml.js';
16
+ import { DIAGRAM_CONTENT_TYPES } from '../../util/fileDetection.js';
17
+ import { getDirSize, getRoots, urlPathToFs } from '../../util/platform.js';
18
+ // eslint-disable-next-line @typescript-eslint/require-await
19
+ export const exportRoutes = async (fastify) => {
20
+ const roots = getRoots(getConfig().roots);
21
+ // GET /api/export/*
22
+ fastify.get('/api/export/*', async (request, reply) => {
23
+ const reqPath = request.params['*'];
24
+ if (!reqPath)
25
+ return reply.code(400).send({ error: 'Path required' });
26
+ const exportFsPath = urlPathToFs(reqPath, roots);
27
+ if (!exportFsPath)
28
+ return reply.code(404).send({ error: 'Invalid path' });
29
+ const resolved = path.resolve(exportFsPath);
30
+ if (!fs.existsSync(resolved))
31
+ return reply.code(404).send({ error: 'Not found', path: resolved });
32
+ const format = request.query.format ?? 'pdf';
33
+ const stats = fs.statSync(resolved);
34
+ // ZIP export for directories
35
+ if (stats.isDirectory()) {
36
+ if (format !== 'zip')
37
+ return reply
38
+ .code(400)
39
+ .send({ error: 'Directories only support ZIP export' });
40
+ const isInsider = request.accessMode === 'insider';
41
+ if (!isInsider)
42
+ return reply
43
+ .code(403)
44
+ .send({ error: 'ZIP export requires insider access' });
45
+ const config = getConfig();
46
+ const totalSize = getDirSize(resolved);
47
+ const maxSizeBytes = config.maxZipSizeMb * 1024 * 1024;
48
+ if (totalSize > maxSizeBytes) {
49
+ return reply.code(413).send({
50
+ error: `Directory too large for ZIP export (${String(Math.round(totalSize / 1024 / 1024))}MB, max ${String(config.maxZipSizeMb)}MB)`,
51
+ });
52
+ }
53
+ const dirName = path.basename(resolved);
54
+ const archive = archiver('zip', { zlib: { level: 6 } });
55
+ reply.header('Content-Type', 'application/zip');
56
+ reply.header('Content-Disposition', `attachment; filename="${dirName}.zip"`);
57
+ reply.send(archive);
58
+ archive.directory(resolved, dirName);
59
+ void archive.finalize();
60
+ return;
61
+ }
62
+ // PDF/DOCX export for files
63
+ if (format !== 'pdf' && format !== 'docx') {
64
+ return reply
65
+ .code(400)
66
+ .send({ error: 'Files support pdf or docx export' });
67
+ }
68
+ const ext = path.extname(resolved).toLowerCase();
69
+ if (ext !== '.md')
70
+ return reply
71
+ .code(400)
72
+ .send({ error: 'Only markdown files support PDF/DOCX export' });
73
+ const config = getConfig();
74
+ const internalKey = config.internalInsiderKey;
75
+ const { port } = config;
76
+ const exportKey = request.query.key || internalKey;
77
+ if (!exportKey)
78
+ return reply
79
+ .code(500)
80
+ .send({ error: 'Export unavailable — no internal key configured' });
81
+ const exportUrl = `http://localhost:${String(port)}/browse/${reqPath}?key=${exportKey}&render_diagrams=1&plain_code=1`;
82
+ const fileName = path.basename(resolved);
83
+ const baseName = fileName.replace(/\.md$/i, '');
84
+ try {
85
+ const contentType = format === 'pdf'
86
+ ? 'application/pdf'
87
+ : 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
88
+ const fileExt = format === 'pdf' ? 'pdf' : 'docx';
89
+ // Check export cache
90
+ const cached = getCachedExport(resolved, format);
91
+ if (cached) {
92
+ return await reply
93
+ .header('Content-Type', contentType)
94
+ .header('Content-Disposition', `attachment; filename="${baseName}.${fileExt}"`)
95
+ .header('Content-Length', cached.length)
96
+ .send(cached);
97
+ }
98
+ const buffer = await exportPage({
99
+ url: exportUrl,
100
+ fileName,
101
+ format: format,
102
+ });
103
+ // Cache the result
104
+ cacheExport(resolved, format, buffer);
105
+ return await reply
106
+ .header('Content-Type', contentType)
107
+ .header('Content-Disposition', `attachment; filename="${baseName}.${fileExt}"`)
108
+ .header('Content-Length', buffer.length)
109
+ .send(buffer);
110
+ }
111
+ catch (err) {
112
+ appendEvent({ kind: `${format}_export_error`, error: String(err) });
113
+ return reply.code(500).send({
114
+ error: `${format.toUpperCase()} export failed`,
115
+ details: String(err),
116
+ });
117
+ }
118
+ });
119
+ // DELETE /api/export-cache/* — clear all caches for a file (insider-only)
120
+ fastify.delete('/api/export-cache/*', async (request, reply) => {
121
+ if (request.accessMode !== 'insider') {
122
+ return reply.code(403).send({ error: 'Insider access required' });
123
+ }
124
+ const reqPath = request.params['*'];
125
+ if (!reqPath)
126
+ return reply.code(400).send({ error: 'Path required' });
127
+ const fsPath = urlPathToFs(reqPath, roots);
128
+ if (!fsPath)
129
+ return reply.code(404).send({ error: 'Invalid path' });
130
+ const resolved = path.resolve(fsPath);
131
+ const { getDiagramCacheDir } = await import('../../services/diagramCache.js');
132
+ const diagCacheDir = getDiagramCacheDir();
133
+ const exportCount = clearExportCache(resolved);
134
+ const embeddedCount = clearDiagramCacheForFile(resolved, diagCacheDir);
135
+ const standaloneCount = clearStandaloneDiagramCache(resolved, diagCacheDir);
136
+ return reply.send({
137
+ cleared: {
138
+ exports: exportCount,
139
+ diagrams: embeddedCount + standaloneCount,
140
+ },
141
+ });
142
+ });
143
+ // GET /api/mermaid-export/*
144
+ fastify.get('/api/mermaid-export/*', async (request, reply) => {
145
+ const reqPath = request.params['*'];
146
+ if (!reqPath)
147
+ return reply.code(400).send({ error: 'Path required' });
148
+ const mmdFsPath = urlPathToFs(reqPath, roots);
149
+ if (!mmdFsPath)
150
+ return reply.code(404).send({ error: 'Invalid path' });
151
+ const resolved = path.resolve(mmdFsPath);
152
+ if (!fs.existsSync(resolved) ||
153
+ !resolved.toLowerCase().endsWith('.mmd')) {
154
+ return reply.code(404).send({ error: 'Mermaid file not found' });
155
+ }
156
+ const mermaidFormats = ['svg', 'png', 'pdf'];
157
+ const format = mermaidFormats.includes(request.query.format ?? '')
158
+ ? request.query.format
159
+ : 'svg';
160
+ const source = fs.readFileSync(resolved, 'utf8');
161
+ // Check cache
162
+ const cachedBuffer = getCachedDiagramBuffer('mermaid', source, format);
163
+ if (cachedBuffer) {
164
+ const downloadName = `${path.basename(resolved, '.mmd')}.${format}`;
165
+ return reply
166
+ .header('Content-Type', DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream')
167
+ .header('Content-Disposition', `attachment; filename="${downloadName}"`)
168
+ .send(cachedBuffer);
169
+ }
170
+ const outFile = await renderMermaidToFile(resolved, format);
171
+ if (!outFile)
172
+ return reply.code(500).send({ error: 'Mermaid render failed' });
173
+ const content = fs.readFileSync(outFile);
174
+ cacheDiagramBuffer('mermaid', source, content, format);
175
+ const downloadName = path.basename(outFile);
176
+ return reply
177
+ .header('Content-Type', DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream')
178
+ .header('Content-Disposition', `attachment; filename="${downloadName}"`)
179
+ .send(content);
180
+ });
181
+ // GET /api/plantuml-export/*
182
+ fastify.get('/api/plantuml-export/*', async (request, reply) => {
183
+ const reqPath = request.params['*'];
184
+ if (!reqPath)
185
+ return reply.code(400).send({ error: 'Path required' });
186
+ const pumlFsPath = urlPathToFs(reqPath, roots);
187
+ if (!pumlFsPath)
188
+ return reply.code(404).send({ error: 'Invalid path' });
189
+ const resolved = path.resolve(pumlFsPath);
190
+ const ext = path.extname(resolved).toLowerCase();
191
+ if (!fs.existsSync(resolved) ||
192
+ !['.puml', '.plantuml', '.pu'].includes(ext)) {
193
+ return reply.code(404).send({ error: 'PlantUML file not found' });
194
+ }
195
+ const supported = getPlantUmlFormats();
196
+ const format = supported.includes(request.query.format ?? '')
197
+ ? request.query.format
198
+ : 'svg';
199
+ const source = fs.readFileSync(resolved, 'utf8');
200
+ const cachedBuffer = getCachedDiagramBuffer('plantuml', source, format);
201
+ if (cachedBuffer) {
202
+ const baseName = path.basename(resolved, ext);
203
+ return reply
204
+ .header('Content-Type', DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream')
205
+ .header('Content-Disposition', `attachment; filename="${baseName}.${format}"`)
206
+ .send(cachedBuffer);
207
+ }
208
+ const buffer = await renderPlantUmlToBuffer(resolved, format);
209
+ if (!buffer)
210
+ return reply.code(500).send({ error: 'PlantUML render failed' });
211
+ cacheDiagramBuffer('plantuml', source, buffer, format);
212
+ const baseName = path.basename(resolved, ext);
213
+ return reply
214
+ .header('Content-Type', DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream')
215
+ .header('Content-Disposition', `attachment; filename="${baseName}.${format}"`)
216
+ .send(buffer);
217
+ });
218
+ };
@@ -0,0 +1,286 @@
1
+ /**
2
+ * File content API routes.
3
+ *
4
+ * Handles: GET /api/file/*, PUT /api/file/*
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { getConfig } from '../../config/index.js';
9
+ import { rewriteLinksForDeepShare } from '../../services/deepShareLinks.js';
10
+ import { getOrRenderDiagram } from '../../services/diagramCache.js';
11
+ import { renderEmbeddedDiagrams, setDiagramContext, } from '../../services/embeddedDiagrams.js';
12
+ import { registerDiagramHashes } from '../../services/exportCache.js';
13
+ import { parseMarkdown } from '../../services/markdown.js';
14
+ import { renderMermaidSvg } from '../../services/mermaid.js';
15
+ import { renderPlantUmlSvg } from '../../services/plantuml.js';
16
+ import { filterBreadcrumbsForOutsider } from '../../util/breadcrumbs.js';
17
+ import { looksLikeText } from '../../util/fileDetection.js';
18
+ import { breadcrumbParts, getRoots, urlPathToFs } from '../../util/platform.js';
19
+ /** Image extensions recognized for type detection. */
20
+ const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico'];
21
+ /** PlantUML file extensions. */
22
+ const PLANTUML_EXTS = ['.puml', '.plantuml', '.pu'];
23
+ // eslint-disable-next-line @typescript-eslint/require-await
24
+ export const fileContentRoutes = async (fastify) => {
25
+ const roots = getRoots(getConfig().roots);
26
+ // GET /api/file/*
27
+ fastify.get('/api/file/*', async (request, reply) => {
28
+ const rawOnly = request.query.raw === '1';
29
+ const reqPath = request.params['*'];
30
+ if (!reqPath)
31
+ return reply.code(400).send({ error: 'Path required' });
32
+ const fsFilePath = urlPathToFs(reqPath, roots);
33
+ if (!fsFilePath)
34
+ return reply.code(404).send({ error: 'Invalid path' });
35
+ const resolved = path.resolve(fsFilePath);
36
+ if (!fs.existsSync(resolved))
37
+ return reply.code(404).send({ error: 'Not found' });
38
+ const stats = fs.statSync(resolved);
39
+ if (stats.isDirectory())
40
+ return reply
41
+ .code(400)
42
+ .send({ error: 'Use /api/path/ for directories' });
43
+ const ext = path.extname(resolved).toLowerCase();
44
+ const fileName = path.basename(resolved);
45
+ const isInsider = request.accessMode === 'insider';
46
+ const matchedPath = request.authMatchedPath ?? null;
47
+ const breadcrumbs = filterBreadcrumbsForOutsider(breadcrumbParts(resolved, roots), isInsider, matchedPath, false);
48
+ // Markdown
49
+ if (ext === '.md') {
50
+ return handleMarkdown(request, reply, resolved, reqPath, rawOnly, fileName, breadcrumbs, isInsider);
51
+ }
52
+ // Mermaid
53
+ if (ext === '.mmd') {
54
+ const content = fs.readFileSync(resolved, 'utf8');
55
+ if (rawOnly)
56
+ return reply.send({
57
+ type: 'mermaid',
58
+ content,
59
+ fileName,
60
+ breadcrumbs,
61
+ isInsider,
62
+ });
63
+ const renderedMermaid = await getOrRenderDiagram('mermaid', content, () => renderMermaidSvg(resolved));
64
+ return reply.send({
65
+ type: 'mermaid',
66
+ content,
67
+ html: renderedMermaid,
68
+ fileName,
69
+ breadcrumbs,
70
+ isInsider,
71
+ });
72
+ }
73
+ // PlantUML
74
+ if (PLANTUML_EXTS.includes(ext)) {
75
+ const content = fs.readFileSync(resolved, 'utf8');
76
+ if (rawOnly)
77
+ return reply.send({
78
+ type: 'plantuml',
79
+ content,
80
+ fileName,
81
+ breadcrumbs,
82
+ isInsider,
83
+ });
84
+ const renderedPuml = await getOrRenderDiagram('plantuml', content, () => renderPlantUmlSvg(resolved));
85
+ return reply.send({
86
+ type: 'plantuml',
87
+ content,
88
+ html: renderedPuml,
89
+ fileName,
90
+ breadcrumbs,
91
+ isInsider,
92
+ });
93
+ }
94
+ // SVG
95
+ if (ext === '.svg') {
96
+ const content = fs.readFileSync(resolved, 'utf8');
97
+ return reply.send({
98
+ type: 'svg',
99
+ content,
100
+ fileName,
101
+ breadcrumbs,
102
+ isInsider,
103
+ });
104
+ }
105
+ // Text files
106
+ const buffer = fs.readFileSync(resolved);
107
+ if (looksLikeText(buffer)) {
108
+ // Try watcher render for non-natively-renderable text files
109
+ if (!rawOnly) {
110
+ const renderResult = await tryWatcherRender(resolved);
111
+ if (renderResult && renderResult.renderAs === 'md') {
112
+ const { html, headings } = await renderMarkdownContent(renderResult.content, request, resolved, reqPath, isInsider);
113
+ return await reply.send({
114
+ type: 'markdown',
115
+ content: buffer.toString('utf8'),
116
+ html,
117
+ headings,
118
+ fileName,
119
+ breadcrumbs,
120
+ isInsider,
121
+ renderAs: renderResult.renderAs,
122
+ matchedRules: renderResult.rules,
123
+ });
124
+ }
125
+ }
126
+ return handleText(reply, buffer, ext, rawOnly, fileName, breadcrumbs, isInsider);
127
+ }
128
+ // Images
129
+ if (IMAGE_EXTS.includes(ext)) {
130
+ return reply.send({ type: 'image', fileName, breadcrumbs, isInsider });
131
+ }
132
+ // Binary
133
+ return reply.send({
134
+ type: 'binary',
135
+ fileName,
136
+ size: stats.size,
137
+ breadcrumbs,
138
+ isInsider,
139
+ });
140
+ });
141
+ // PUT /api/file/*
142
+ fastify.put('/api/file/*', async (request, reply) => {
143
+ if (request.accessMode !== 'insider') {
144
+ return reply.code(403).send({ error: 'Insider access required' });
145
+ }
146
+ const reqPath = request.params['*'];
147
+ const fsPath = urlPathToFs(reqPath, roots);
148
+ if (!fsPath)
149
+ return reply.code(404).send({ error: 'Invalid path' });
150
+ const resolved = path.resolve(fsPath);
151
+ try {
152
+ const stat = await fs.promises.stat(resolved);
153
+ if (!stat.isFile())
154
+ return await reply
155
+ .code(400)
156
+ .send({ error: 'Can only write to files' });
157
+ }
158
+ catch {
159
+ return reply.code(404).send({ error: 'File not found' });
160
+ }
161
+ const body = request.body;
162
+ if (!body || typeof body.content !== 'string') {
163
+ return reply
164
+ .code(400)
165
+ .send({ error: 'Request body must include "content" string' });
166
+ }
167
+ try {
168
+ await fs.promises.writeFile(resolved, body.content, 'utf8');
169
+ return await reply.send({
170
+ ok: true,
171
+ path: resolved,
172
+ size: Buffer.byteLength(body.content, 'utf8'),
173
+ });
174
+ }
175
+ catch (err) {
176
+ return reply
177
+ .code(500)
178
+ .send({ error: `Write failed: ${err.message}` });
179
+ }
180
+ });
181
+ };
182
+ /**
183
+ * Try to render a file via the watcher's render endpoint.
184
+ * Returns null if watcher is not configured, unreachable, or no rules match.
185
+ */
186
+ async function tryWatcherRender(fsPath) {
187
+ const config = getConfig();
188
+ if (!config.watcherUrl)
189
+ return null;
190
+ try {
191
+ const res = await fetch(`${config.watcherUrl}/render`, {
192
+ method: 'POST',
193
+ headers: { 'Content-Type': 'application/json' },
194
+ body: JSON.stringify({ path: fsPath }),
195
+ signal: AbortSignal.timeout(5000),
196
+ });
197
+ if (!res.ok)
198
+ return null;
199
+ const data = (await res.json());
200
+ if (data.rules.length === 0)
201
+ return null;
202
+ return data;
203
+ }
204
+ catch (err) {
205
+ console.warn('Watcher render failed for ' + fsPath + ':', err);
206
+ return null;
207
+ }
208
+ }
209
+ /**
210
+ * Shared markdown rendering pipeline: parse → diagram hashes → optional
211
+ * diagram rendering → optional deep share link rewriting.
212
+ */
213
+ async function renderMarkdownContent(markdownSource, request, resolved, reqPath, isInsider) {
214
+ const urlDir = reqPath.includes('/')
215
+ ? reqPath.substring(0, reqPath.lastIndexOf('/'))
216
+ : '';
217
+ const fsDir = path.dirname(resolved);
218
+ setDiagramContext(fsDir);
219
+ const { headings, html: parsedHtml } = parseMarkdown(markdownSource, {
220
+ linkWindowsPaths: true,
221
+ basePath: urlDir,
222
+ });
223
+ let html = parsedHtml;
224
+ // Register diagram hashes for cache-clear reverse index
225
+ const diagramHashMatches = [
226
+ ...html.matchAll(/data-diagram-hash="([a-f0-9]{64})"/g),
227
+ ];
228
+ if (diagramHashMatches.length > 0) {
229
+ registerDiagramHashes(resolved, diagramHashMatches.map((m) => m[1]));
230
+ }
231
+ if (request.query.render_diagrams === '1') {
232
+ html = await renderEmbeddedDiagrams(html, fsDir);
233
+ }
234
+ const deepShare = request.deepShareParams;
235
+ const seed = request.authSeed;
236
+ if (!isInsider && deepShare && seed) {
237
+ const maxDepth = parseInt(deepShare.d, 10);
238
+ const dirs = deepShare.dirs === '1';
239
+ const currentPath = `/${reqPath}`;
240
+ if (!isNaN(maxDepth) && maxDepth > 0) {
241
+ html = rewriteLinksForDeepShare(html, seed, currentPath, maxDepth, dirs, deepShare.s, request.query.exp);
242
+ }
243
+ }
244
+ return { html, headings };
245
+ }
246
+ /** Handle markdown file content. */
247
+ async function handleMarkdown(request, reply, resolved, reqPath, rawOnly, fileName, breadcrumbs, isInsider) {
248
+ const markdown = fs.readFileSync(resolved, 'utf8');
249
+ if (rawOnly)
250
+ return reply.send({
251
+ type: 'markdown',
252
+ content: markdown,
253
+ fileName,
254
+ breadcrumbs,
255
+ isInsider,
256
+ });
257
+ const { html, headings } = await renderMarkdownContent(markdown, request, resolved, reqPath, isInsider);
258
+ return reply.send({
259
+ type: 'markdown',
260
+ content: markdown,
261
+ html,
262
+ headings,
263
+ fileName,
264
+ breadcrumbs,
265
+ isInsider,
266
+ });
267
+ }
268
+ /** Handle text file content with optional syntax highlighting. */
269
+ function handleText(reply, buffer, ext, rawOnly, fileName, breadcrumbs, isInsider) {
270
+ const textContent = buffer.toString('utf8');
271
+ if (rawOnly)
272
+ return reply.send({
273
+ type: 'text',
274
+ content: textContent,
275
+ fileName,
276
+ breadcrumbs,
277
+ isInsider,
278
+ });
279
+ return reply.send({
280
+ type: 'text',
281
+ content: textContent,
282
+ fileName,
283
+ breadcrumbs,
284
+ isInsider,
285
+ });
286
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * API route registrar — composes all API sub-plugins.
3
+ */
4
+ import { authStatusRoutes } from './auth-status.js';
5
+ import { diagramsRoutes } from './diagrams.js';
6
+ import { directoryRoutes } from './directory.js';
7
+ import { drivesRoutes } from './drives.js';
8
+ import { exportRoutes } from './export.js';
9
+ import { fileContentRoutes } from './fileContent.js';
10
+ import { linkInfoRoutes } from './linkInfo.js';
11
+ import { addAuthMiddleware } from './middleware.js';
12
+ import { rawRoutes } from './raw.js';
13
+ import { runnerRoutes } from './runner.js';
14
+ import { searchRoutes } from './search.js';
15
+ import { sharingRoutes } from './sharing.js';
16
+ import { statusRoutes } from './status.js';
17
+ export const apiRoute = async (fastify) => {
18
+ // Add auth hook directly to this context (not as a child plugin)
19
+ // so it applies to all routes registered below.
20
+ addAuthMiddleware(fastify);
21
+ await fastify.register(drivesRoutes);
22
+ await fastify.register(directoryRoutes);
23
+ await fastify.register(fileContentRoutes);
24
+ await fastify.register(linkInfoRoutes);
25
+ await fastify.register(rawRoutes);
26
+ await fastify.register(exportRoutes);
27
+ await fastify.register(diagramsRoutes);
28
+ await fastify.register(runnerRoutes);
29
+ await fastify.register(searchRoutes);
30
+ await fastify.register(sharingRoutes);
31
+ await fastify.register(authStatusRoutes);
32
+ await fastify.register(statusRoutes);
33
+ };