@open330/kiwimu 0.8.0 → 1.1.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/server.ts CHANGED
@@ -1,40 +1,104 @@
1
1
  import { join } from "path";
2
2
  import path from "path";
3
3
  import crypto from "crypto";
4
- import { DB_FILE, loadConfig, saveConfig, getActivePersona } from "./config";
4
+ import { existsSync, readFileSync, writeFileSync } from "fs";
5
+ import { DB_FILE, SUPPORTED_EXTENSIONS, loadConfig, saveConfig, getActivePersona } from "./config";
5
6
  import { Store } from "./store";
6
7
  import type { KiwiConfig } from "./config";
8
+ import type { ContentIndex } from "./services/index-generator";
9
+ import { renderActivityPage } from "./build/templates";
7
10
 
8
11
  export function startServer(root: string, port: number, host: string): void {
9
12
  const config = loadConfig(root);
10
13
  const siteDir = join(root, config.build.output_dir);
14
+ const store = new Store(join(root, DB_FILE));
15
+
16
+ process.on('beforeExit', () => store.close());
11
17
 
12
18
  let isProcessing = false;
13
19
  let processingStatus = "";
14
20
 
21
+ // Cached content index for /api/index
22
+ let cachedIndex: { data: ContentIndex; pageCount: number } | null = null;
23
+
24
+ const askRateLimit = new Map<string, number[]>(); // ip -> timestamps
25
+ const ASK_RATE_LIMIT = 10; // max requests
26
+ const ASK_RATE_WINDOW = 60_000; // per minute
27
+
28
+ // Background task tracking for dynamic Q&A
29
+ const bgTasks = new Map<string, { status: 'processing' | 'completed' | 'error'; result?: any; error?: string }>();
30
+
15
31
  const hostname = host;
16
- const authToken = crypto.randomUUID();
32
+
33
+ // Persistent auth token: env var → on-disk file → fresh UUID (cached to file).
34
+ // This keeps the manage URL stable across server restarts and lets the
35
+ // settings page / dynamic Q&A keep working once a user has authed in once.
36
+ const tokenFile = join(root, ".kiwi-token");
37
+ function loadOrCreateToken(): string {
38
+ const fromEnv = process.env.KIWIMU_AUTH_TOKEN;
39
+ if (fromEnv && fromEnv.trim().length >= 16) return fromEnv.trim();
40
+ try {
41
+ const cached = readFileSync(tokenFile, "utf-8").trim();
42
+ if (cached.length >= 16) return cached;
43
+ } catch { /* file does not exist — fall through */ }
44
+ const fresh = crypto.randomUUID();
45
+ try { writeFileSync(tokenFile, fresh, { mode: 0o600 }); } catch { /* best effort */ }
46
+ return fresh;
47
+ }
48
+ const authToken = loadOrCreateToken();
49
+ const AUTH_COOKIE_NAME = "kiwi-auth";
50
+ const AUTH_COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days
51
+
52
+ function readCookie(req: Request, name: string): string | null {
53
+ const raw = req.headers.get("cookie") || "";
54
+ const part = raw.split(";").map(s => s.trim()).find(s => s.startsWith(name + "="));
55
+ return part ? decodeURIComponent(part.slice(name.length + 1)) : null;
56
+ }
57
+ function isAuthenticated(req: Request, url: URL): boolean {
58
+ const queryToken = url.searchParams.get("token");
59
+ const authHeader = req.headers.get("Authorization");
60
+ const bearerToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
61
+ const cookieToken = readCookie(req, AUTH_COOKIE_NAME);
62
+ return queryToken === authToken || bearerToken === authToken || cookieToken === authToken;
63
+ }
64
+ function authCookieHeader(): string {
65
+ return `${AUTH_COOKIE_NAME}=${encodeURIComponent(authToken)}; Path=/; Max-Age=${AUTH_COOKIE_MAX_AGE}; SameSite=Lax; HttpOnly`;
66
+ }
67
+
17
68
  console.log(`\x1b[32m🥝 Kiwi Mu 서버 시작!\x1b[0m`);
18
69
  console.log(` http://${hostname === "0.0.0.0" ? "localhost" : hostname}:${port}`);
19
- console.log(` 관리 페이지: http://${hostname === "0.0.0.0" ? "localhost" : hostname}:${port}/admin?token=${authToken}`);
70
+ console.log(` 관리 페이지: http://${hostname === "0.0.0.0" ? "localhost" : hostname}:${port}/manage?token=${authToken}`);
20
71
  console.log(` 인증 토큰: ${authToken}`);
21
72
  if (hostname === "0.0.0.0") console.log(" 네트워크에 공개됨 (0.0.0.0)");
22
73
  console.log(" 웹에서 문서 추가 가능합니다.\n");
23
74
 
75
+ // TLS: auto-detect cert files for HTTPS
76
+ const certPaths = [
77
+ { cert: join(root, "certs", "fullchain.pem"), key: join(root, "certs", "privkey.pem") },
78
+ { cert: "/etc/letsencrypt/live/internal.jiun.dev/fullchain.pem", key: "/etc/letsencrypt/live/internal.jiun.dev/privkey.pem" },
79
+ { cert: "/certs/fullchain.pem", key: "/certs/privkey.pem" },
80
+ ];
81
+ const tlsConfig = certPaths.find(p => existsSync(p.cert) && existsSync(p.key));
82
+ if (tlsConfig) {
83
+ console.log(` 🔒 HTTPS 활성화 (${tlsConfig.cert})`);
84
+ }
85
+
24
86
  Bun.serve({
25
87
  port,
26
88
  hostname,
89
+ ...(tlsConfig ? { tls: { cert: Bun.file(tlsConfig.cert), key: Bun.file(tlsConfig.key) } } : {}),
27
90
  async fetch(req) {
28
91
  const url = new URL(req.url);
29
92
 
30
93
  // ── Auth middleware for /api/* and /admin ──
31
- if (url.pathname.startsWith("/api/") || url.pathname === "/admin") {
32
- const authHeader = req.headers.get("Authorization");
33
- const queryToken = url.searchParams.get("token");
34
- const bearerToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
35
- if (bearerToken !== authToken && queryToken !== authToken) {
94
+ if (url.pathname.startsWith("/api/") || url.pathname === "/manage" || url.pathname === "/activity" || url.pathname === "/provenance") {
95
+ if (!isAuthenticated(req, url)) {
36
96
  return Response.json({ error: "Unauthorized" }, { status: 401 });
37
97
  }
98
+ // If user supplied token via ?token=…, set a cookie so subsequent
99
+ // requests don't need it. Continue handling the request below.
100
+ // (We set the Set-Cookie header on the eventual response in branches
101
+ // that build their own Response; for /manage we wrap below.)
38
102
  }
39
103
 
40
104
  // ── API endpoints ──
@@ -57,9 +121,8 @@ export function startServer(root: string, port: number, host: string): void {
57
121
  }
58
122
 
59
123
  const ext = file.name.split(".").pop()?.toLowerCase() || "";
60
- const supported = ["pdf", "docx", "doc", "pptx", "ppt", "key", "rtf"];
61
- if (!supported.includes(ext)) {
62
- return Response.json({ error: `지원하지 않는 형식: .${ext}. 지원: ${supported.join(", ")}` }, { status: 400 });
124
+ if (!SUPPORTED_EXTENSIONS.includes(ext)) {
125
+ return Response.json({ error: `지원하지 않는 형식: .${ext}. 지원: ${SUPPORTED_EXTENSIONS.join(", ")}` }, { status: 400 });
63
126
  }
64
127
 
65
128
  // Save uploaded file
@@ -73,7 +136,6 @@ export function startServer(root: string, port: number, host: string): void {
73
136
  processingStatus = "파일 처리 시작...";
74
137
 
75
138
  (async () => {
76
- const store = new Store(join(root, DB_FILE));
77
139
  try {
78
140
  const { ingestFile } = await import("./services/ingest");
79
141
  const currentConfig = loadConfig(root);
@@ -81,7 +143,7 @@ export function startServer(root: string, port: number, host: string): void {
81
143
 
82
144
  await ingestFile(root, store, filePath, file.name, currentConfig.llm, currentPersona, (status) => {
83
145
  processingStatus = status;
84
- });
146
+ }, currentConfig.schema);
85
147
 
86
148
  processingStatus = "빌드 중...";
87
149
  const { buildSite } = await import("./build/renderer");
@@ -92,7 +154,6 @@ export function startServer(root: string, port: number, host: string): void {
92
154
  const message = e instanceof Error ? e.message : String(e);
93
155
  processingStatus = `오류: ${message}`;
94
156
  } finally {
95
- store.close();
96
157
  setTimeout(() => { isProcessing = false; }, 2000);
97
158
  }
98
159
  })();
@@ -123,7 +184,6 @@ export function startServer(root: string, port: number, host: string): void {
123
184
  processingStatus = "시작 중...";
124
185
 
125
186
  (async () => {
126
- const store = new Store(join(root, DB_FILE));
127
187
  try {
128
188
  const { ingestUrl } = await import("./services/ingest");
129
189
  const currentConfig = loadConfig(root);
@@ -131,7 +191,7 @@ export function startServer(root: string, port: number, host: string): void {
131
191
 
132
192
  await ingestUrl(root, store, body.source, currentConfig.llm, currentPersona, (status) => {
133
193
  processingStatus = status;
134
- });
194
+ }, currentConfig.schema);
135
195
 
136
196
  processingStatus = "빌드 중...";
137
197
  const { buildSite } = await import("./build/renderer");
@@ -142,7 +202,6 @@ export function startServer(root: string, port: number, host: string): void {
142
202
  const message = e instanceof Error ? e.message : String(e);
143
203
  processingStatus = `오류: ${message}`;
144
204
  } finally {
145
- store.close();
146
205
  setTimeout(() => { isProcessing = false; }, 2000);
147
206
  }
148
207
  })();
@@ -165,7 +224,6 @@ export function startServer(root: string, port: number, host: string): void {
165
224
 
166
225
  // Auto-rebuild site with new settings
167
226
  (async () => {
168
- const store = new Store(join(root, DB_FILE));
169
227
  try {
170
228
  const { buildSite } = await import("./build/renderer");
171
229
  await buildSite(store, currentConfig, root);
@@ -173,8 +231,6 @@ export function startServer(root: string, port: number, host: string): void {
173
231
  } catch (e: unknown) {
174
232
  const message = e instanceof Error ? e.message : String(e);
175
233
  console.error(`\x1b[31m❌ 리빌드 실패: ${message}\x1b[0m`);
176
- } finally {
177
- store.close();
178
234
  }
179
235
  })();
180
236
 
@@ -246,7 +302,6 @@ export function startServer(root: string, port: number, host: string): void {
246
302
  isProcessing = true;
247
303
  processingStatus = "빌드 중...";
248
304
  (async () => {
249
- const store = new Store(join(root, DB_FILE));
250
305
  try {
251
306
  const { buildSite } = await import("./build/renderer");
252
307
  await buildSite(store, loadConfig(root), root);
@@ -256,7 +311,6 @@ export function startServer(root: string, port: number, host: string): void {
256
311
  const message = e instanceof Error ? e.message : String(e);
257
312
  processingStatus = `빌드 오류: ${message}`;
258
313
  } finally {
259
- store.close();
260
314
  setTimeout(() => { isProcessing = false; }, 2000);
261
315
  }
262
316
  })();
@@ -264,14 +318,14 @@ export function startServer(root: string, port: number, host: string): void {
264
318
  }
265
319
 
266
320
  // Admin page
267
- if (url.pathname === "/admin") {
268
- const store = new Store(join(root, DB_FILE));
321
+ if (url.pathname === "/manage") {
269
322
  const sources = store.listSourcesMeta();
270
323
  const usage = store.getUsageSummary();
271
324
  const configData = loadConfig(root);
272
- store.close();
273
325
 
274
326
  const { renderAdmin } = await import("./build/templates");
327
+ const headers: Record<string, string> = { "Content-Type": "text/html" };
328
+ if (url.searchParams.get("token") === authToken) headers["Set-Cookie"] = authCookieHeader();
275
329
  return new Response(renderAdmin({
276
330
  wikiName: configData.project.name,
277
331
  sources,
@@ -280,17 +334,253 @@ export function startServer(root: string, port: number, host: string): void {
280
334
  personas: configData.personas || [],
281
335
  activePersona: configData.active_persona || "",
282
336
  authToken,
283
- }), { headers: { "Content-Type": "text/html" } });
337
+ }), { headers });
338
+ }
339
+
340
+ if (url.pathname === "/api/ask" && req.method === "POST") {
341
+ const clientIp = req.headers.get("x-forwarded-for") || "local";
342
+ const now = Date.now();
343
+ const timestamps = askRateLimit.get(clientIp) || [];
344
+ const recent = timestamps.filter(t => now - t < ASK_RATE_WINDOW);
345
+ if (recent.length >= ASK_RATE_LIMIT) {
346
+ return Response.json({ error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." }, { status: 429 });
347
+ }
348
+ recent.push(now);
349
+ askRateLimit.set(clientIp, recent);
350
+
351
+ try {
352
+ const body = await req.json();
353
+ const { selected_text, question, page_slug, page_id } = body;
354
+
355
+ if (!selected_text || !page_slug) {
356
+ return Response.json({ error: "선택한 텍스트가 필요합니다" }, { status: 400 });
357
+ }
358
+
359
+ const parentPage = store.getPage(page_slug);
360
+ if (!parentPage) {
361
+ return Response.json({ error: "페이지를 찾을 수 없습니다" }, { status: 404 });
362
+ }
363
+
364
+ // Auto-generate question from selected text if not provided
365
+ const autoQuestion = question || `"${selected_text.slice(0, 100)}" 개념을 자세히 설명해주세요`;
366
+
367
+ // Run generation in background, return task ID immediately
368
+ const taskId = crypto.randomUUID();
369
+ bgTasks.set(taskId, { status: 'processing' });
370
+
371
+ (async () => {
372
+ try {
373
+ const currentConfig = loadConfig(root);
374
+ const persona = getActivePersona(currentConfig);
375
+ const { LLMClient } = await import("./llm-client");
376
+ const llmClient = new LLMClient(currentConfig.llm);
377
+
378
+ const { generateDynamicPage } = await import("./services/dynamic-qa");
379
+ const result = await generateDynamicPage(store, llmClient, persona, parentPage, selected_text, autoQuestion);
380
+
381
+ // Hot-render the new page + re-render parent page
382
+ const { buildSinglePage } = await import("./build/renderer");
383
+ await buildSinglePage(root, store, result.slug);
384
+ await buildSinglePage(root, store, page_slug);
385
+
386
+ // Check auto_promote config
387
+ const qaConfig = currentConfig.qa;
388
+ if (qaConfig?.auto_promote && result.isPromotable) {
389
+ // Auto-promote: create permanent wiki page with quizzes
390
+ try {
391
+ const { promoteToWiki } = await import("./services/promote");
392
+ const promoteResult = await promoteToWiki(store, {
393
+ question: autoQuestion,
394
+ answer: result.content,
395
+ title: result.suggestedTitle,
396
+ sourcePageId: parentPage.id,
397
+ selectedText: selected_text,
398
+ }, currentConfig.llm);
399
+
400
+ // Hot-render the promoted page
401
+ await buildSinglePage(root, store, promoteResult.slug);
402
+
403
+ console.log(`\x1b[32m✅ Auto-promoted: ${result.title}\x1b[0m`);
404
+ } catch (promoteErr) {
405
+ console.log(`\x1b[33m⚠ Auto-promote failed: ${promoteErr}\x1b[0m`);
406
+ }
407
+ }
408
+
409
+ bgTasks.set(taskId, {
410
+ status: 'completed',
411
+ result: {
412
+ ok: true,
413
+ slug: result.slug,
414
+ title: result.title,
415
+ url: `/wiki/${result.slug}.html`,
416
+ isPromotable: result.isPromotable,
417
+ suggestedTitle: result.suggestedTitle,
418
+ keyConcepts: result.keyConcepts,
419
+ sourcePageId: parentPage.id,
420
+ content: result.content,
421
+ question: autoQuestion,
422
+ selectedText: selected_text,
423
+ }
424
+ });
425
+ } catch (e: unknown) {
426
+ const message = e instanceof Error ? e.message : String(e);
427
+ bgTasks.set(taskId, { status: 'error', error: message });
428
+ }
429
+
430
+ // Clean up task after 5 minutes
431
+ setTimeout(() => bgTasks.delete(taskId), 5 * 60 * 1000);
432
+ })();
433
+
434
+ return Response.json({ task_id: taskId, message: "생성 시작" });
435
+ } catch (e: unknown) {
436
+ const message = e instanceof Error ? e.message : String(e);
437
+ return Response.json({ error: message }, { status: 500 });
438
+ }
439
+ }
440
+
441
+ // Background task status polling for dynamic Q&A
442
+ if (url.pathname === "/api/ask/status" && req.method === "GET") {
443
+ const taskId = url.searchParams.get("task_id");
444
+ if (!taskId) {
445
+ return Response.json({ error: "task_id가 필요합니다" }, { status: 400 });
446
+ }
447
+ const task = bgTasks.get(taskId);
448
+ if (!task) {
449
+ return Response.json({ error: "작업을 찾을 수 없습니다" }, { status: 404 });
450
+ }
451
+ return Response.json(task);
452
+ }
453
+
454
+ // ── Promote Q&A answer to permanent wiki page ──
455
+ if (url.pathname === "/api/promote" && req.method === "POST") {
456
+ try {
457
+ const body = await req.json() as {
458
+ question: string;
459
+ answer: string;
460
+ title: string;
461
+ sourcePageId: number;
462
+ selectedText?: string;
463
+ };
464
+
465
+ if (!body.question || !body.answer || !body.title || !body.sourcePageId) {
466
+ return Response.json({ error: "question, answer, title, sourcePageId가 필요합니다" }, { status: 400 });
467
+ }
468
+
469
+ if (body.title.length > 200) {
470
+ return Response.json({ error: "title은 200자 이하여야 합니다" }, { status: 400 });
471
+ }
472
+ if (body.question.length > 2000) {
473
+ return Response.json({ error: "question은 2000자 이하여야 합니다" }, { status: 400 });
474
+ }
475
+ if (body.answer.length > 50000) {
476
+ return Response.json({ error: "answer는 50000자 이하여야 합니다" }, { status: 400 });
477
+ }
478
+
479
+ const sourcePage = store.getPageById(body.sourcePageId);
480
+ if (!sourcePage) {
481
+ return Response.json({ error: "원본 페이지를 찾을 수 없습니다" }, { status: 404 });
482
+ }
483
+
484
+ const currentConfig = loadConfig(root);
485
+ const { promoteToWiki } = await import("./services/promote");
486
+ const result = await promoteToWiki(store, body, currentConfig.llm);
487
+
488
+ // Hot-render the affected pages
489
+ const { buildSinglePage } = await import("./build/renderer");
490
+ await buildSinglePage(root, store, result.slug);
491
+ await buildSinglePage(root, store, sourcePage.slug);
492
+
493
+ return Response.json({
494
+ ok: true,
495
+ slug: result.slug,
496
+ title: result.title,
497
+ url: `/wiki/${result.slug}.html`,
498
+ updated: !result.isNew,
499
+ message: result.isNew ? "새 위키 페이지가 생성되었습니다" : "기존 페이지에 내용이 추가되었습니다",
500
+ });
501
+ } catch (e: unknown) {
502
+ const message = e instanceof Error ? e.message : String(e);
503
+ return Response.json({ error: message }, { status: 500 });
504
+ }
505
+ }
506
+
507
+ if (url.pathname === "/api/search" && req.method === "GET") {
508
+ const query = url.searchParams.get("q")?.trim();
509
+ if (!query || query.length < 2) {
510
+ return Response.json({ results: [] });
511
+ }
512
+
513
+ // Try semantic search first (if embeddings exist)
514
+ try {
515
+ const searchConfig = loadConfig(root);
516
+ // Use embedding config if available, fall back to llm config
517
+ const embeddingLlmConfig = searchConfig.embedding
518
+ ? { ...searchConfig.llm, provider: searchConfig.embedding.provider, api_key: searchConfig.embedding.api_key }
519
+ : searchConfig.llm;
520
+ if (embeddingLlmConfig.api_key) {
521
+ const { semanticSearch } = await import("./services/embedding");
522
+ const semanticResults = await semanticSearch(query, store, embeddingLlmConfig, 5);
523
+ if (semanticResults.length > 0) {
524
+ return Response.json({
525
+ results: semanticResults.map(r => ({
526
+ slug: r.slug,
527
+ title: r.title,
528
+ page_type: r.pageType,
529
+ origin: r.origin,
530
+ preview: '',
531
+ similarity: r.similarity
532
+ })),
533
+ method: 'semantic'
534
+ });
535
+ }
536
+ }
537
+ } catch {
538
+ // Fall through to FTS/LIKE search
539
+ }
540
+
541
+ // Fallback: FTS5 / LIKE search
542
+ const results = store.searchPages(query, 5);
543
+ return Response.json({ results, method: 'fts' });
544
+ }
545
+
546
+ if (url.pathname === "/api/lint" && req.method === "GET") {
547
+ try {
548
+ const { lintWiki } = await import("./services/lint");
549
+ const report = lintWiki(store);
550
+ return Response.json(report);
551
+ } catch (e: unknown) {
552
+ const message = e instanceof Error ? e.message : String(e);
553
+ return Response.json({ error: message }, { status: 500 });
554
+ }
555
+ }
556
+
557
+ // Content index API
558
+ if (url.pathname === "/api/index" && req.method === "GET") {
559
+ const refresh = url.searchParams.get("refresh") === "true";
560
+ const currentPageCount = store.countPages();
561
+
562
+ if (!refresh && cachedIndex && cachedIndex.pageCount === currentPageCount) {
563
+ return Response.json(cachedIndex.data);
564
+ }
565
+
566
+ const { generateContentIndex } = await import("./services/index-generator");
567
+ const useLLM = url.searchParams.get("llm") === "true";
568
+ const currentConfig = loadConfig(root);
569
+ const indexData = await generateContentIndex(store, {
570
+ useLLM,
571
+ llmConfig: currentConfig.llm,
572
+ });
573
+
574
+ cachedIndex = { data: indexData, pageCount: currentPageCount };
575
+ return Response.json(indexData);
284
576
  }
285
577
 
286
578
  if (url.pathname === "/api/status") {
287
- const store = new Store(join(root, DB_FILE));
288
579
  const sources = store.listSourcesMeta();
289
580
  const sourcePages = store.listSourcePages();
290
581
  const conceptPages = store.listConceptPages();
291
- const links = store.getAllLinks();
582
+ const linkCount = store.countLinks();
292
583
  const usage = store.getUsageSummary();
293
- store.close();
294
584
 
295
585
  return Response.json({
296
586
  processing: isProcessing,
@@ -298,13 +588,140 @@ export function startServer(root: string, port: number, host: string): void {
298
588
  sources: sources.length,
299
589
  sourcePages: sourcePages.length,
300
590
  conceptPages: conceptPages.length,
301
- links: links.length,
591
+ links: linkCount,
302
592
  usage,
303
593
  });
304
594
  }
305
595
 
596
+ // Page edit endpoint
597
+ if (url.pathname === "/api/page/edit" && req.method === "POST") {
598
+ try {
599
+ const { slug, content } = await req.json() as { slug: string; content: string };
600
+ if (!slug || !content) {
601
+ return Response.json({ error: "slug과 content가 필요합니다" }, { status: 400 });
602
+ }
603
+
604
+ const page = store.getPage(slug);
605
+ if (!page) {
606
+ return Response.json({ error: "페이지를 찾을 수 없습니다" }, { status: 404 });
607
+ }
608
+
609
+ // Update page content in DB
610
+ store.updatePageContentBySlug(slug, content);
611
+
612
+ // Hot-render the updated page
613
+ const { buildSinglePage } = await import("./build/renderer");
614
+ await buildSinglePage(root, store, slug);
615
+
616
+ return Response.json({ ok: true, slug });
617
+ } catch (e: unknown) {
618
+ const message = e instanceof Error ? e.message : String(e);
619
+ return Response.json({ error: message }, { status: 500 });
620
+ }
621
+ }
622
+
623
+ // Citation endpoints
624
+ if (url.pathname.match(/^\/api\/pages\/(\d+)\/citations$/) && req.method === "GET") {
625
+ const pageId = parseInt(url.pathname.split("/")[3]);
626
+ if (isNaN(pageId)) return Response.json({ error: "잘못된 ID입니다" }, { status: 400 });
627
+ const citations = store.getCitationsForPage(pageId);
628
+ return Response.json({ citations });
629
+ }
630
+
631
+ if (url.pathname.match(/^\/api\/sources\/(\d+)\/citations$/) && req.method === "GET") {
632
+ const sourceId = parseInt(url.pathname.split("/")[3]);
633
+ if (isNaN(sourceId)) return Response.json({ error: "잘못된 ID입니다" }, { status: 400 });
634
+ const citations = store.getCitationsForSource(sourceId);
635
+ return Response.json({ citations });
636
+ }
637
+
638
+ if (url.pathname === "/api/provenance" && req.method === "GET") {
639
+ const coverage = store.getSourceCoverage();
640
+ return Response.json({ coverage });
641
+ }
642
+
643
+ // Get page raw content endpoint. ?format=html also returns the rendered
644
+ // article HTML fragment (used by the peek panel client-side).
645
+ if (url.pathname.startsWith("/api/page/") && req.method === "GET") {
646
+ const slug = url.pathname.replace("/api/page/", "");
647
+ const page = store.getPage(decodeURIComponent(slug));
648
+ if (!page) return Response.json({ error: "찾을 수 없습니다" }, { status: 404 });
649
+ const body: Record<string, unknown> = {
650
+ slug: page.slug,
651
+ title: page.title,
652
+ content: page.content,
653
+ origin: page.origin,
654
+ };
655
+ if (url.searchParams.get("format") === "html") {
656
+ const { renderPageContent } = await import("./build/renderer");
657
+ const allSlugs = new Set(store.listPages().map(p => p.slug));
658
+ body.html = await renderPageContent(page, allSlugs);
659
+ }
660
+ return Response.json(body);
661
+ }
662
+
663
+ // Activity log API
664
+ if (url.pathname === "/api/activity" && req.method === "GET") {
665
+ const limit = Math.min(Math.max(parseInt(url.searchParams.get("limit") || "50") || 50, 1), 200);
666
+ const offset = Math.max(parseInt(url.searchParams.get("offset") || "0") || 0, 0);
667
+ const action = url.searchParams.get("action") || undefined;
668
+ const entries = store.getActivityLog(limit, offset, action);
669
+ const stats = store.getActivityStats();
670
+ return Response.json({ entries, total: stats.total });
671
+ }
672
+
673
+ // Activity log page
674
+ if (url.pathname === "/activity") {
675
+ const stats = store.getActivityStats();
676
+ const html = renderActivityPage(authToken, config.project.name, stats);
677
+ return new Response(html, { headers: { "Content-Type": "text/html" } });
678
+ }
679
+
680
+ // Provenance page (no auth required — it's a public view page)
681
+ if (url.pathname === "/provenance") {
682
+ const coverage = store.getSourceCoverage();
683
+ const sources = store.listSourcesMeta();
684
+ const conceptPages = store.listConceptPages();
685
+ const sourcePages = store.listSourcePages();
686
+ const configData = loadConfig(root);
687
+
688
+ // Build citation matrix: for each source, which pages cite it
689
+ const matrix: Array<{
690
+ sourceId: number;
691
+ sourceTitle: string;
692
+ citationCount: number;
693
+ pageCount: number;
694
+ pages: Array<{ title: string; slug: string }>;
695
+ }> = [];
696
+
697
+ for (const cov of coverage) {
698
+ const citations = store.getCitationsForSource(cov.sourceId);
699
+ const pageMap = new Map<number, { title: string; slug: string }>();
700
+ for (const c of citations) {
701
+ if (c.page_title && c.page_slug && !pageMap.has(c.page_id)) {
702
+ pageMap.set(c.page_id, { title: c.page_title, slug: c.page_slug });
703
+ }
704
+ }
705
+ matrix.push({
706
+ sourceId: cov.sourceId,
707
+ sourceTitle: cov.sourceTitle,
708
+ citationCount: cov.citationCount,
709
+ pageCount: cov.pageCount,
710
+ pages: Array.from(pageMap.values()),
711
+ });
712
+ }
713
+
714
+ const { renderProvenancePage } = await import("./build/templates");
715
+ return new Response(renderProvenancePage({
716
+ wikiName: configData.project.name,
717
+ coverage: matrix,
718
+ sourcePages: sourcePages.map(p => ({ slug: p.slug, title: p.title })),
719
+ conceptPages: conceptPages.map(p => ({ slug: p.slug, title: p.title })),
720
+ }), { headers: { "Content-Type": "text/html" } });
721
+ }
722
+
306
723
  // ── Static file serving ──
307
- let pathname = url.pathname;
724
+ let pathname = decodeURIComponent(url.pathname);
308
725
  if (pathname === "/") pathname = "/index.html";
309
726
 
310
727
  const resolved = path.resolve(join(siteDir, pathname));
@@ -315,7 +732,22 @@ export function startServer(root: string, port: number, host: string): void {
315
732
 
316
733
  if (await staticFile.exists()) {
317
734
  const isHtml = pathname.endsWith(".html");
318
- const cspValue = "default-src 'self'; script-src 'self' 'unsafe-inline' cdn.jsdelivr.net d3js.org; style-src 'self' 'unsafe-inline' cdn.jsdelivr.net fonts.googleapis.com; font-src fonts.gstatic.com; img-src * data:; connect-src 'self'";
735
+ const cspValue = "default-src 'self'; script-src 'self' 'unsafe-inline' cdn.jsdelivr.net d3js.org static.cloudflareinsights.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; font-src https://fonts.gstatic.com https://*.gstatic.com data:; img-src * data:; connect-src 'self' cloudflareinsights.com";
736
+ if (isHtml) {
737
+ const queryToken = url.searchParams.get("token");
738
+ const isAuthed = isAuthenticated(req, url);
739
+ if (isAuthed) {
740
+ let html = await staticFile.text();
741
+ if (!html.includes('kiwi-auth')) {
742
+ html = html.replace('</head>', `<meta name="kiwi-auth" content="${authToken}"></head>`);
743
+ }
744
+ const headers: Record<string, string> = { "Content-Type": "text/html", "Content-Security-Policy": cspValue };
745
+ // If token came in via query, persist it as a cookie so the user
746
+ // doesn't need to re-paste ?token=… on every navigation.
747
+ if (queryToken === authToken) headers["Set-Cookie"] = authCookieHeader();
748
+ return new Response(html, { headers });
749
+ }
750
+ }
319
751
  if (isHtml) {
320
752
  return new Response(staticFile, { headers: { "Content-Type": "text/html", "Content-Security-Policy": cspValue } });
321
753
  }
@@ -325,3 +757,4 @@ export function startServer(root: string, port: number, host: string): void {
325
757
  },
326
758
  });
327
759
  }
760
+