@iflow-mcp/dearcloud09-logseq-mcp 1.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/src/graph.ts ADDED
@@ -0,0 +1,710 @@
1
+ /**
2
+ * Logseq Graph Service
3
+ * Handles file system operations for Logseq graphs
4
+ */
5
+
6
+ import { readdir, readFile, writeFile, unlink, mkdir, stat, lstat } from 'node:fs/promises';
7
+ import { join, basename, extname, resolve } from 'node:path';
8
+ import type { Page, PageMetadata, SearchResult, SearchMatch, Graph, GraphNode, GraphEdge } from './types.js';
9
+
10
+ // 보안 상수
11
+ const MAX_CONTENT_SIZE = 10 * 1024 * 1024; // 10MB - DoS 방지
12
+ const MAX_DEPTH = 10; // 그래프 탐색 최대 깊이
13
+
14
+ export class GraphService {
15
+ private graphPath: string;
16
+ private pagesPath: string;
17
+ private journalsPath: string;
18
+
19
+ constructor(graphPath: string) {
20
+ this.graphPath = graphPath;
21
+ this.pagesPath = join(graphPath, 'pages');
22
+ this.journalsPath = join(graphPath, 'journals');
23
+ }
24
+
25
+ /**
26
+ * List all pages in the graph
27
+ */
28
+ async listPages(folder?: string): Promise<PageMetadata[]> {
29
+ const pages: PageMetadata[] = [];
30
+ const backlinksMap = new Map<string, string[]>();
31
+
32
+ // Collect all pages first to build backlinks
33
+ const allPages = await this.collectAllPages();
34
+
35
+ // Build backlinks map
36
+ for (const page of allPages) {
37
+ for (const link of page.links) {
38
+ const existing = backlinksMap.get(link) || [];
39
+ existing.push(page.name);
40
+ backlinksMap.set(link, existing);
41
+ }
42
+ }
43
+
44
+ // Filter by folder if specified
45
+ let filteredPages = allPages;
46
+ if (folder) {
47
+ if (folder === 'journals') {
48
+ filteredPages = allPages.filter(p => p.isJournal);
49
+ } else if (folder === 'pages') {
50
+ filteredPages = allPages.filter(p => !p.isJournal);
51
+ }
52
+ }
53
+
54
+ // Add backlinks to each page
55
+ for (const page of filteredPages) {
56
+ pages.push({
57
+ ...page,
58
+ backlinks: backlinksMap.get(page.name) || [],
59
+ });
60
+ }
61
+
62
+ return pages;
63
+ }
64
+
65
+ private async collectAllPages(): Promise<PageMetadata[]> {
66
+ const pages: PageMetadata[] = [];
67
+
68
+ // Read pages folder
69
+ try {
70
+ const pageFiles = await readdir(this.pagesPath);
71
+ for (const file of pageFiles) {
72
+ if (extname(file) === '.md') {
73
+ const filePath = join(this.pagesPath, file);
74
+ // 보안: 심링크 공격 방지 - 심링크는 건너뜀
75
+ try {
76
+ const stats = await lstat(filePath);
77
+ if (stats.isSymbolicLink()) continue;
78
+ } catch {
79
+ continue;
80
+ }
81
+ const content = await readFile(filePath, 'utf-8');
82
+ const name = basename(file, '.md');
83
+ pages.push({
84
+ path: `pages/${file}`,
85
+ name,
86
+ tags: this.extractTags(content),
87
+ links: this.extractLinks(content),
88
+ backlinks: [],
89
+ isJournal: false,
90
+ });
91
+ }
92
+ }
93
+ } catch (e) {
94
+ // pages folder might not exist
95
+ }
96
+
97
+ // Read journals folder
98
+ try {
99
+ const journalFiles = await readdir(this.journalsPath);
100
+ for (const file of journalFiles) {
101
+ if (extname(file) === '.md') {
102
+ const filePath = join(this.journalsPath, file);
103
+ // 보안: 심링크 공격 방지 - 심링크는 건너뜀
104
+ try {
105
+ const stats = await lstat(filePath);
106
+ if (stats.isSymbolicLink()) continue;
107
+ } catch {
108
+ continue;
109
+ }
110
+ const content = await readFile(filePath, 'utf-8');
111
+ const name = basename(file, '.md');
112
+ pages.push({
113
+ path: `journals/${file}`,
114
+ name,
115
+ tags: this.extractTags(content),
116
+ links: this.extractLinks(content),
117
+ backlinks: [],
118
+ isJournal: true,
119
+ });
120
+ }
121
+ }
122
+ } catch (e) {
123
+ // journals folder might not exist
124
+ }
125
+
126
+ return pages;
127
+ }
128
+
129
+ /**
130
+ * Read a page by path or name
131
+ */
132
+ async readPage(pathOrName: string): Promise<Page> {
133
+ const filePath = await this.resolvePath(pathOrName);
134
+ await this.checkSymlink(filePath); // 심링크 공격 방지
135
+ const content = await readFile(filePath, 'utf-8');
136
+ const name = basename(filePath, '.md');
137
+ const isJournal = filePath.includes('/journals/');
138
+
139
+ const { properties, body } = this.parseProperties(content);
140
+ const stats = await stat(filePath);
141
+
142
+ // Get backlinks
143
+ const allPages = await this.collectAllPages();
144
+ const backlinks = allPages
145
+ .filter(p => p.links.includes(name))
146
+ .map(p => p.name);
147
+
148
+ return {
149
+ path: filePath.replace(this.graphPath + '/', ''),
150
+ name,
151
+ content: body,
152
+ properties,
153
+ tags: this.extractTags(content),
154
+ links: this.extractLinks(content),
155
+ backlinks,
156
+ isJournal,
157
+ createdAt: stats.birthtime,
158
+ modifiedAt: stats.mtime,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Validate page name (whitelist approach for security)
164
+ */
165
+ private validatePageName(name: string): void {
166
+ // 최대 길이 제한 (파일시스템 호환성)
167
+ if (name.length > 200) {
168
+ throw new Error('Invalid page name: too long (max 200 characters)');
169
+ }
170
+
171
+ // 빈 이름 금지
172
+ if (name.trim().length === 0) {
173
+ throw new Error('Invalid page name: cannot be empty');
174
+ }
175
+
176
+ // 화이트리스트: 알파벳, 숫자, 한글, 공백, 일부 안전한 특수문자만 허용
177
+ // 금지: / \ .. : * ? " < > | null문자 등
178
+ const safePattern = /^[a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ\s_\-().,'!@#$%&+=\[\]{}]+$/;
179
+ if (!safePattern.test(name)) {
180
+ throw new Error('Invalid page name: contains forbidden characters');
181
+ }
182
+
183
+ // 명시적으로 위험한 패턴 차단
184
+ const dangerousPatterns = ['..', '/', '\\', '\x00', ':', '*', '?', '"', '<', '>', '|'];
185
+ for (const pattern of dangerousPatterns) {
186
+ if (name.includes(pattern)) {
187
+ throw new Error('Invalid page name: contains forbidden characters');
188
+ }
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Create a new page
194
+ */
195
+ async createPage(name: string, content: string, properties?: Record<string, unknown>): Promise<Page> {
196
+ // 보안 검증: 화이트리스트 기반 이름 검증
197
+ this.validatePageName(name);
198
+
199
+ const filePath = join(this.pagesPath, `${name}.md`);
200
+ this.validatePath(filePath);
201
+
202
+ // 보안 검증: 콘텐츠 크기 제한 (DoS 방지)
203
+ this.validateContentSize(content);
204
+
205
+ // Ensure pages directory exists
206
+ await mkdir(this.pagesPath, { recursive: true });
207
+
208
+ // 보안 검증: 심링크 공격 방지
209
+ await this.checkSymlink(filePath);
210
+
211
+ const fullContent = this.buildContent(content, properties);
212
+
213
+ // 보안: TOCTOU 방지 - 'wx' 플래그로 원자적 생성 (파일 존재 시 실패)
214
+ try {
215
+ await writeFile(filePath, fullContent, { encoding: 'utf-8', flag: 'wx' });
216
+ } catch (e: any) {
217
+ if (e.code === 'EEXIST') {
218
+ throw new Error(`Page already exists: ${name}`);
219
+ }
220
+ throw e;
221
+ }
222
+
223
+ return this.readPage(name);
224
+ }
225
+
226
+ /**
227
+ * Update an existing page
228
+ */
229
+ async updatePage(pathOrName: string, content: string, properties?: Record<string, unknown>): Promise<Page> {
230
+ // 보안 검증: 콘텐츠 크기 제한 (DoS 방지)
231
+ this.validateContentSize(content);
232
+
233
+ const filePath = await this.resolvePath(pathOrName);
234
+ await this.checkSymlink(filePath); // 심링크 공격 방지
235
+ const fullContent = this.buildContent(content, properties);
236
+ await writeFile(filePath, fullContent, 'utf-8');
237
+ return this.readPage(pathOrName);
238
+ }
239
+
240
+ /**
241
+ * Delete a page
242
+ */
243
+ async deletePage(pathOrName: string): Promise<void> {
244
+ const filePath = await this.resolvePath(pathOrName);
245
+ await this.checkSymlink(filePath); // 보안: 심링크 공격 방지
246
+ await unlink(filePath);
247
+ }
248
+
249
+ /**
250
+ * Append content to a page
251
+ */
252
+ async appendToPage(pathOrName: string, content: string): Promise<Page> {
253
+ // 보안 검증: 추가할 콘텐츠 크기 제한 (DoS 방지)
254
+ this.validateContentSize(content);
255
+
256
+ const filePath = await this.resolvePath(pathOrName);
257
+ await this.checkSymlink(filePath); // 심링크 공격 방지
258
+ const existing = await readFile(filePath, 'utf-8');
259
+ const newContent = existing.trimEnd() + '\n' + content;
260
+
261
+ // 결합된 콘텐츠도 크기 검증
262
+ this.validateContentSize(newContent);
263
+
264
+ await writeFile(filePath, newContent, 'utf-8');
265
+ return this.readPage(pathOrName);
266
+ }
267
+
268
+ /**
269
+ * Search pages
270
+ */
271
+ async searchPages(query: string, options?: { tags?: string[]; folder?: string }): Promise<SearchResult[]> {
272
+ // 보안: 검색어 길이 제한 (DoS 방지)
273
+ if (query.length > 1000) {
274
+ throw new Error('Search query too long (max 1000 characters)');
275
+ }
276
+
277
+ const results: SearchResult[] = [];
278
+ const pages = await this.listPages(options?.folder);
279
+ const queryLower = query.toLowerCase();
280
+
281
+ for (const page of pages) {
282
+ // Filter by tags if specified
283
+ if (options?.tags && options.tags.length > 0) {
284
+ const hasTag = options.tags.some(tag => page.tags.includes(tag));
285
+ if (!hasTag) continue;
286
+ }
287
+
288
+ // Search in content
289
+ const filePath = join(this.graphPath, page.path);
290
+
291
+ // 보안: TOCTOU 방지 - 심링크/하드링크 체크
292
+ try {
293
+ await this.checkRegularFile(filePath);
294
+ } catch {
295
+ continue; // 심링크/하드링크/특수파일은 건너뜀
296
+ }
297
+
298
+ const content = await readFile(filePath, 'utf-8');
299
+ const lines = content.split('\n');
300
+ const matches: SearchMatch[] = [];
301
+
302
+ for (let i = 0; i < lines.length; i++) {
303
+ if (lines[i].toLowerCase().includes(queryLower)) {
304
+ matches.push({
305
+ line: i + 1,
306
+ content: lines[i],
307
+ context: lines.slice(Math.max(0, i - 1), i + 2).join('\n'),
308
+ });
309
+ }
310
+ }
311
+
312
+ // Also match page name
313
+ if (page.name.toLowerCase().includes(queryLower) || matches.length > 0) {
314
+ results.push({ page, matches });
315
+ }
316
+ }
317
+
318
+ return results;
319
+ }
320
+
321
+ /**
322
+ * Get backlinks for a page
323
+ */
324
+ async getBacklinks(pathOrName: string): Promise<PageMetadata[]> {
325
+ const page = await this.readPage(pathOrName);
326
+ const allPages = await this.listPages();
327
+ return allPages.filter(p => p.links.includes(page.name));
328
+ }
329
+
330
+ /**
331
+ * Get graph data
332
+ */
333
+ async getGraph(options?: { center?: string; depth?: number }): Promise<Graph> {
334
+ const nodes: GraphNode[] = [];
335
+ const edges: GraphEdge[] = [];
336
+ const nodeSet = new Set<string>();
337
+ const pages = await this.listPages();
338
+
339
+ const addNode = (id: string, name: string, type: GraphNode['type']) => {
340
+ if (!nodeSet.has(id)) {
341
+ nodeSet.add(id);
342
+ nodes.push({ id, name, type });
343
+ }
344
+ };
345
+
346
+ if (options?.center) {
347
+ // Build graph around center node
348
+ const visited = new Set<string>();
349
+ const queue: { name: string; depth: number }[] = [{ name: options.center, depth: 0 }];
350
+ // 보안: depth는 0 이상, MAX_DEPTH 이하로 제한
351
+ const requestedDepth = options.depth ?? 1;
352
+ const maxDepth = Math.max(0, Math.min(requestedDepth, MAX_DEPTH));
353
+
354
+ while (queue.length > 0) {
355
+ const { name, depth } = queue.shift()!;
356
+ if (visited.has(name) || depth > maxDepth) continue;
357
+ visited.add(name);
358
+
359
+ const page = pages.find(p => p.name === name);
360
+ if (!page) continue;
361
+
362
+ const nodeType = page.isJournal ? 'journal' : 'page';
363
+ addNode(name, name, nodeType);
364
+
365
+ // Add links
366
+ for (const link of page.links) {
367
+ addNode(link, link, 'page');
368
+ edges.push({ source: name, target: link, type: 'link' });
369
+ if (depth < maxDepth) {
370
+ queue.push({ name: link, depth: depth + 1 });
371
+ }
372
+ }
373
+
374
+ // Add backlinks
375
+ for (const backlink of page.backlinks) {
376
+ addNode(backlink, backlink, 'page');
377
+ edges.push({ source: backlink, target: name, type: 'backlink' });
378
+ if (depth < maxDepth) {
379
+ queue.push({ name: backlink, depth: depth + 1 });
380
+ }
381
+ }
382
+
383
+ // Add tags
384
+ for (const tag of page.tags) {
385
+ addNode(`tag:${tag}`, tag, 'tag');
386
+ edges.push({ source: name, target: `tag:${tag}`, type: 'tag' });
387
+ }
388
+ }
389
+ } else {
390
+ // Full graph
391
+ for (const page of pages) {
392
+ const nodeType = page.isJournal ? 'journal' : 'page';
393
+ addNode(page.name, page.name, nodeType);
394
+
395
+ for (const link of page.links) {
396
+ addNode(link, link, 'page');
397
+ edges.push({ source: page.name, target: link, type: 'link' });
398
+ }
399
+
400
+ for (const tag of page.tags) {
401
+ addNode(`tag:${tag}`, tag, 'tag');
402
+ edges.push({ source: page.name, target: `tag:${tag}`, type: 'tag' });
403
+ }
404
+ }
405
+ }
406
+
407
+ return { nodes, edges };
408
+ }
409
+
410
+ /**
411
+ * Get journal page for a date
412
+ */
413
+ async getJournalPage(date?: string): Promise<Page | null> {
414
+ const targetDate = date || this.getTodayString();
415
+
416
+ // Validate date format (YYYY-MM-DD only)
417
+ if (date && !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
418
+ throw new Error('Invalid date format: use YYYY-MM-DD');
419
+ }
420
+
421
+ const fileName = targetDate.replace(/-/g, '_') + '.md';
422
+ const filePath = join(this.journalsPath, fileName);
423
+ this.validatePath(filePath);
424
+
425
+ try {
426
+ await stat(filePath);
427
+ return this.readPage(`journals/${fileName}`);
428
+ } catch {
429
+ return null;
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Create journal page
435
+ */
436
+ async createJournalPage(date?: string, template?: string): Promise<Page> {
437
+ const targetDate = date || this.getTodayString();
438
+
439
+ // Validate date format (YYYY-MM-DD only)
440
+ if (date && !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
441
+ throw new Error('Invalid date format: use YYYY-MM-DD');
442
+ }
443
+
444
+ // 보안 검증: 템플릿 크기 제한 (DoS 방지)
445
+ if (template) {
446
+ this.validateContentSize(template);
447
+ }
448
+
449
+ const fileName = targetDate.replace(/-/g, '_') + '.md';
450
+ const filePath = join(this.journalsPath, fileName);
451
+ this.validatePath(filePath);
452
+
453
+ // Ensure journals directory exists
454
+ await mkdir(this.journalsPath, { recursive: true });
455
+
456
+ // 보안 검증: 심링크 공격 방지
457
+ await this.checkSymlink(filePath);
458
+
459
+ // Check if already exists
460
+ try {
461
+ await stat(filePath);
462
+ return this.readPage(`journals/${fileName}`);
463
+ } catch {
464
+ // Create new journal page
465
+ const content = template || `- `;
466
+ await writeFile(filePath, content, 'utf-8');
467
+ return this.readPage(`journals/${fileName}`);
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Append content to journal page (creates if doesn't exist)
473
+ */
474
+ async appendToJournalPage(date?: string, content?: string): Promise<Page> {
475
+ const targetDate = date || this.getTodayString();
476
+
477
+ // Validate date format (YYYY-MM-DD only)
478
+ if (date && !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
479
+ throw new Error('Invalid date format: use YYYY-MM-DD');
480
+ }
481
+
482
+ // 보안 검증: 콘텐츠 크기 제한
483
+ if (content) {
484
+ this.validateContentSize(content);
485
+ }
486
+
487
+ const fileName = targetDate.replace(/-/g, '_') + '.md';
488
+ const filePath = join(this.journalsPath, fileName);
489
+ this.validatePath(filePath);
490
+
491
+ // Ensure journals directory exists
492
+ await mkdir(this.journalsPath, { recursive: true });
493
+
494
+ // 보안 검증: 심링크 공격 방지
495
+ await this.checkSymlink(filePath);
496
+
497
+ // Get existing content or create new
498
+ let existingContent = '';
499
+ try {
500
+ existingContent = await readFile(filePath, 'utf-8');
501
+ } catch {
502
+ // File doesn't exist, start with empty
503
+ }
504
+
505
+ // Append content
506
+ const newContent = existingContent
507
+ ? existingContent + '\n' + (content || '')
508
+ : (content || '- ');
509
+
510
+ await writeFile(filePath, newContent, 'utf-8');
511
+ return this.readPage(`journals/${fileName}`);
512
+ }
513
+
514
+ // Helper methods
515
+
516
+ /**
517
+ * Validate content size (prevents DoS attacks)
518
+ */
519
+ private validateContentSize(content: string): void {
520
+ const byteSize = Buffer.byteLength(content, 'utf-8');
521
+ if (byteSize > MAX_CONTENT_SIZE) {
522
+ throw new Error(`Content too large: ${Math.round(byteSize / 1024 / 1024)}MB exceeds limit of 10MB`);
523
+ }
524
+ }
525
+
526
+ /**
527
+ * Check if path is a symlink (prevents symlink escape attacks)
528
+ */
529
+ private async checkSymlink(filePath: string): Promise<void> {
530
+ try {
531
+ const stats = await lstat(filePath);
532
+ if (stats.isSymbolicLink()) {
533
+ throw new Error(`Access denied: symbolic links not allowed`);
534
+ }
535
+ } catch (e: any) {
536
+ // ENOENT is ok (file doesn't exist yet), rethrow symlink errors
537
+ if (e.message?.includes('symbolic links')) throw e;
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Check if path is a regular file (prevents symlink/hardlink attacks)
543
+ * More strict than checkSymlink - also detects hardlinks to external files
544
+ */
545
+ private async checkRegularFile(filePath: string): Promise<void> {
546
+ const stats = await lstat(filePath);
547
+
548
+ // 심링크 차단
549
+ if (stats.isSymbolicLink()) {
550
+ throw new Error(`Access denied: symbolic links not allowed`);
551
+ }
552
+
553
+ // 일반 파일만 허용 (디렉토리, 소켓, 디바이스 등 차단)
554
+ if (!stats.isFile()) {
555
+ throw new Error(`Access denied: not a regular file`);
556
+ }
557
+
558
+ // 하드링크 감지: nlink > 1이면 다른 곳에서 링크된 파일
559
+ // 주의: 정상적인 경우에도 nlink > 1일 수 있으나, 보안을 위해 차단
560
+ if (stats.nlink > 1) {
561
+ throw new Error(`Access denied: hardlinks not allowed`);
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Validate that a path is within the graph directory (prevents path traversal attacks)
567
+ */
568
+ private validatePath(filePath: string): string {
569
+ const normalizedPath = resolve(filePath);
570
+ const normalizedGraphPath = resolve(this.graphPath);
571
+
572
+ if (!normalizedPath.startsWith(normalizedGraphPath + '/') && normalizedPath !== normalizedGraphPath) {
573
+ throw new Error(`Access denied: path outside graph directory`);
574
+ }
575
+
576
+ return normalizedPath;
577
+ }
578
+
579
+ private async resolvePath(pathOrName: string): Promise<string> {
580
+ // If it's already a relative path
581
+ if (pathOrName.includes('/')) {
582
+ const filePath = join(this.graphPath, pathOrName);
583
+ return this.validatePath(filePath);
584
+ }
585
+
586
+ // Try pages first
587
+ const pagePath = join(this.pagesPath, `${pathOrName}.md`);
588
+ try {
589
+ const validPath = this.validatePath(pagePath);
590
+ await stat(validPath);
591
+ return validPath;
592
+ } catch (e: any) {
593
+ if (e.message?.includes('Access denied')) throw e;
594
+ }
595
+
596
+ // Try journals (with underscore format)
597
+ const journalPath = join(this.journalsPath, `${pathOrName.replace(/-/g, '_')}.md`);
598
+ try {
599
+ const validPath = this.validatePath(journalPath);
600
+ await stat(validPath);
601
+ return validPath;
602
+ } catch (e: any) {
603
+ if (e.message?.includes('Access denied')) throw e;
604
+ }
605
+
606
+ // Try journals with original name
607
+ const journalPath2 = join(this.journalsPath, `${pathOrName}.md`);
608
+ try {
609
+ const validPath = this.validatePath(journalPath2);
610
+ await stat(validPath);
611
+ return validPath;
612
+ } catch (e: any) {
613
+ if (e.message?.includes('Access denied')) throw e;
614
+ }
615
+
616
+ throw new Error(`Page not found: ${pathOrName}`);
617
+ }
618
+
619
+ private extractTags(content: string): string[] {
620
+ const tagRegex = /#([a-zA-Z0-9_\-/\uAC00-\uD7A3]+)/g;
621
+ const tags = new Set<string>();
622
+ let match;
623
+ while ((match = tagRegex.exec(content)) !== null) {
624
+ tags.add(match[1]);
625
+ }
626
+ return Array.from(tags);
627
+ }
628
+
629
+ private extractLinks(content: string): string[] {
630
+ const linkRegex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
631
+ const links = new Set<string>();
632
+ let match;
633
+ while ((match = linkRegex.exec(content)) !== null) {
634
+ links.add(match[1]);
635
+ }
636
+ return Array.from(links);
637
+ }
638
+
639
+ private parseProperties(content: string): { properties: Record<string, unknown>; body: string } {
640
+ const lines = content.split('\n');
641
+ const properties: Record<string, unknown> = {};
642
+ let bodyStart = 0;
643
+
644
+ // Logseq properties are at the start with format: key:: value
645
+ for (let i = 0; i < lines.length; i++) {
646
+ const match = lines[i].match(/^([a-zA-Z_-]+)::\s*(.*)$/);
647
+ if (match) {
648
+ properties[match[1]] = match[2];
649
+ bodyStart = i + 1;
650
+ } else if (lines[i].trim() === '') {
651
+ continue;
652
+ } else {
653
+ break;
654
+ }
655
+ }
656
+
657
+ return {
658
+ properties,
659
+ body: lines.slice(bodyStart).join('\n'),
660
+ };
661
+ }
662
+
663
+ /**
664
+ * Validate and sanitize properties (prevents injection attacks)
665
+ */
666
+ private validateProperties(properties: Record<string, unknown>): void {
667
+ for (const [key, value] of Object.entries(properties)) {
668
+ const keyStr = String(key);
669
+ const valueStr = String(value);
670
+
671
+ // 키에 개행문자, ::, 특수문자 금지
672
+ if (/[\n\r]/.test(keyStr) || keyStr.includes('::')) {
673
+ throw new Error(`Invalid property key: contains forbidden characters`);
674
+ }
675
+
676
+ // 값에 개행문자 금지 (Logseq 구조 오염 방지)
677
+ if (/[\n\r]/.test(valueStr)) {
678
+ throw new Error(`Invalid property value: contains newline characters`);
679
+ }
680
+
681
+ // 키는 알파벳, 숫자, -, _ 만 허용
682
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(keyStr)) {
683
+ throw new Error(`Invalid property key: must start with letter and contain only alphanumeric, dash, underscore`);
684
+ }
685
+ }
686
+ }
687
+
688
+ private buildContent(content: string, properties?: Record<string, unknown>): string {
689
+ if (!properties || Object.keys(properties).length === 0) {
690
+ return content;
691
+ }
692
+
693
+ // 보안 검증
694
+ this.validateProperties(properties);
695
+
696
+ const propLines = Object.entries(properties)
697
+ .map(([key, value]) => `${key}:: ${value}`)
698
+ .join('\n');
699
+
700
+ return propLines + '\n\n' + content;
701
+ }
702
+
703
+ private getTodayString(): string {
704
+ const now = new Date();
705
+ const year = now.getFullYear();
706
+ const month = String(now.getMonth() + 1).padStart(2, '0');
707
+ const day = String(now.getDate()).padStart(2, '0');
708
+ return `${year}-${month}-${day}`;
709
+ }
710
+ }