@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/CLAUDE.md +22 -0
- package/LICENSE +135 -0
- package/README.ko.md +345 -0
- package/README.md +302 -0
- package/add-today-dairy.js +165 -0
- package/com.logseq.daily-automation.plist.example +41 -0
- package/dist/graph.d.ts +97 -0
- package/dist/graph.js +627 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +626 -0
- package/dist/types.d.ts +57 -0
- package/dist/types.js +4 -0
- package/dist/weather-scraper.d.ts +22 -0
- package/dist/weather-scraper.js +202 -0
- package/language.json +1 -0
- package/package.json +1 -0
- package/package_name +1 -0
- package/push_info.json +5 -0
- package/run-daily-automation.sh +19 -0
- package/run-daily-automation.sh.example +27 -0
- package/src/graph.ts +710 -0
- package/src/index.ts +697 -0
- package/src/types.ts +66 -0
- package/src/weather-scraper.ts +249 -0
- package/tsconfig.json +17 -0
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
|
+
}
|