collabmd 0.1.45 → 0.1.46

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 (190) hide show
  1. package/README.md +39 -19
  2. package/bin/collabmd.js +27 -1
  3. package/dist/client/assets/CodeMirrorEditor-UCMRAMCV-CYM5nxUi.js +1 -0
  4. package/dist/client/assets/browser-utils--H_DkyEF.js +1 -0
  5. package/dist/client/assets/chunk-ERSWUTIN-hHdvnhaY.js +41 -0
  6. package/dist/client/assets/comment-thread-store-CdcwrObc.js +1 -0
  7. package/dist/client/assets/comment-threads-9J6Em-_y.js +4 -0
  8. package/dist/client/assets/comment-ui-shared-BC4pt4w8.js +7 -0
  9. package/dist/client/assets/dist-B5HTH2BO.js +1 -0
  10. package/dist/client/assets/{dist-DhLvC8DS.js → dist-B5Jf1SLZ.js} +2 -2
  11. package/dist/client/assets/{dist-CnL7Y2sM.js → dist-BDt3rz7l.js} +1 -1
  12. package/dist/client/assets/dist-BEwK4csK.js +1 -0
  13. package/dist/client/assets/dist-BKW4PALF.js +1 -0
  14. package/dist/client/assets/dist-BWcHHOx-.js +1 -0
  15. package/dist/client/assets/{dist-CYZmbokb.js → dist-BalScOwZ.js} +9 -9
  16. package/dist/client/assets/dist-Bf8f4HmM.js +6 -0
  17. package/dist/client/assets/dist-BjHJDtyU.js +1 -0
  18. package/dist/client/assets/dist-Bm6pf9Bl.js +1 -0
  19. package/dist/client/assets/dist-BoVBdSjk.js +1 -0
  20. package/dist/client/assets/dist-CC5IrQMG.js +1 -0
  21. package/dist/client/assets/{dist-C11hHqv_.js → dist-CCP_AKPG.js} +12 -12
  22. package/dist/client/assets/dist-CZgOmxK6.js +1 -0
  23. package/dist/client/assets/{dist-D2AQ-76g.js → dist-Cqhv5LAk.js} +1 -1
  24. package/dist/client/assets/{dist-MvcV4190.js → dist-D-Z6FLem.js} +1 -1
  25. package/dist/client/assets/dist-DLMiIsNC.js +11 -0
  26. package/dist/client/assets/{dist-3bgCp7iY.js → dist-DZKu9Cs8.js} +1 -1
  27. package/dist/client/assets/dist-DaYdhcVs.js +1 -0
  28. package/dist/client/assets/dist-FWJsgGYI.js +1 -0
  29. package/dist/client/assets/dist-Kh2COra_.js +1 -0
  30. package/dist/client/assets/{dist-JYEDRrPT.js → dist-PDZ3bSqm.js} +1 -1
  31. package/dist/client/assets/{dist-Drp8uu5J.js → dist-TjMh8n1u.js} +1 -1
  32. package/dist/client/assets/{dist-DGb27Dl6.js → dist-dtAdgL39.js} +1 -1
  33. package/dist/client/assets/dist-jv4WdPnK.js +9 -0
  34. package/dist/client/assets/{dist-DK5Gs2wa.js → dist-rGBmqxC7.js} +1 -1
  35. package/dist/client/assets/{dist-DCzuX1AF.js → dist-shrl6K_0.js} +1 -1
  36. package/dist/client/assets/dist-tOaegDYS2.js +1 -0
  37. package/dist/client/assets/dist-ule2n6ak.js +1 -0
  38. package/dist/client/assets/dist-xElaxbia.js +1 -0
  39. package/dist/client/assets/{drawio-editor-ClE8jpoG.js → drawio-editor-FzY1cVw8.js} +1 -1
  40. package/dist/client/assets/drawioEditor-BjiKJqlc.js +2 -0
  41. package/dist/client/assets/editor-session-Cm9ekeNo.js +54 -0
  42. package/dist/client/assets/embedded-editor-base-CwTkk911.css +1 -0
  43. package/dist/client/assets/{preview-render-profile-BZzGSg6K.js → es-DXGV3RHd.js} +13 -13
  44. package/dist/client/assets/excalidraw-editor-B46zsTFI.js +9 -0
  45. package/dist/client/assets/{excalidraw-editor-CyGwOhLz.css → excalidraw-editor-Bt7gMSoh.css} +1 -1
  46. package/dist/client/assets/excalidraw-scene-BOben2gr.js +1 -0
  47. package/dist/client/assets/excalidrawEditor-CT2pHq7e.js +2 -0
  48. package/dist/client/assets/{export-page-CUfws2T-.js → export-page-A7NHawJY.js} +3 -3
  49. package/dist/client/assets/exportDocument-B2L8fe34.css +1 -0
  50. package/dist/client/assets/exportDocument-BaqcOAAW.js +2 -0
  51. package/dist/client/assets/file-icon-svg-CQAjlWjK.js +1 -0
  52. package/dist/client/assets/file-kind-BbqgGzpc.js +1 -0
  53. package/dist/client/assets/image-QXRYHUNP-SWEV8H0J.js +1 -0
  54. package/dist/client/assets/index-ClbjMBVt.js +2 -0
  55. package/dist/client/assets/index-Pum8ET-5.css +1 -0
  56. package/dist/client/assets/main-B3uPyqic.js +893 -0
  57. package/dist/client/assets/pdf-BL9Pr2D5.js +54 -0
  58. package/dist/client/assets/pdf.worker-CLesOks4.mjs +64857 -0
  59. package/dist/client/assets/pdf_viewer-FVkkThnz.js +551 -0
  60. package/dist/client/assets/{preview-render-compiler-BSaQO_p3.js → preview-render-compiler-BD-XhkS2.js} +2 -2
  61. package/dist/client/assets/preview-render-compiler-BKedaMxx.js +1 -0
  62. package/dist/client/assets/preview-render-profile-CE-5rVTC.js +1 -0
  63. package/dist/client/assets/{preview-render-worker-B42LWvrD.js → preview-render-worker-CiPqc9n6.js} +20 -20
  64. package/dist/client/assets/prod-Dy20X7eT.js +1 -0
  65. package/dist/client/assets/prod-hnwh7wRM.js +177 -0
  66. package/dist/client/assets/quick-switcher-controller-fqOYR7qR.js +12 -0
  67. package/dist/client/assets/runtime-config-BLrEKVO1.js +1 -0
  68. package/dist/client/assets/vault-api-client-_LpV1aIn.js +6 -0
  69. package/dist/client/drawio-editor.html +1 -1
  70. package/dist/client/excalidraw-editor.html +1 -1
  71. package/dist/client/export-document.html +2 -2
  72. package/dist/client/index.html +15 -3
  73. package/docker-compose.yml +42 -0
  74. package/package.json +17 -4
  75. package/scripts/local-structurizr-compose.mjs +101 -0
  76. package/src/client/app/index.html +13 -1
  77. package/src/client/application/app-shell/comments-feature.js +19 -10
  78. package/src/client/application/app-shell/git-feature.js +12 -0
  79. package/src/client/application/app-shell/presence-feature.js +2 -1
  80. package/src/client/application/app-shell/ui-feature-shell.js +45 -2
  81. package/src/client/application/app-shell/ui-feature-toolbar.js +2 -52
  82. package/src/client/application/app-shell/workspace-feature.js +5 -1
  83. package/src/client/application/app-shell-elements.js +2 -0
  84. package/src/client/application/pdf-preview-controller.js +992 -0
  85. package/src/client/application/structurizr-preview-controller.js +395 -0
  86. package/src/client/application/workspace-coordinator.js +25 -3
  87. package/src/client/application/workspace-preview-controller.js +91 -2
  88. package/src/client/bootstrap/collabmd-app-shell.js +31 -5
  89. package/src/client/browser-utils.js +44 -0
  90. package/src/client/domain/runtime-paths.js +26 -5
  91. package/src/client/domain/structurizr-language.js +99 -0
  92. package/src/client/domain/vault-paths.js +48 -0
  93. package/src/client/excalidraw-editor.js +833 -51
  94. package/src/client/infrastructure/browser-preferences-port.js +10 -0
  95. package/src/client/infrastructure/comment-thread-store.js +34 -13
  96. package/src/client/infrastructure/editor-session.js +10 -0
  97. package/src/client/infrastructure/editor-view-adapter.js +46 -2
  98. package/src/client/infrastructure/excalidraw-room-client.js +103 -14
  99. package/src/client/infrastructure/git-api-client.js +5 -0
  100. package/src/client/infrastructure/vault-api-client.js +26 -0
  101. package/src/client/presentation/comment-ui/comment-ui-shared.js +4 -0
  102. package/src/client/presentation/drawio-embed-controller.js +9 -102
  103. package/src/client/presentation/drawio-viewer.js +118 -0
  104. package/src/client/presentation/excalidraw-embed-controller.js +74 -8
  105. package/src/client/presentation/file-action-controller.js +102 -0
  106. package/src/client/presentation/file-explorer-view.js +10 -29
  107. package/src/client/presentation/file-icon-svg.js +40 -0
  108. package/src/client/presentation/file-tree-state.js +2 -0
  109. package/src/client/presentation/git-diff-view-controller.js +731 -3
  110. package/src/client/presentation/outline-controller.js +86 -0
  111. package/src/client/presentation/quick-switcher-controller.js +25 -13
  112. package/src/client/presentation/quick-switcher-text-search.js +1 -1
  113. package/src/client/styles/components/navigation.css +11 -1
  114. package/src/client/styles/features/diagram-preview.css +76 -0
  115. package/src/client/styles/features/diff-special.css +309 -0
  116. package/src/client/styles/foundation/themes.css +6 -0
  117. package/src/client/styles/layout/shell.css +358 -1
  118. package/src/client/styles/layout/view-modes.css +1 -0
  119. package/src/client/styles/style.css +1 -0
  120. package/src/client/styles/surfaces/excalidraw-editor.css +294 -0
  121. package/src/domain/comment-threads.js +63 -5
  122. package/src/domain/excalidraw-room-codec.js +6 -4
  123. package/src/domain/file-kind.js +35 -3
  124. package/src/server/config/env.js +79 -0
  125. package/src/server/create-app-server.js +12 -0
  126. package/src/server/domain/bases/base-definition.js +1 -1
  127. package/src/server/domain/bases/base-expression-runtime.js +1 -4
  128. package/src/server/domain/bases/base-index-snapshot-store.js +1 -4
  129. package/src/server/domain/bases/base-transform.js +1 -4
  130. package/src/server/domain/collaboration/collaboration-room.js +55 -21
  131. package/src/server/domain/comment-overview.js +15 -5
  132. package/src/server/domain/ripgrep-search-service.js +4 -0
  133. package/src/server/infrastructure/git/diff-service.js +5 -0
  134. package/src/server/infrastructure/git/git-service.js +21 -0
  135. package/src/server/infrastructure/git/history-service.js +54 -0
  136. package/src/server/infrastructure/git/image-mime.js +14 -0
  137. package/src/server/infrastructure/git/untracked-files.js +28 -1
  138. package/src/server/infrastructure/http/create-git-api-query-handler.js +31 -1
  139. package/src/server/infrastructure/http/create-request-handler.js +16 -23
  140. package/src/server/infrastructure/http/create-static-handler.js +5 -2
  141. package/src/server/infrastructure/http/create-structurizr-api-handler.js +145 -0
  142. package/src/server/infrastructure/http/create-vault-api-command-handler.js +66 -4
  143. package/src/server/infrastructure/http/create-vault-api-query-handler.js +51 -3
  144. package/src/server/infrastructure/http/http-request-helpers.js +22 -0
  145. package/src/server/infrastructure/persistence/path-utils.js +1 -1
  146. package/src/server/infrastructure/persistence/vault-file-store.js +3 -0
  147. package/src/server/infrastructure/structurizr/structurizr-embed.css +43 -0
  148. package/src/server/infrastructure/structurizr/structurizr-workspace-service.js +374 -0
  149. package/src/server/infrastructure/websocket/attach-collaboration-gateway.js +4 -22
  150. package/dist/client/assets/CodeMirrorEditor-UCMRAMCV-CbSISC1w.js +0 -1
  151. package/dist/client/assets/browser-utils-CKtI_rZl.js +0 -1
  152. package/dist/client/assets/chunk-ERSWUTIN-ME9RVNel.js +0 -41
  153. package/dist/client/assets/comment-threads-DDegmaFO.js +0 -4
  154. package/dist/client/assets/dist-BH87JoHx.js +0 -11
  155. package/dist/client/assets/dist-BIkPRZO7.js +0 -6
  156. package/dist/client/assets/dist-BqCsFpx4.js +0 -1
  157. package/dist/client/assets/dist-C2JHCt8U.js +0 -1
  158. package/dist/client/assets/dist-CZjTtmTu.js +0 -1
  159. package/dist/client/assets/dist-Cig4Ov_X.js +0 -1
  160. package/dist/client/assets/dist-CqQEC8gR.js +0 -1
  161. package/dist/client/assets/dist-D4Rzqod1.js +0 -1
  162. package/dist/client/assets/dist-D5sn-mwk.js +0 -9
  163. package/dist/client/assets/dist-D7G-n87v.js +0 -1
  164. package/dist/client/assets/dist-DRqOrNgN.js +0 -1
  165. package/dist/client/assets/dist-DetkMS_4.js +0 -1
  166. package/dist/client/assets/dist-Dp6FVkQ1.js +0 -1
  167. package/dist/client/assets/dist-DtbjNB28.js +0 -1
  168. package/dist/client/assets/dist-DyrYcvpo2.js +0 -1
  169. package/dist/client/assets/dist-GrygD2zE.js +0 -1
  170. package/dist/client/assets/dist-HmEpWUow.js +0 -1
  171. package/dist/client/assets/dist-RTa81Km7.js +0 -1
  172. package/dist/client/assets/drawioEditor-CXaTM9Ds.js +0 -2
  173. package/dist/client/assets/editor-session-GTwZS6Df.js +0 -16
  174. package/dist/client/assets/embedded-editor-base-CiNEc8vy.css +0 -1
  175. package/dist/client/assets/excalidraw-editor-BZVM81BO.js +0 -9
  176. package/dist/client/assets/excalidraw-scene-C8n9CHsK.js +0 -176
  177. package/dist/client/assets/excalidrawEditor-DxJanCze.js +0 -2
  178. package/dist/client/assets/exportDocument-BCuxqXDY.js +0 -2
  179. package/dist/client/assets/exportDocument-DV-OX74E.css +0 -1
  180. package/dist/client/assets/file-kind-BKPOIRPE.js +0 -1
  181. package/dist/client/assets/image-QXRYHUNP-lfTHE3bb.js +0 -1
  182. package/dist/client/assets/index-C3YnUgMD.css +0 -1
  183. package/dist/client/assets/index-D2wXraJ1.js +0 -2
  184. package/dist/client/assets/main-DtiGz7uI.js +0 -795
  185. package/dist/client/assets/preview-render-compiler-csGM5ZFz.js +0 -1
  186. package/dist/client/assets/quick-switcher-controller-shW_6vPY.js +0 -12
  187. package/dist/client/assets/room-CQLoqtMo.js +0 -1
  188. package/dist/client/assets/runtime-config-DSGmGE9V.js +0 -1
  189. package/dist/client/assets/vault-api-client-C6bcGwy-.js +0 -5
  190. package/src/server/domain/collaboration/room-persistence-controller.js +0 -80
@@ -70,6 +70,23 @@ export class GitService {
70
70
 
71
71
  return String(result.stdout ?? '');
72
72
  },
73
+ async execGitBuffer(args, { env = null } = {}) {
74
+ const result = await gitExecFile('git', ['-c', 'core.quotepath=false', ...args], {
75
+ cwd: vaultDir,
76
+ encoding: null,
77
+ env: {
78
+ ...process.env,
79
+ ...(commandEnv ?? {}),
80
+ ...(env ?? {}),
81
+ },
82
+ maxBuffer: 5 * 1024 * 1024,
83
+ timeout: 10_000,
84
+ });
85
+
86
+ return Buffer.isBuffer(result.stdout)
87
+ ? result.stdout
88
+ : Buffer.from(result.stdout ?? '');
89
+ },
73
90
  };
74
91
  this.untrackedFileService = new GitUntrackedFileService({ vaultDir });
75
92
  this.statusService = new GitStatusService({
@@ -293,6 +310,10 @@ export class GitService {
293
310
  return this.historyService.getFileSnapshot({ hash, path });
294
311
  }
295
312
 
313
+ async getFileAttachment({ hash, path } = {}) {
314
+ return this.historyService.getFileAttachment({ hash, path });
315
+ }
316
+
296
317
  async pathExistsAtRef(ref, path) {
297
318
  try {
298
319
  await this.commandRunner.execGit(['cat-file', '-e', `${ref}:${path}`]);
@@ -1,6 +1,7 @@
1
1
  import { createRequestError as createGitRequestError } from '../http/http-errors.js';
2
2
  import { getVaultFileKind, isImageAttachmentFilePath } from '../../../domain/file-kind.js';
3
3
  import { normalizeRelativeGitPath } from './path-utils.js';
4
+ import { getImageMimeType } from './image-mime.js';
4
5
  import {
5
6
  countPatchLines,
6
7
  parseGitLogHeader,
@@ -308,6 +309,7 @@ export class GitHistoryService {
308
309
 
309
310
  if (metaOnly) {
310
311
  const metaResponse = {
312
+ baseRef: meta.baseRef,
311
313
  commit: meta.commit,
312
314
  files: meta.files,
313
315
  isGitRepo: true,
@@ -335,6 +337,7 @@ export class GitHistoryService {
335
337
  && (fileSummary.additions + fileSummary.deletions) > this.maxInitialPatchLines
336
338
  ) {
337
339
  const guardedResponse = {
340
+ baseRef: meta.baseRef,
338
341
  commit: meta.commit,
339
342
  files: [{
340
343
  ...baseFile,
@@ -396,6 +399,7 @@ export class GitHistoryService {
396
399
  };
397
400
 
398
401
  const response = {
402
+ baseRef: meta.baseRef,
399
403
  commit: meta.commit,
400
404
  files: [detailedFile],
401
405
  isGitRepo: true,
@@ -460,6 +464,46 @@ export class GitHistoryService {
460
464
  });
461
465
  }
462
466
 
467
+ async getFileAttachment({ hash, path } = {}) {
468
+ const isGitRepo = await this.commandRunner.isGitRepo();
469
+ const normalizedHash = this.normalizeAttachmentRef(hash);
470
+ const normalizedPath = normalizeRelativeGitPath(path);
471
+
472
+ if (!isGitRepo) {
473
+ throw createGitRequestError(404, 'Git repository not found');
474
+ }
475
+
476
+ if (!isImageAttachmentFilePath(normalizedPath)) {
477
+ throw createGitRequestError(400, 'Unsupported image attachment path');
478
+ }
479
+
480
+ const cacheKey = JSON.stringify({
481
+ hash: normalizedHash,
482
+ path: normalizedPath,
483
+ type: 'file-attachment',
484
+ });
485
+ const cached = getCachedValue(this.commitCache, cacheKey);
486
+ if (cached) {
487
+ return cached;
488
+ }
489
+
490
+ return this.runRequest(cacheKey, async () => {
491
+ let content;
492
+ try {
493
+ content = await this.commandRunner.execGitBuffer(['cat-file', 'blob', `${normalizedHash}:${normalizedPath}`]);
494
+ } catch {
495
+ throw createGitRequestError(404, 'Commit image not found');
496
+ }
497
+
498
+ return setCachedValue(this.commitCache, cacheKey, {
499
+ content,
500
+ hash: normalizedHash,
501
+ mimeType: getImageMimeType(normalizedPath),
502
+ path: normalizedPath,
503
+ }, this.responseCacheTtlMs);
504
+ });
505
+ }
506
+
463
507
  async runRequest(key, callback) {
464
508
  if (this.pendingRequests.has(key)) {
465
509
  return this.pendingRequests.get(key);
@@ -494,6 +538,14 @@ export class GitHistoryService {
494
538
  return normalizedHash;
495
539
  }
496
540
 
541
+ normalizeAttachmentRef(hash) {
542
+ const normalizedHash = String(hash ?? '').trim();
543
+ if (normalizedHash === 'HEAD') {
544
+ return normalizedHash;
545
+ }
546
+ return this.normalizeCommitHash(normalizedHash);
547
+ }
548
+
497
549
  parseHistoryChunk(chunk) {
498
550
  const [headerLine = '', ...statLines] = String(chunk ?? '')
499
551
  .split(/\r?\n/u)
@@ -588,6 +640,7 @@ export class GitHistoryService {
588
640
  authoredAt: chunk.authoredAt,
589
641
  deletions: Number(numstatEntry?.deletions || 0),
590
642
  filesChanged: 1,
643
+ fileKind: getVaultFileKind(nameStatusEntry?.path),
591
644
  hash: chunk.hash,
592
645
  isMergeCommit: chunk.isMergeCommit,
593
646
  oldPath: nameStatusEntry?.oldPath ?? null,
@@ -679,6 +732,7 @@ export class GitHistoryService {
679
732
  const numstatEntry = numstatEntries[index];
680
733
  return {
681
734
  ...entry,
735
+ fileKind: getVaultFileKind(entry.path),
682
736
  stats: numstatEntry
683
737
  ? {
684
738
  additions: numstatEntry.additions,
@@ -0,0 +1,14 @@
1
+ const IMAGE_MIME_TYPES = Object.freeze({
2
+ '.gif': 'image/gif',
3
+ '.jpeg': 'image/jpeg',
4
+ '.jpg': 'image/jpeg',
5
+ '.png': 'image/png',
6
+ '.svg': 'image/svg+xml',
7
+ '.webp': 'image/webp',
8
+ });
9
+
10
+ export function getImageMimeType(filePath) {
11
+ const normalizedPath = String(filePath ?? '').toLowerCase();
12
+ const extension = Object.keys(IMAGE_MIME_TYPES).find((candidate) => normalizedPath.endsWith(candidate));
13
+ return extension ? IMAGE_MIME_TYPES[extension] : 'application/octet-stream';
14
+ }
@@ -2,11 +2,17 @@ import { createReadStream } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
 
5
+ import { getVaultFileKind, isImageAttachmentFilePath } from '../../../domain/file-kind.js';
5
6
  import { mapWithConcurrency } from '../../shared/async-utils.js';
6
7
  import { createEmptyStats } from './responses.js';
7
8
  import { splitContentLines } from './parsers.js';
9
+ import { getImageMimeType } from './image-mime.js';
8
10
 
9
11
  async function countFileLines(filePath) {
12
+ if (isImageAttachmentFilePath(filePath)) {
13
+ return 0;
14
+ }
15
+
10
16
  return new Promise((resolve, reject) => {
11
17
  const stream = createReadStream(filePath, { encoding: 'utf8' });
12
18
  let lineCount = 0;
@@ -36,6 +42,7 @@ function buildSyntheticAddedFileDiff(pathValue, content) {
36
42
  const normalizedContent = String(content ?? '').replace(/\r\n/g, '\n');
37
43
  const file = {
38
44
  code: 'U',
45
+ fileKind: getVaultFileKind(pathValue),
39
46
  hunks: [],
40
47
  isBinary: false,
41
48
  oldPath: null,
@@ -89,7 +96,27 @@ export class GitUntrackedFileService {
89
96
  async buildSyntheticDiffs(files = []) {
90
97
  const results = await mapWithConcurrency(files, 2, async (file) => {
91
98
  try {
92
- const content = await readFile(join(this.vaultDir, file.path), 'utf8');
99
+ const absolutePath = join(this.vaultDir, file.path);
100
+ if (isImageAttachmentFilePath(file.path)) {
101
+ const content = await readFile(absolutePath);
102
+ return {
103
+ ...file,
104
+ binaryMessage: 'Binary image file',
105
+ byteLength: content.byteLength,
106
+ code: file.code || 'U',
107
+ fileKind: 'image',
108
+ hunks: [],
109
+ isBinary: true,
110
+ mimeType: getImageMimeType(file.path),
111
+ oldPath: file.oldPath ?? null,
112
+ path: file.path,
113
+ stats: createEmptyStats(),
114
+ status: file.status || 'untracked',
115
+ synthetic: true,
116
+ };
117
+ }
118
+
119
+ const content = await readFile(absolutePath, 'utf8');
93
120
  return buildSyntheticAddedFileDiff(file.path, content);
94
121
  } catch {
95
122
  return null;
@@ -1,5 +1,12 @@
1
+ import { basename } from 'node:path';
2
+
1
3
  import { handleApiError } from './http-request-helpers.js';
2
- import { jsonResponse } from './http-response.js';
4
+ import {
5
+ createSafeAsciiFilename,
6
+ encodeContentDispositionFilename,
7
+ jsonResponse,
8
+ sendResponse,
9
+ } from './http-response.js';
3
10
 
4
11
  function resolveDiffScope(requestUrl) {
5
12
  const scope = String(requestUrl.searchParams.get('scope') || '').trim().toLowerCase();
@@ -106,6 +113,29 @@ export function createGitApiQueryHandler({ gitService }) {
106
113
  return true;
107
114
  }
108
115
 
116
+ if (requestUrl.pathname === '/api/git/file-attachment' && req.method === 'GET') {
117
+ try {
118
+ const attachment = await gitService.getFileAttachment({
119
+ hash: requestUrl.searchParams.get('hash'),
120
+ path: requestUrl.searchParams.get('path'),
121
+ });
122
+ const fileName = basename(String(attachment.path || 'image'));
123
+ sendResponse(req, res, {
124
+ body: attachment.content,
125
+ headers: {
126
+ 'Cache-Control': 'private, max-age=300, stale-while-revalidate=3600',
127
+ 'Content-Disposition': `inline; filename="${createSafeAsciiFilename(fileName)}"; filename*=UTF-8''${encodeContentDispositionFilename(fileName)}`,
128
+ 'Content-Type': attachment.mimeType || 'application/octet-stream',
129
+ 'X-Content-Type-Options': 'nosniff',
130
+ },
131
+ statusCode: 200,
132
+ });
133
+ } catch (error) {
134
+ handleApiError(req, res, error, '[api] Failed to read git file attachment:', 'Failed to read git file attachment');
135
+ }
136
+ return true;
137
+ }
138
+
109
139
  if (requestUrl.pathname === '/api/git/pull-backups' && req.method === 'GET') {
110
140
  try {
111
141
  jsonResponse(req, res, 200, {
@@ -4,9 +4,14 @@ import { createGitApiQueryHandler } from './create-git-api-query-handler.js';
4
4
  import { createHostedApiHandler } from './create-hosted-api-handler.js';
5
5
  import { createPlantUmlApiHandler } from './create-plantuml-api-handler.js';
6
6
  import { createStaticHandler } from './create-static-handler.js';
7
+ import { createStructurizrApiHandler } from './create-structurizr-api-handler.js';
7
8
  import { createVaultApiCommandHandler } from './create-vault-api-command-handler.js';
8
9
  import { createVaultApiQueryHandler } from './create-vault-api-query-handler.js';
9
10
  import { parseJsonBody } from './request-body.js';
11
+ import {
12
+ createRequestUrlWithPathname,
13
+ stripBasePath,
14
+ } from './http-request-helpers.js';
10
15
  import {
11
16
  applyCorsHeaders,
12
17
  jsonResponse,
@@ -16,28 +21,6 @@ import {
16
21
  WRITE_METHODS,
17
22
  } from './http-response.js';
18
23
 
19
- function stripBasePath(pathname, basePath) {
20
- if (!basePath) {
21
- return pathname;
22
- }
23
-
24
- if (pathname === basePath) {
25
- return '/';
26
- }
27
-
28
- if (pathname.startsWith(`${basePath}/`)) {
29
- return pathname.slice(basePath.length) || '/';
30
- }
31
-
32
- return pathname;
33
- }
34
-
35
- function createRequestUrlWithPathname(requestUrl, pathname) {
36
- const nextUrl = new URL(requestUrl.toString());
37
- nextUrl.pathname = pathname || '/';
38
- return nextUrl;
39
- }
40
-
41
24
  export function createRequestHandler(
42
25
  config,
43
26
  authService,
@@ -54,6 +37,7 @@ export function createRequestHandler(
54
37
  fileSystemSyncService = null,
55
38
  hostedWorkspaceService = null,
56
39
  githubSetupFlow = null,
40
+ structurizrWorkspaceService = null,
57
41
  ) {
58
42
  const handleStaticRequest = createStaticHandler(config, authService, searchService);
59
43
  const handleAuthApi = createAuthApiHandler({ authService });
@@ -78,12 +62,17 @@ export function createRequestHandler(
78
62
  });
79
63
  const handleVaultApiCommand = createVaultApiCommandHandler({
80
64
  backlinkIndex,
65
+ maxPdfUploadBytes: config.maxPdfUploadBytes,
81
66
  renderDocx,
82
67
  roomRegistry,
83
68
  vaultFileStore,
84
69
  workspaceMutationCoordinator,
85
70
  });
86
71
  const handlePlantUmlApi = createPlantUmlApiHandler({ plantUmlRenderer });
72
+ const handleStructurizrApi = createStructurizrApiHandler({
73
+ basePath: config.basePath,
74
+ service: structurizrWorkspaceService,
75
+ });
87
76
 
88
77
  function handleBasePathRedirect(req, res, originalRequestUrl) {
89
78
  if (
@@ -193,7 +182,7 @@ export function createRequestHandler(
193
182
  return;
194
183
  }
195
184
 
196
- if (requestUrl.pathname.startsWith('/api/')) {
185
+ if (requestUrl.pathname.startsWith('/api/') || handleStructurizrApi.requiresAuthorization(requestUrl)) {
197
186
  const authorization = authService.authorizeApiRequest(req);
198
187
  if (!authorization.ok) {
199
188
  jsonResponse(req, res, authorization.statusCode, authorization.body);
@@ -209,6 +198,10 @@ export function createRequestHandler(
209
198
  }
210
199
  }
211
200
 
201
+ if (await handleStructurizrApi(req, res, requestUrl)) {
202
+ return;
203
+ }
204
+
212
205
  if (requestUrl.pathname.startsWith('/api/')) {
213
206
  if (await handleVaultApiQuery(req, res, requestUrl)) {
214
207
  return;
@@ -7,12 +7,13 @@ const CONTENT_TYPES = {
7
7
  '.css': 'text/css; charset=utf-8',
8
8
  '.html': 'text/html; charset=utf-8',
9
9
  '.js': 'text/javascript; charset=utf-8',
10
+ '.mjs': 'text/javascript; charset=utf-8',
10
11
  '.json': 'application/json; charset=utf-8',
11
12
  '.svg': 'image/svg+xml',
12
13
  };
13
14
 
14
15
  function isVersionedAssetPath(pathname = '') {
15
- return /-[A-Za-z0-9_-]{8,}\.(?:css|js|woff2?|ttf|svg)$/u.test(pathname);
16
+ return /-[A-Za-z0-9_-]{8,}\.(?:css|js|mjs|woff2?|ttf|svg)$/u.test(pathname);
16
17
  }
17
18
 
18
19
  function getStaticCacheControl(pathname = '', extension = '') {
@@ -33,7 +34,7 @@ function getStaticCacheControl(pathname = '', extension = '') {
33
34
  return 'public, max-age=31536000, immutable';
34
35
  }
35
36
 
36
- if (extension === '.css' || extension === '.js') {
37
+ if (extension === '.css' || extension === '.js' || extension === '.mjs') {
37
38
  return 'public, max-age=0, must-revalidate';
38
39
  }
39
40
 
@@ -53,6 +54,7 @@ function buildRuntimeConfig({
53
54
  nodeEnv,
54
55
  publicWsBaseUrl,
55
56
  search,
57
+ structurizr,
56
58
  wikiLinkAutoCreate,
57
59
  wsBasePath,
58
60
  }) {
@@ -68,6 +70,7 @@ function buildRuntimeConfig({
68
70
  },
69
71
  publicWsBaseUrl,
70
72
  search,
73
+ structurizrEnabled: Boolean(structurizr?.enabled ?? structurizr?.serverUrl),
71
74
  wikiLinkAutoCreate,
72
75
  wsBasePath,
73
76
  })};\n`;
@@ -0,0 +1,145 @@
1
+ import { readFile } from 'node:fs/promises';
2
+
3
+ import { getRequestErrorStatusCode } from './http-errors.js';
4
+ import { jsonResponse, sendResponse, textResponse } from './http-response.js';
5
+ import { parseJsonBody } from './request-body.js';
6
+
7
+ const EMBED_STYLESHEET_PATH = new URL('../structurizr/structurizr-embed.css', import.meta.url);
8
+
9
+ function isSyncRequest(requestUrl, req) {
10
+ return requestUrl.pathname === '/api/structurizr/sync' && req.method === 'POST';
11
+ }
12
+
13
+ function isEmbedStylesheetRequest(requestUrl) {
14
+ return requestUrl.pathname === '/api/structurizr/embed.css';
15
+ }
16
+
17
+ function rewriteBasePath(body, contentType, basePath, { injectEmbedStylesheet = false } = {}) {
18
+ if (!/(?:text\/(?:html|css)|javascript|ecmascript)/iu.test(contentType)) {
19
+ return body;
20
+ }
21
+
22
+ let source = body.toString('utf8');
23
+ if (basePath) {
24
+ source = source.replace(
25
+ /(["'`(=])\/(static|workspace|api\/workspace)(?=[/"'`?#)])/gu,
26
+ `$1${basePath}/$2`,
27
+ );
28
+ }
29
+
30
+ if (injectEmbedStylesheet && /text\/html/iu.test(contentType)) {
31
+ const normalizedBasePath = String(basePath || '').replace(/\/+$/u, '');
32
+ const stylesheetPath = `${normalizedBasePath}/api/structurizr/embed.css`;
33
+ source = source.replace(
34
+ /<head(?:\s[^>]*)?>/iu,
35
+ (head) => `${head}\n<link rel="stylesheet" href="${stylesheetPath}">`,
36
+ );
37
+ }
38
+
39
+ return Buffer.from(source);
40
+ }
41
+
42
+ export function createStructurizrApiHandler({ basePath = '', service = null } = {}) {
43
+ async function handleRequest(req, res, requestUrl) {
44
+ if (isSyncRequest(requestUrl, req)) {
45
+ try {
46
+ const body = await parseJsonBody(req);
47
+ if (!body || typeof body.path !== 'string') {
48
+ jsonResponse(req, res, 400, { error: 'Missing Structurizr workspace path' });
49
+ return true;
50
+ }
51
+
52
+ if (!service?.isEnabled()) {
53
+ jsonResponse(req, res, 503, { error: 'Structurizr renderer is not configured' });
54
+ return true;
55
+ }
56
+
57
+ jsonResponse(req, res, 200, await service.sync({
58
+ content: body.source,
59
+ rootPath: body.path,
60
+ }));
61
+ } catch (error) {
62
+ const statusCode = getRequestErrorStatusCode(error) || Number(error?.statusCode) || 502;
63
+ if (statusCode >= 500) {
64
+ console.error('[api] Failed to sync Structurizr workspace:', error.message);
65
+ }
66
+ jsonResponse(req, res, statusCode, {
67
+ code: error?.requestCode,
68
+ error: error instanceof Error ? error.message : 'Failed to sync Structurizr workspace',
69
+ });
70
+ }
71
+ return true;
72
+ }
73
+
74
+ if (isEmbedStylesheetRequest(requestUrl)) {
75
+ if (!service?.isEnabled()) {
76
+ return false;
77
+ }
78
+
79
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
80
+ textResponse(req, res, 405, 'Method Not Allowed', { Allow: 'GET, HEAD' });
81
+ return true;
82
+ }
83
+
84
+ try {
85
+ sendResponse(req, res, {
86
+ body: await readFile(EMBED_STYLESHEET_PATH),
87
+ headers: {
88
+ 'Cache-Control': 'no-store',
89
+ 'Content-Type': 'text/css; charset=utf-8',
90
+ },
91
+ statusCode: 200,
92
+ });
93
+ } catch (error) {
94
+ console.error('[http] Failed to serve Structurizr embed stylesheet:', error.message);
95
+ textResponse(req, res, 500, 'Internal Server Error');
96
+ }
97
+ return true;
98
+ }
99
+
100
+ if (!service?.isProxyPath(requestUrl.pathname)) {
101
+ return false;
102
+ }
103
+
104
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
105
+ textResponse(req, res, 405, 'Method Not Allowed', { Allow: 'GET, HEAD' });
106
+ return true;
107
+ }
108
+
109
+ try {
110
+ const response = await service.proxy(requestUrl, {
111
+ headers: req.headers,
112
+ method: req.method,
113
+ });
114
+ if (!response) {
115
+ return false;
116
+ }
117
+
118
+ sendResponse(req, res, {
119
+ body: rewriteBasePath(response.body, response.contentType, basePath, {
120
+ injectEmbedStylesheet: requestUrl.pathname === '/workspace/1/diagrams',
121
+ }),
122
+ headers: {
123
+ 'Cache-Control': requestUrl.pathname.startsWith('/static/')
124
+ ? 'public, max-age=31536000, immutable'
125
+ : 'no-store',
126
+ 'Content-Type': response.contentType,
127
+ },
128
+ statusCode: response.statusCode,
129
+ });
130
+ } catch (error) {
131
+ const statusCode = getRequestErrorStatusCode(error) || Number(error?.statusCode) || 502;
132
+ console.error('[http] Failed to proxy Structurizr request:', error.message);
133
+ textResponse(req, res, statusCode, error.message || 'Structurizr renderer unavailable');
134
+ }
135
+
136
+ return true;
137
+ }
138
+
139
+ handleRequest.requiresAuthorization = (requestUrl) => (
140
+ Boolean(service?.isProxyPath(requestUrl.pathname))
141
+ && !requestUrl.pathname.startsWith('/api/')
142
+ );
143
+
144
+ return handleRequest;
145
+ }
@@ -1,4 +1,5 @@
1
1
  import { basename } from 'node:path';
2
+ import { isPdfFilePath } from '../../../domain/file-kind.js';
2
3
  import { createRequestError } from './http-errors.js';
3
4
  import { handleApiError, readRequestId } from './http-request-helpers.js';
4
5
  import {
@@ -7,10 +8,15 @@ import {
7
8
  jsonResponse,
8
9
  sendResponse,
9
10
  } from './http-response.js';
10
- import { parseJsonBody, readBinaryRequestBody } from './request-body.js';
11
+ import {
12
+ parseJsonBody,
13
+ readBinaryRequestBody,
14
+ REQUEST_BODY_LIMIT_BYTES,
15
+ } from './request-body.js';
11
16
 
12
17
  const DOCX_EXPORT_REQUEST_LIMIT_BYTES = 33_554_432;
13
18
  const DOCX_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
19
+ const PDF_SIGNATURE = Buffer.from('%PDF-');
14
20
 
15
21
  function createDocxDownloadHeaders(filePath) {
16
22
  const fileName = basename(String(filePath ?? 'document')).replace(/\.[^.]+$/u, '') || 'document';
@@ -23,7 +29,7 @@ function createDocxDownloadHeaders(filePath) {
23
29
  };
24
30
  }
25
31
 
26
- function decodeHeaderMetadata(value) {
32
+ function decodeHeaderMetadata(value, label = 'attachment') {
27
33
  const normalized = String(value ?? '').trim();
28
34
  if (!normalized) {
29
35
  return '';
@@ -32,7 +38,7 @@ function decodeHeaderMetadata(value) {
32
38
  try {
33
39
  return decodeURIComponent(normalized);
34
40
  } catch {
35
- throw createRequestError(400, 'Invalid attachment metadata header encoding');
41
+ throw createRequestError(400, `Invalid ${label} metadata header encoding`);
36
42
  }
37
43
  }
38
44
 
@@ -129,6 +135,55 @@ async function handleUploadAttachment({ workspaceMutationCoordinator }, req, res
129
135
  return true;
130
136
  }
131
137
 
138
+ async function handleUploadFile({ maxPdfUploadBytes, workspaceMutationCoordinator }, req, res) {
139
+ try {
140
+ const filePath = decodeHeaderMetadata(req.headers['x-collabmd-file-path'], 'file upload');
141
+ if (!filePath) {
142
+ jsonResponse(req, res, 400, { error: 'Missing file path' });
143
+ return true;
144
+ }
145
+
146
+ if (typeof workspaceMutationCoordinator?.createFile !== 'function') {
147
+ jsonResponse(req, res, 503, { error: 'File upload is unavailable' });
148
+ return true;
149
+ }
150
+
151
+ const isPdfUpload = isPdfFilePath(filePath);
152
+ if (isPdfUpload) {
153
+ const contentType = String(req.headers['content-type'] || '')
154
+ .split(';', 1)[0]
155
+ .trim()
156
+ .toLowerCase();
157
+ if (contentType !== 'application/pdf') {
158
+ jsonResponse(req, res, 400, { error: 'PDF uploads must use application/pdf' });
159
+ return true;
160
+ }
161
+ }
162
+
163
+ const maxUploadBytes = isPdfUpload ? maxPdfUploadBytes : REQUEST_BODY_LIMIT_BYTES;
164
+ const content = await readBinaryRequestBody(req, maxUploadBytes);
165
+ if (isPdfUpload && !content.subarray(0, PDF_SIGNATURE.length).equals(PDF_SIGNATURE)) {
166
+ jsonResponse(req, res, 400, { error: 'Invalid PDF file' });
167
+ return true;
168
+ }
169
+
170
+ const result = await workspaceMutationCoordinator.createFile({
171
+ content,
172
+ path: filePath,
173
+ requestId: readRequestId(req),
174
+ });
175
+ if (!result.ok) {
176
+ jsonResponse(req, res, result.error === 'File already exists' ? 409 : 400, { error: result.error });
177
+ return true;
178
+ }
179
+
180
+ jsonResponse(req, res, 201, { ok: true, path: filePath });
181
+ } catch (error) {
182
+ handleApiError(req, res, error, '[api] Failed to upload file:', 'Failed to upload file');
183
+ }
184
+ return true;
185
+ }
186
+
132
187
  async function handleCreateFile({ workspaceMutationCoordinator }, req, res) {
133
188
  try {
134
189
  const body = await parseJsonBody(req);
@@ -280,6 +335,7 @@ const ROUTE_TABLE = [
280
335
  { method: 'POST', path: '/api/export/docx', handler: handleExportDocx },
281
336
  { method: 'PUT', path: '/api/file', handler: handleWriteFile },
282
337
  { method: 'POST', path: '/api/attachments', handler: handleUploadAttachment },
338
+ { method: 'POST', path: '/api/file/upload', handler: handleUploadFile },
283
339
  { method: 'POST', path: '/api/file', handler: handleCreateFile },
284
340
  { method: 'DELETE', path: '/api/file', handler: handleDeleteFile },
285
341
  { method: 'PATCH', path: '/api/file', handler: handleRenameFile },
@@ -289,11 +345,17 @@ const ROUTE_TABLE = [
289
345
  ];
290
346
 
291
347
  export function createVaultApiCommandHandler({
348
+ maxPdfUploadBytes = REQUEST_BODY_LIMIT_BYTES,
292
349
  renderDocx = null,
293
350
  vaultFileStore,
294
351
  workspaceMutationCoordinator = null,
295
352
  }) {
296
- const context = { renderDocx, vaultFileStore, workspaceMutationCoordinator };
353
+ const context = {
354
+ maxPdfUploadBytes,
355
+ renderDocx,
356
+ vaultFileStore,
357
+ workspaceMutationCoordinator,
358
+ };
297
359
 
298
360
  return async function handleVaultApiCommand(req, res, requestUrl) {
299
361
  for (const route of ROUTE_TABLE) {