Просмотр исходного кода

fix(studio): restore testable server guardrails and missing i18n

Ma 7 месяцев назад
Родитель
Сommit
2aeb4d2fb7

+ 4 - 1
packages/studio/package.json

@@ -8,6 +8,7 @@
     "dev": "vite",
     "build": "vite build",
     "preview": "vite preview",
+    "test": "vitest run",
     "typecheck": "tsc --noEmit"
   },
   "dependencies": {
@@ -28,12 +29,14 @@
   },
   "devDependencies": {
     "@tailwindcss/vite": "^4.0.0",
+    "@types/node": "^22.0.0",
     "@types/react": "^19.0.0",
     "@types/react-dom": "^19.0.0",
     "@vitejs/plugin-react": "^4.4.0",
     "autoprefixer": "^10.4.0",
     "tailwindcss": "^4.0.0",
     "typescript": "^5.8.0",
-    "vite": "^6.0.0"
+    "vite": "^6.0.0",
+    "vitest": "^3.0.0"
   }
 }

+ 10 - 0
packages/studio/src/api/errors.ts

@@ -0,0 +1,10 @@
+export class ApiError extends Error {
+  constructor(
+    readonly status: number,
+    readonly code: string,
+    message: string,
+  ) {
+    super(message);
+    this.name = "ApiError";
+  }
+}

+ 9 - 0
packages/studio/src/api/safety.ts

@@ -0,0 +1,9 @@
+const FORBIDDEN_PATH_CHARS = /[/\\\0]/;
+
+export function isSafeBookId(bookId: string): boolean {
+  if (!bookId) return false;
+  if (bookId.trim() !== bookId) return false;
+  if (bookId === "." || bookId === "..") return false;
+  if (bookId.includes("..")) return false;
+  return !FORBIDDEN_PATH_CHARS.test(bookId);
+}

+ 17 - 0
packages/studio/src/api/server.test.ts

@@ -138,4 +138,21 @@ describe("createStudioServer daemon lifecycle", () => {
 
     resolveStart?.();
   });
+
+  it("rejects book routes with path traversal ids", async () => {
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(projectConfig as never, root);
+
+    const response = await app.request("http://localhost/api/books/..%2Fetc%2Fpasswd", {
+      method: "GET",
+    });
+
+    expect(response.status).toBe(400);
+    await expect(response.json()).resolves.toEqual({
+      error: {
+        code: "INVALID_BOOK_ID",
+        message: 'Invalid book ID: "../etc/passwd"',
+      },
+    });
+  });
 });

+ 34 - 0
packages/studio/src/hooks/use-i18n.ts

@@ -7,6 +7,8 @@ const strings = {
   "nav.books": { zh: "书籍", en: "Books" },
   "nav.newBook": { zh: "新建书籍", en: "New Book" },
   "nav.config": { zh: "配置", en: "Config" },
+  "nav.daemon": { zh: "守护进程", en: "Daemon" },
+  "nav.style": { zh: "风格分析", en: "Style" },
   "nav.connected": { zh: "已连接", en: "Connected" },
   "nav.disconnected": { zh: "未连接", en: "Disconnected" },
 
@@ -43,6 +45,7 @@ const strings = {
   "create.bookTitle": { zh: "书名", en: "Title" },
   "create.language": { zh: "语言", en: "Language" },
   "create.genre": { zh: "题材", en: "Genre" },
+  "create.platform": { zh: "平台", en: "Platform" },
   "create.wordsPerChapter": { zh: "每章字数", en: "Words / Chapter" },
   "create.targetChapters": { zh: "目标章数", en: "Target Chapters" },
   "create.creating": { zh: "创建中...", en: "Creating..." },
@@ -72,6 +75,37 @@ const strings = {
   "config.provider": { zh: "提供方", en: "Provider" },
   "config.model": { zh: "模型", en: "Model" },
   "config.editHint": { zh: "通过 CLI 编辑配置:", en: "Edit via CLI:" },
+
+  // Daemon
+  "daemon.title": { zh: "守护进程", en: "Daemon" },
+  "daemon.running": { zh: "运行中", en: "Running" },
+  "daemon.stopped": { zh: "已停止", en: "Stopped" },
+  "daemon.start": { zh: "启动", en: "Start" },
+  "daemon.starting": { zh: "启动中...", en: "Starting..." },
+  "daemon.stop": { zh: "停止", en: "Stop" },
+  "daemon.stopping": { zh: "停止中...", en: "Stopping..." },
+  "daemon.waitingEvents": { zh: "等待守护进程事件...", en: "Waiting for daemon events..." },
+  "daemon.startHint": { zh: "启动守护进程后,这里会显示事件日志。", en: "Start the daemon to see event logs here." },
+
+  // Style
+  "style.title": { zh: "风格分析", en: "Style Analysis" },
+  "style.sourceName": { zh: "来源名称", en: "Source Name" },
+  "style.sourceExample": { zh: "例如:金庸、畅销样文、个人草稿", en: "e.g. Jin Yong, bestseller sample, personal draft" },
+  "style.textSample": { zh: "文本样本", en: "Text Sample" },
+  "style.pasteHint": { zh: "粘贴一段能代表目标风格的文本...", en: "Paste a representative writing sample..." },
+  "style.analyze": { zh: "分析风格", en: "Analyze Style" },
+  "style.analyzing": { zh: "分析中...", en: "Analyzing..." },
+  "style.results": { zh: "分析结果", en: "Results" },
+  "style.avgSentence": { zh: "平均句长", en: "Avg Sentence" },
+  "style.vocabDiversity": { zh: "词汇多样性", en: "Vocab Diversity" },
+  "style.avgParagraph": { zh: "平均段长", en: "Avg Paragraph" },
+  "style.sentenceStdDev": { zh: "句长波动", en: "Sentence Std Dev" },
+  "style.topPatterns": { zh: "高频模式", en: "Top Patterns" },
+  "style.rhetoricalFeatures": { zh: "修辞特征", en: "Rhetorical Features" },
+  "style.importToBook": { zh: "导入到书籍", en: "Import to Book" },
+  "style.selectBook": { zh: "选择书籍", en: "Select Book" },
+  "style.importGuide": { zh: "导入风格指南", en: "Import Style Guide" },
+  "style.emptyHint": { zh: "输入样本文本后即可分析写作风格。", en: "Paste a sample to analyze the writing style." },
 } as const;
 
 export type StringKey = keyof typeof strings;

+ 4 - 1
packages/studio/tsconfig.json

@@ -12,8 +12,11 @@
     "resolveJsonModule": true,
     "isolatedModules": true,
     "noEmit": true,
+    "types": ["node", "vitest/globals"],
     "paths": {
-      "@/*": ["./src/*"]
+      "@/*": ["./src/*"],
+      "@actalk/inkos-core": ["../core/src/index.ts"],
+      "@actalk/inkos-core/*": ["../core/src/*"]
     },
     "baseUrl": "."
   },

+ 6 - 0
pnpm-lock.yaml

@@ -112,6 +112,9 @@ importers:
       '@tailwindcss/vite':
         specifier: ^4.0.0
         version: 4.2.1(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1))
+      '@types/node':
+        specifier: ^22.0.0
+        version: 22.19.15
       '@types/react':
         specifier: ^19.0.0
         version: 19.2.14
@@ -133,6 +136,9 @@ importers:
       vite:
         specifier: ^6.0.0
         version: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)
+      vitest:
+        specifier: ^3.0.0
+        version: 3.2.4(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.12.13(@types/node@22.19.15)(typescript@5.9.3))
 
 packages: