@allbluecn/web-app 0.4.7 → 0.4.9

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 (38) hide show
  1. package/package.json +9 -8
  2. package/src/components/chat/ai-chat-history/components/conversation-item.tsx +1 -1
  3. package/src/components/chat/ai-chat-history/index.tsx +1 -1
  4. package/src/components/knowledge/editor/knowledge-doc-editor.tsx +4 -4
  5. package/src/components/knowledge/knowledge-settings-panel.tsx +2 -2
  6. package/src/components/knowledge/knowledge-toolbar.tsx +4 -4
  7. package/src/components/knowledge/knowledge-tree-item.tsx +3 -3
  8. package/src/components/knowledge/knowledge-tree.tsx +2 -2
  9. package/src/components/settings/personal-team/account/account-client.tsx +1 -1
  10. package/src/components/story/detail/activity-timeline.tsx +7 -6
  11. package/src/lib/anyhub/manager.ts +17 -4
  12. package/src/lib/auth/auth.config.ts +2 -2
  13. package/src/lib/auth/sso-authorize.ts +1 -1
  14. package/src/lib/sanitize.ts +5 -0
  15. package/src/routes/_nav/chat/route.lazy.tsx +1 -1
  16. package/src/routes/_nav/design/index.lazy.tsx +1 -1
  17. package/src/routes/_nav/settings/ai/route.tsx +1 -1
  18. package/src/routes/api/design/upload.ts +9 -1
  19. package/src/routes/design-files/$projectId/$artboardId/$filename.ts +6 -2
  20. package/src/routes/server.ai-stream.ts +1 -1
  21. package/src/server/audit.ts +1 -1
  22. package/src/server/keepalive/middleware.ts +1 -1
  23. package/src/server/knowledge-public/github-sync.ts +21 -3
  24. package/src/server/middlewares/audit-recorder.ts +1 -1
  25. package/src/server/middlewares/rate-limit.ts +27 -0
  26. package/src/server/references/engine.ts +11 -8
  27. package/src/server/serverFns/auth/forgot-password.ts +3 -1
  28. package/src/server/serverFns/auth/invitation.ts +1 -0
  29. package/src/server/serverFns/auth/register.ts +2 -0
  30. package/src/server/serverFns/auth/reset-password.ts +2 -0
  31. package/src/server/serverFns/auth/session.ts +2 -1
  32. package/src/server/serverFns/billing.ts +1 -1
  33. package/src/server/serverFns/compliance.ts +3 -2
  34. package/src/server/serverFns/design/settings.ts +1 -0
  35. package/src/server/serverFns/team.ts +1 -1
  36. package/src/server/sso/tokens.ts +18 -3
  37. package/src/start.ts +2 -2
  38. package/vite.shared.ts +3 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allbluecn/web-app",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "license": "AGPL-3.0-or-later",
5
5
  "type": "module",
6
6
  "files": [
@@ -102,6 +102,7 @@
102
102
  "i18next-browser-languagedetector": "^8.2.1",
103
103
  "ioredis": "^5.11.0",
104
104
  "is-hotkey": "^0.2.0",
105
+ "isomorphic-dompurify": "^3.22.0",
105
106
  "lowlight": "^3.3.0",
106
107
  "lucide-react": "^1.24.0",
107
108
  "mind-elixir": "^5.12.2",
@@ -130,14 +131,14 @@
130
131
  "three": "^0.180.0",
131
132
  "tw-animate-css": "^1.4.0",
132
133
  "zod": "^4.4.3",
133
- "@allbluecn/ai-gateway": "0.4.7",
134
- "@allbluecn/database": "0.4.7",
135
- "@allbluecn/prototype": "0.1.1",
136
- "@allbluecn/shared": "0.4.7",
134
+ "@allbluecn/database": "0.4.9",
137
135
  "@allbluecn/kernel": "0.3.0",
138
- "@allbluecn/plugins-core": "0.4.7",
139
- "@allbluecn/tiptap": "0.4.7",
140
- "@allbluecn/ui": "0.4.7"
136
+ "@allbluecn/ai-gateway": "0.4.9",
137
+ "@allbluecn/plugins-core": "0.4.9",
138
+ "@allbluecn/prototype": "0.1.1",
139
+ "@allbluecn/tiptap": "0.4.9",
140
+ "@allbluecn/shared": "0.4.9",
141
+ "@allbluecn/ui": "0.4.9"
141
142
  },
142
143
  "devDependencies": {
143
144
  "@babel/parser": "^8.0.4",
@@ -102,7 +102,7 @@ export function ConversationItem({
102
102
  await onDelete(conversation.id);
103
103
  setShowDeleteDialog(false);
104
104
  } catch (error) {
105
- console.error("Failed to delete conversation:", error);
105
+ console.error("Failed to delete conversation:", error instanceof Error ? error.message : String(error));
106
106
  } finally {
107
107
  setIsDeleting(false);
108
108
  }
@@ -107,7 +107,7 @@ export default function AIChatHistory({
107
107
  await onRename(conversationId, trimmedValue);
108
108
  setEditingId(null);
109
109
  } catch (error) {
110
- console.error("Failed to rename conversation:", error);
110
+ console.error("Failed to rename conversation:", error instanceof Error ? error.message : String(error));
111
111
  } finally {
112
112
  setLoadingStates((prev) => {
113
113
  const next = { ...prev };
@@ -139,7 +139,7 @@ function KnowledgeDocEditorBody({ document, editorWidth = 'auto', kbName, proper
139
139
  setDirty(false)
140
140
  setLastSavedAt(new Date())
141
141
  } catch (e) {
142
- console.error('Auto-save failed:', e)
142
+ console.error('Auto-save failed:', e instanceof Error ? e.message : String(e))
143
143
  } finally {
144
144
  setSaving(false)
145
145
  }
@@ -176,7 +176,7 @@ function KnowledgeDocEditorBody({ document, editorWidth = 'auto', kbName, proper
176
176
  await saver(newTitle)
177
177
  onDocTitleChanged()
178
178
  } catch (e) {
179
- console.error('Title update failed:', e)
179
+ console.error('Title update failed:', e instanceof Error ? e.message : String(e))
180
180
  }
181
181
  }, [onDocTitleChanged, saveHandlers])
182
182
 
@@ -186,7 +186,7 @@ function KnowledgeDocEditorBody({ document, editorWidth = 'auto', kbName, proper
186
186
  const saver = saveHandlers?.tags ?? ((t: string[]) => updateDocumentFn({ data: { id: docId.current, tags: t } }))
187
187
  await saver(newTags)
188
188
  } catch (e) {
189
- console.error('Tags update failed:', e)
189
+ console.error('Tags update failed:', e instanceof Error ? e.message : String(e))
190
190
  }
191
191
  }, [saveHandlers])
192
192
 
@@ -198,7 +198,7 @@ function KnowledgeDocEditorBody({ document, editorWidth = 'auto', kbName, proper
198
198
  () => {
199
199
  if (saveHandlers?.properties && latestPropertyValues.current) {
200
200
  saveHandlers.properties(latestPropertyValues.current)
201
- .catch((e) => console.error('Properties update failed:', e))
201
+ .catch((e) => console.error('Properties update failed:', e instanceof Error ? e.message : String(e)))
202
202
  }
203
203
  },
204
204
  { wait: EDITOR_AUTO_SAVE_MS },
@@ -66,7 +66,7 @@ export function KnowledgeSettingsPanel({ open, onOpenChange, kb, onUpdate }: Kno
66
66
  await updateKnowledgeFn({ data: { id: kb.id, visibility: v } })
67
67
  onUpdate?.()
68
68
  } catch (e) {
69
- console.error('Update visibility failed:', e)
69
+ console.error('Update visibility failed:', e instanceof Error ? e.message : String(e))
70
70
  }
71
71
  setSaving(false)
72
72
  }
@@ -78,7 +78,7 @@ export function KnowledgeSettingsPanel({ open, onOpenChange, kb, onUpdate }: Kno
78
78
  onOpenChange(false)
79
79
  navigate({ to: '/knowledge' })
80
80
  } catch (e) {
81
- console.error('Delete failed:', e)
81
+ console.error('Delete failed:', e instanceof Error ? e.message : String(e))
82
82
  }
83
83
  setDeleting(false)
84
84
  }
@@ -127,7 +127,7 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
127
127
  await updateKnowledgeFn({ data: { id: kb.id, name: name.trim() } })
128
128
  onUpdate?.()
129
129
  } catch (e) {
130
- console.error('Rename failed:', e)
130
+ console.error('Rename failed:', e instanceof Error ? e.message : String(e))
131
131
  }
132
132
  setEditingName(false)
133
133
  }
@@ -137,7 +137,7 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
137
137
  try {
138
138
  await updateKnowledgeFn({ data: { id: kb.id, tags } })
139
139
  } catch (e) {
140
- console.error('Update tags failed:', e)
140
+ console.error('Update tags failed:', e instanceof Error ? e.message : String(e))
141
141
  }
142
142
  }
143
143
 
@@ -148,7 +148,7 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
148
148
  await updateKnowledgeFn({ data: { id: kb.id, visibility: value as 'private' | 'team' } })
149
149
  onUpdate?.()
150
150
  } catch (e) {
151
- console.error('Update visibility failed:', e)
151
+ console.error('Update visibility failed:', e instanceof Error ? e.message : String(e))
152
152
  }
153
153
  }
154
154
 
@@ -161,7 +161,7 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
161
161
  window.dispatchEvent(new CustomEvent("knowledge-deleted"))
162
162
  navigate({ to: '/knowledge' })
163
163
  } catch (e) {
164
- console.error('Delete failed:', e)
164
+ console.error('Delete failed:', e instanceof Error ? e.message : String(e))
165
165
  }
166
166
  setDeleting(false)
167
167
  }
@@ -120,7 +120,7 @@ export function KnowledgeTreeItem({
120
120
  onRefresh()
121
121
  onDocRenamed?.(node.id)
122
122
  } catch (e) {
123
- console.error('Rename failed:', e)
123
+ console.error('Rename failed:', e instanceof Error ? e.message : String(e))
124
124
  }
125
125
  finishEditing()
126
126
  }
@@ -134,7 +134,7 @@ export function KnowledgeTreeItem({
134
134
  setDeleteConfirm('')
135
135
  onRefresh()
136
136
  } catch (e) {
137
- console.error('Delete failed:', e)
137
+ console.error('Delete failed:', e instanceof Error ? e.message : String(e))
138
138
  }
139
139
  setDeleting(false)
140
140
  }
@@ -156,7 +156,7 @@ export function KnowledgeTreeItem({
156
156
  onRefresh()
157
157
  onSelect(doc.id)
158
158
  } catch (e) {
159
- console.error('Create child failed:', e)
159
+ console.error('Create child failed:', e instanceof Error ? e.message : String(e))
160
160
  }
161
161
  setCreatingChild(false)
162
162
  }
@@ -107,7 +107,7 @@ export function KnowledgeTree({
107
107
  const docs = await listDocumentsFn({ data: { kbId } })
108
108
  setDocuments(docs as TreeNode[])
109
109
  } catch (e) {
110
- console.error('Failed to fetch documents:', e)
110
+ console.error('Failed to fetch documents:', e instanceof Error ? e.message : String(e))
111
111
  } finally {
112
112
  setLoading(false)
113
113
  }
@@ -155,7 +155,7 @@ export function KnowledgeTree({
155
155
  await fetchDocuments()
156
156
  onSelect(doc.id)
157
157
  } catch (e) {
158
- console.error('Create failed:', e)
158
+ console.error('Create failed:', e instanceof Error ? e.message : String(e))
159
159
  }
160
160
  setCreatingType(null)
161
161
  }
@@ -67,7 +67,7 @@ export default function AccountClient({
67
67
  old ? { ...old, user: { ...old.user, avatarConfig: config, bgShape: shape, bgColor: color } } : old,
68
68
  )
69
69
  } catch (err) {
70
- console.error("Failed to persist avatar:", err)
70
+ console.error("Failed to persist avatar:", err instanceof Error ? err.message : String(err))
71
71
  toast.error(t("profile.avatarSaveFailed"))
72
72
  }
73
73
  }, [queryClient])
@@ -35,6 +35,7 @@ import { listProjectsBriefFn } from "@/server/serverFns/project";
35
35
  import type { ActivityItem, UserBrief } from "@/components/story/types";
36
36
  import { groupActivities, type ActivityGroup } from "@/lib/story/activity-grouper";
37
37
  import { ArrowUpFromDot, ChevronDown, Dot, Sparkles, Layers, UserRound } from "lucide-react";
38
+ import { sanitize } from "@/lib/sanitize";
38
39
 
39
40
  function getDiffDecorator(
40
41
  action: string,
@@ -245,7 +246,7 @@ function DiffCompare({ oldValue, newValue, oldDecorator, newDecorator, plain, bl
245
246
  <div className="w-1/2 border-r bg-red-50/40 dark:bg-red-950/20 p-2 pr-4">
246
247
  <div className="flex items-center gap-1.5">
247
248
  {b.oldDecorator}
248
- <div className="break-all" dangerouslySetInnerHTML={{ __html: b.left }} />
249
+ <div className="break-all" dangerouslySetInnerHTML={{ __html: sanitize(b.left) }} />
249
250
  </div>
250
251
  </div>
251
252
  <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-10 flex size-5 items-center justify-center rounded-full bg-background border">
@@ -254,7 +255,7 @@ function DiffCompare({ oldValue, newValue, oldDecorator, newDecorator, plain, bl
254
255
  <div className="w-1/2 bg-green-50/40 dark:bg-green-950/20 p-2 pl-4">
255
256
  <div className="flex items-center gap-1.5">
256
257
  {b.newDecorator}
257
- <div className="break-all" dangerouslySetInnerHTML={{ __html: b.right }} />
258
+ <div className="break-all" dangerouslySetInnerHTML={{ __html: sanitize(b.right) }} />
258
259
  </div>
259
260
  </div>
260
261
  </div>
@@ -271,7 +272,7 @@ function DiffCompare({ oldValue, newValue, oldDecorator, newDecorator, plain, bl
271
272
  {/* 测量用:渲染所有块右侧文本以计算总高度 */}
272
273
  <div ref={measureRef} className="absolute left-0 top-0 -z-10 invisible w-1/2 p-2 pl-4 break-all space-y-2">
273
274
  {renderedBlocks.map((b, idx) => (
274
- <div key={idx} dangerouslySetInnerHTML={{ __html: b.right }} />
275
+ <div key={idx} dangerouslySetInnerHTML={{ __html: sanitize(b.right) }} />
275
276
  ))}
276
277
  </div>
277
278
  </div>
@@ -291,7 +292,7 @@ function DiffCompare({ oldValue, newValue, oldDecorator, newDecorator, plain, bl
291
292
  <div className="w-1/2 border-r bg-red-50/40 dark:bg-red-950/20 p-2 pr-4">
292
293
  <div className="flex items-center gap-1.5">
293
294
  {oldDecorator}
294
- <div className="break-all" dangerouslySetInnerHTML={{ __html: single!.left }} />
295
+ <div className="break-all" dangerouslySetInnerHTML={{ __html: sanitize(single!.left) }} />
295
296
  </div>
296
297
  </div>
297
298
  <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-10 flex size-5 items-center justify-center rounded-full bg-background border">
@@ -300,7 +301,7 @@ function DiffCompare({ oldValue, newValue, oldDecorator, newDecorator, plain, bl
300
301
  <div className="w-1/2 bg-green-50/40 dark:bg-green-950/20 p-2 pl-4">
301
302
  <div className="flex items-center gap-1.5">
302
303
  {newDecorator}
303
- <div className="break-all" dangerouslySetInnerHTML={{ __html: single!.right }} />
304
+ <div className="break-all" dangerouslySetInnerHTML={{ __html: sanitize(single!.right) }} />
304
305
  </div>
305
306
  </div>
306
307
  <div
@@ -311,7 +312,7 @@ function DiffCompare({ oldValue, newValue, oldDecorator, newDecorator, plain, bl
311
312
  transition: "opacity 0.25s ease",
312
313
  }}
313
314
  />
314
- <div ref={measureRef} className="absolute left-0 top-0 -z-10 invisible w-1/2 p-2 pl-4 break-all" dangerouslySetInnerHTML={{ __html: single!.right }} />
315
+ <div ref={measureRef} className="absolute left-0 top-0 -z-10 invisible w-1/2 p-2 pl-4 break-all" dangerouslySetInnerHTML={{ __html: sanitize(single!.right) }} />
315
316
  </div>
316
317
  );
317
318
  }
@@ -16,7 +16,7 @@
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
18
  import { prisma } from "@allbluecn/database"
19
- import { execSync } from "child_process"
19
+ import { execFileSync } from "child_process"
20
20
  import path from "path"
21
21
  import { getAnyHubBaseDir } from "./types"
22
22
  import type { AnyHubAppSource, AnyHubAppData } from "./types"
@@ -112,9 +112,17 @@ export const AnyHubManager = {
112
112
  let proxyBasePath: string | null = null
113
113
 
114
114
  if (isRelativeEntry) {
115
+ if (!/^[a-zA-Z0-9._-]+$/.test(manifest.name)) {
116
+ throw new Error("Invalid manifest name")
117
+ }
115
118
  const cloneDir = path.join(getAnyHubBaseDir(), "apps", manifest.name)
116
- execSync(`git clone ${githubUrl} ${cloneDir}`, { stdio: "pipe" })
117
- localPath = cloneDir
119
+ const resolvedClone = path.resolve(cloneDir)
120
+ const resolvedBase = path.resolve(getAnyHubBaseDir())
121
+ if (!resolvedClone.startsWith(resolvedBase + path.sep)) {
122
+ throw new Error("Clone directory outside allowed base path")
123
+ }
124
+ execFileSync("git", ["clone", "--depth", "1", githubUrl, resolvedClone], { stdio: "pipe" })
125
+ localPath = resolvedClone
118
126
  proxyBasePath = "/api/anyhub/proxy/"
119
127
  url = null
120
128
  }
@@ -177,7 +185,12 @@ export const AnyHubManager = {
177
185
  if (!app) throw new Error("应用不存在")
178
186
 
179
187
  if (app.source === "github" && app.localPath) {
180
- execSync(`cd ${app.localPath} && git pull`, { stdio: "pipe" })
188
+ const resolvedBase = path.resolve(getAnyHubBaseDir())
189
+ const resolvedLocalPath = path.resolve(app.localPath)
190
+ if (!resolvedLocalPath.startsWith(resolvedBase + path.sep)) {
191
+ throw new Error("Local path outside allowed base path")
192
+ }
193
+ execFileSync("git", ["pull"], { cwd: resolvedLocalPath, stdio: "pipe" })
181
194
  }
182
195
 
183
196
  const updated = await prisma.anyHubApp.update({
@@ -72,7 +72,7 @@ async function getCachedProfile(userId: string): Promise<CachedProfile | null> {
72
72
  // DB 异常(如 Prisma Client 未 generate、字段缺失、连接失败)不应导致整个 session 崩溃。
73
73
  // @auth/core 的 session action 会把 session callback 的任何异常误判为 JWTSessionError 并删除 cookie,
74
74
  // 导致用户被登出。此处降级返回最小有效 profile,保持用户登录态。
75
- console.error('[auth] getCachedProfile 查询失败,降级返回默认 profile:', e)
75
+ console.error('[auth] getCachedProfile 查询失败,降级返回默认 profile:', e instanceof Error ? e.message : String(e))
76
76
  return {
77
77
  username: '用户',
78
78
  avatarConfig: null,
@@ -163,7 +163,7 @@ export const authConfig: StartAuthJSConfig = {
163
163
  },
164
164
  })
165
165
  } catch (err) {
166
- console.error('[auth] 记录登录日志失败:', err)
166
+ console.error('[auth] 记录登录日志失败:', err instanceof Error ? err.message : String(err))
167
167
  }
168
168
  }
169
169
 
@@ -33,7 +33,7 @@ export async function resolveSsoTokenUser(ssoToken: string): Promise<SsoAuthUser
33
33
  },
34
34
  })
35
35
  } catch (err) {
36
- console.error('[auth] 记录 SSO 登录日志失败:', err)
36
+ console.error('[auth] 记录 SSO 登录日志失败:', err instanceof Error ? err.message : String(err))
37
37
  }
38
38
  }
39
39
 
@@ -0,0 +1,5 @@
1
+ import DOMPurify from 'isomorphic-dompurify'
2
+
3
+ export function sanitize(html: string): string {
4
+ return DOMPurify.sanitize(html)
5
+ }
@@ -145,7 +145,7 @@ function ChatPage() {
145
145
  clearHandoffState()
146
146
  applyConv(conv, pendingMsg)
147
147
  } catch (e) {
148
- console.error('[chat] Failed to process pending workspace message:', e)
148
+ console.error('[chat] Failed to process pending workspace message:', e instanceof Error ? e.message : String(e))
149
149
  clearHandoffState()
150
150
  }
151
151
  })()
@@ -116,7 +116,7 @@ function DesignDashboard() {
116
116
  setProjects((prev) => prev.filter((p) => p.id !== pendingDeleteId))
117
117
  setPendingDeleteId(null)
118
118
  } catch (error) {
119
- console.error("Failed to delete design project:", error)
119
+ console.error("Failed to delete design project:", error instanceof Error ? error.message : String(error))
120
120
  } finally {
121
121
  setDeleting(false)
122
122
  }
@@ -24,7 +24,7 @@ export const Route = createFileRoute("/_nav/settings/ai")({
24
24
  const providers = await listAiProvidersFn()
25
25
  return { aiProviders: providers }
26
26
  } catch (e) {
27
- console.error("[settings/ai] Failed to load provider list", e)
27
+ console.error("[settings/ai] Failed to load provider list", e instanceof Error ? e.message : String(e))
28
28
  return { aiProviders: [] }
29
29
  }
30
30
  },
@@ -52,7 +52,15 @@ export const Route = createFileRoute("/api/design/upload")({
52
52
  })
53
53
  }
54
54
 
55
- const annotation = parseAnnotationJson(JSON.parse(metadataStr))
55
+ let annotation: ReturnType<typeof parseAnnotationJson>
56
+ try {
57
+ annotation = parseAnnotationJson(JSON.parse(metadataStr))
58
+ } catch {
59
+ return new Response(JSON.stringify({ error: "Invalid JSON in meta field" }), {
60
+ status: 400,
61
+ headers: { "Content-Type": "application/json" },
62
+ })
63
+ }
56
64
  const docName = annotation.document.name || "Untitled"
57
65
 
58
66
  let project = await prisma.designProject.findFirst({
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { createFileRoute } from "@tanstack/react-router"
19
19
  import { readFile } from "node:fs/promises"
20
- import { join } from "node:path"
20
+ import { resolve, normalize } from "node:path"
21
21
  import { existsSync } from "node:fs"
22
22
  import { requireAuth } from "@/server/auth-helpers"
23
23
 
@@ -40,7 +40,11 @@ export const Route = createFileRoute("/design-files/$projectId/$artboardId/$file
40
40
  }
41
41
 
42
42
  const safeFilename = filename.replace(/\.\./g, "").replace(/\//g, "")
43
- const filePath = join(UPLOAD_ROOT, projectId, artboardId, safeFilename)
43
+ const baseDir = resolve(UPLOAD_ROOT, projectId, artboardId)
44
+ const filePath = normalize(resolve(baseDir, safeFilename))
45
+ if (!filePath.startsWith(resolve(baseDir))) {
46
+ throw new Error("Path traversal detected")
47
+ }
44
48
 
45
49
  if (!existsSync(filePath)) {
46
50
  return new Response("Not Found", { status: 404 })
@@ -103,7 +103,7 @@ export const Route = createFileRoute('/server/ai-stream')({
103
103
  },
104
104
  })
105
105
  } catch (err) {
106
- console.warn('[ai-stream] chat failed', err)
106
+ console.warn('[ai-stream] chat failed', err instanceof Error ? err.message : String(err))
107
107
  const fallback = JSON.stringify({
108
108
  type: 'fallback',
109
109
  content: 'AI service is temporarily unavailable. Please try again later.',
@@ -20,6 +20,6 @@ export async function auditLog(entry: AuditEntryInput): Promise<void> {
20
20
  userAgent: null,
21
21
  })
22
22
  } catch (err) {
23
- console.error('[audit] 写入失败:', err)
23
+ console.error('[audit] 写入失败:', err instanceof Error ? err.message : String(err))
24
24
  }
25
25
  }
@@ -35,7 +35,7 @@ export const neonKeepaliveMiddleware = createMiddleware().server(async ({ next }
35
35
  await prisma.$queryRawUnsafe('SELECT 1')
36
36
  ;(globalThis as any).__lastNeonKeepaliveAt = Date.now()
37
37
  } catch (e) {
38
- console.warn('[neon-keepalive] error', e)
38
+ console.warn('[neon-keepalive] error', e instanceof Error ? e.message : String(e))
39
39
  }
40
40
  })(),
41
41
  ])
@@ -33,18 +33,36 @@ const HEADERS: HeadersInit = {
33
33
  : {}),
34
34
  }
35
35
 
36
+ function isAllowedGitHubUrl(url: string): boolean {
37
+ try {
38
+ const parsed = new URL(url)
39
+ return ['github.com', 'www.github.com'].includes(parsed.hostname)
40
+ && ['https:', 'http:'].includes(parsed.protocol)
41
+ } catch {
42
+ return false
43
+ }
44
+ }
45
+
36
46
  export async function fetchGithubData(
37
47
  owner: string,
38
48
  repo: string,
39
49
  ): Promise<GithubData | null> {
40
- const repoRes = await fetch(`${API}/repos/${owner}/${repo}`, { headers: HEADERS })
50
+ const repoUrl = `${API}/repos/${owner}/${repo}`
51
+ if (!isAllowedGitHubUrl(repoUrl)) {
52
+ throw new Error('URL not allowed')
53
+ }
54
+ const repoRes = await fetch(repoUrl, { headers: HEADERS, redirect: 'error' })
41
55
  if (!repoRes.ok) return null
42
56
  const repoJson: any = await repoRes.json()
43
57
 
44
58
  let latestVersion: string | null = null
45
59
  let versionDate: Date | null = null
46
60
  try {
47
- const relRes = await fetch(`${API}/repos/${owner}/${repo}/releases/latest`, { headers: HEADERS })
61
+ const relUrl = `${API}/repos/${owner}/${repo}/releases/latest`
62
+ if (!isAllowedGitHubUrl(relUrl)) {
63
+ throw new Error('URL not allowed')
64
+ }
65
+ const relRes = await fetch(relUrl, { headers: HEADERS, redirect: 'error' })
48
66
  if (relRes.ok) {
49
67
  const relJson: any = await relRes.json()
50
68
  latestVersion = relJson.tag_name ?? null
@@ -104,7 +122,7 @@ export async function syncAllStaleGithubBookmarks(): Promise<number> {
104
122
  await syncBookmarkGithub(bm.id)
105
123
  count++
106
124
  } catch (e) {
107
- console.warn('[github-sync] failed for', bm.id, e)
125
+ console.warn('[github-sync] failed for', bm.id, e instanceof Error ? e.message : String(e))
108
126
  }
109
127
  }
110
128
  return count
@@ -32,6 +32,6 @@ export async function recordAuditEntry(
32
32
  userAgent: request.headers.get('user-agent'),
33
33
  })
34
34
  } catch (err) {
35
- console.error('[audit] middleware 写入失败:', err)
35
+ console.error('[audit] middleware 写入失败:', err instanceof Error ? err.message : String(err))
36
36
  }
37
37
  }
@@ -0,0 +1,27 @@
1
+ import { getRequest } from '@tanstack/react-start/server'
2
+
3
+ const store = new Map<string, { count: number; resetAt: number }>()
4
+
5
+ function check(key: string, max: number, windowMs: number): boolean {
6
+ const now = Date.now()
7
+ const entry = store.get(key)
8
+ if (!entry || now > entry.resetAt) {
9
+ store.set(key, { count: 1, resetAt: now + windowMs })
10
+ return true
11
+ }
12
+ if (entry.count >= max) return false
13
+ entry.count++
14
+ return true
15
+ }
16
+
17
+ export async function checkAuthRateLimit(
18
+ key: string,
19
+ max = 10,
20
+ windowMs = 15 * 60 * 1000,
21
+ ): Promise<void> {
22
+ const request = getRequest()
23
+ const ip = request?.headers?.get('x-forwarded-for') ?? 'unknown'
24
+ if (!check(`${key}:${ip}`, max, windowMs)) {
25
+ throw new Error('Too many requests')
26
+ }
27
+ }
@@ -33,7 +33,8 @@ async function resolveDisplay(
33
33
  if (!entity) return { title: `[${targetModule}:${targetEntityId}]` }
34
34
 
35
35
  try {
36
- const fields = [...entity.displayFields, "id"].join(", ")
36
+ const safeFields = [...entity.displayFields, "id"].filter(f => /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(f))
37
+ const fields = safeFields.join(", ")
37
38
  const rows = await prisma.$queryRawUnsafe<Record<string, unknown>[]>(
38
39
  `SELECT ${fields} FROM \`${entity.table}\` WHERE id = ?`,
39
40
  targetEntityId
@@ -80,13 +81,15 @@ export async function searchReferences(
80
81
  for (const config of filtered) {
81
82
  const mod = moduleRegistry.getModule(config.module)
82
83
  if (!mod) continue
83
- const searchField = config.fields[0]
84
- const { prisma: p } = await import("@allbluecn/database")
85
- try {
86
- const rows = await p.$queryRawUnsafe<Record<string, unknown>[]>(
87
- `SELECT id, ${config.fields.join(", ")} FROM \`${mod.entities[0].table}\` WHERE \`${searchField}\` LIKE ? LIMIT 20`,
88
- `%${query}%`
89
- )
84
+ const searchField = config.fields[0]
85
+ const safeSearchField = /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(searchField) ? searchField : "id"
86
+ const safeFields = config.fields.filter(f => /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(f))
87
+ const { prisma: p } = await import("@allbluecn/database")
88
+ try {
89
+ const rows = await p.$queryRawUnsafe<Record<string, unknown>[]>(
90
+ `SELECT id, ${safeFields.join(", ")} FROM \`${mod.entities[0].table}\` WHERE \`${safeSearchField}\` LIKE ? LIMIT 20`,
91
+ `%${query}%`
92
+ )
90
93
  for (const row of rows) {
91
94
  const display = mod.referenceDisplay(config.type, row)
92
95
  results.push({
@@ -19,6 +19,7 @@ import { createServerFn } from '@tanstack/react-start'
19
19
  import { z } from 'zod'
20
20
  import { prisma } from '@allbluecn/database'
21
21
  import { randomBytes } from 'crypto'
22
+ import { checkAuthRateLimit } from '@/server/middlewares/rate-limit'
22
23
 
23
24
  const forgotPasswordSchema = z.object({
24
25
  email: z.string().email('请输入有效邮箱'),
@@ -31,6 +32,7 @@ function generateToken(): string {
31
32
  export const forgotPasswordFn = createServerFn({ method: 'POST' })
32
33
  .validator(forgotPasswordSchema)
33
34
  .handler(async ({ data }) => {
35
+ await checkAuthRateLimit('forgotPassword', 5, 15 * 60 * 1000)
34
36
  const user = await prisma.user.findUnique({
35
37
  where: { email: data.email },
36
38
  select: { id: true },
@@ -45,7 +47,7 @@ export const forgotPasswordFn = createServerFn({ method: 'POST' })
45
47
  data: { token, userId: user.id, expiresAt },
46
48
  })
47
49
  } catch (dbError) {
48
- console.error('Database error:', dbError)
50
+ console.error('Database error:', dbError instanceof Error ? dbError.message : String(dbError))
49
51
  }
50
52
  }
51
53
 
@@ -23,6 +23,7 @@ import { requireAdmin } from '@/server/auth-helpers'
23
23
  const DEFAULT_CODE = '123123'
24
24
 
25
25
  export const createDefaultInvitationFn = createServerFn({ method: 'POST' })
26
+ .validator(z.object({}))
26
27
  .handler(async () => {
27
28
  const hasPermission = true
28
29
 
@@ -20,6 +20,7 @@ import { z } from 'zod'
20
20
  import { prisma } from '@allbluecn/database'
21
21
  import bcrypt from 'bcryptjs'
22
22
  import { getRandomConfig, presetBgColors } from '@/components/settings/personal-team/account/avatar/avatar-config'
23
+ import { checkAuthRateLimit } from '@/server/middlewares/rate-limit'
23
24
 
24
25
  const registerSchema = z.object({
25
26
  username: z.string().min(3).max(20),
@@ -31,6 +32,7 @@ const registerSchema = z.object({
31
32
  export const registerFn = createServerFn({ method: 'POST' })
32
33
  .validator(registerSchema)
33
34
  .handler(async ({ data }) => {
35
+ await checkAuthRateLimit('register', 5, 15 * 60 * 1000)
34
36
  const exists = await prisma.user.findFirst({
35
37
  where: { OR: [{ email: data.email }, { username: data.username }] },
36
38
  select: { id: true },
@@ -19,6 +19,7 @@ import { createServerFn } from '@tanstack/react-start'
19
19
  import { z } from 'zod'
20
20
  import { prisma } from '@allbluecn/database'
21
21
  import bcrypt from 'bcryptjs'
22
+ import { checkAuthRateLimit } from '@/server/middlewares/rate-limit'
22
23
 
23
24
  const resetPasswordSchema = z.object({
24
25
  token: z.string().min(1, 'Token is required'),
@@ -43,6 +44,7 @@ export const validateResetTokenFn = createServerFn({ method: 'GET' })
43
44
  export const resetPasswordFn = createServerFn({ method: 'POST' })
44
45
  .validator(resetPasswordSchema)
45
46
  .handler(async ({ data }) => {
47
+ await checkAuthRateLimit('resetPassword', 5, 15 * 60 * 1000)
46
48
  const resetToken = await prisma.passwordResetToken.findUnique({
47
49
  where: { token: data.token },
48
50
  select: { id: true, userId: true, usedAt: true, expiresAt: true },
@@ -16,6 +16,7 @@
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
18
  import { createServerFn } from '@tanstack/react-start'
19
+ import { z } from 'zod'
19
20
  import { getRequest } from '@tanstack/react-start/server'
20
21
  import type { FeatureKey } from '@allbluecn/shared'
21
22
  import type { Session } from '@/lib/session'
@@ -117,7 +118,7 @@ export const exchangeRemoteTokenFn = createServerFn({ method: 'POST' })
117
118
  return { success: true, userId: profile.user.id, planName }
118
119
  })
119
120
 
120
- export const logoutRemoteFn = createServerFn({ method: 'POST' }).handler(async () => {
121
+ export const logoutRemoteFn = createServerFn({ method: 'POST' }).validator(z.object({})).handler(async () => {
121
122
  if (isDesktopServer()) {
122
123
  await clearRemoteAuth()
123
124
  return { success: true }
@@ -85,7 +85,7 @@ export const createCheckoutSessionFn = createServerFn({ method: 'POST' })
85
85
  })
86
86
  })
87
87
 
88
- export const createCustomerPortalSessionFn = createServerFn({ method: 'POST' }).handler(
88
+ export const createCustomerPortalSessionFn = createServerFn({ method: 'POST' }).validator(z.object({})).handler(
89
89
  async () => {
90
90
  const request = getRequest()
91
91
  const session = await requireAuth(request)
@@ -1,4 +1,5 @@
1
1
  import { createServerFn } from '@tanstack/react-start'
2
+ import { z } from 'zod'
2
3
  import { getRequest } from '@tanstack/react-start/server'
3
4
  import { checkRateLimit } from '@/lib/pacer'
4
5
  import { getProService } from '@/server/pro-services'
@@ -7,7 +8,7 @@ import { requireFeature } from '@/server/entitlements'
7
8
 
8
9
  const JOB_RATE_LIMIT = { max: 3, windowMs: 3600_000 }
9
10
 
10
- export const requestMyExportFn = createServerFn({ method: 'POST' }).handler(async () => {
11
+ export const requestMyExportFn = createServerFn({ method: 'POST' }).validator(z.object({})).handler(async () => {
11
12
  const request = getRequest()
12
13
  const session = await requireAuth(request)
13
14
  if (!session?.user?.id) {
@@ -26,7 +27,7 @@ export const requestMyExportFn = createServerFn({ method: 'POST' }).handler(asyn
26
27
  return compliance.createExportJob('user', userId, userId)
27
28
  })
28
29
 
29
- export const requestMyDeletionFn = createServerFn({ method: 'POST' }).handler(async () => {
30
+ export const requestMyDeletionFn = createServerFn({ method: 'POST' }).validator(z.object({})).handler(async () => {
30
31
  const request = getRequest()
31
32
  const session = await requireAuth(request)
32
33
  if (!session?.user?.id) {
@@ -42,6 +42,7 @@ export const getDesignSettingsFn = createServerFn({ method: 'GET' })
42
42
  })
43
43
 
44
44
  export const generateSketchTokenFn = createServerFn({ method: 'POST' })
45
+ .validator(z.object({}))
45
46
  .handler(async () => {
46
47
  const request = getRequest()
47
48
  const session = await requireAuth(request)
@@ -232,7 +232,7 @@ export const removeTeamMemberFn = createServerFn({ method: 'POST' })
232
232
  return { success: true }
233
233
  })
234
234
 
235
- export const leaveTeamFn = createServerFn({ method: 'POST' }).handler(async () => {
235
+ export const leaveTeamFn = createServerFn({ method: 'POST' }).validator(z.object({})).handler(async () => {
236
236
  const request = getRequest()
237
237
  const session = await requireAuth(request)
238
238
  const userId = session.user!.id!
@@ -1,10 +1,25 @@
1
- import { createHmac, timingSafeEqual } from 'node:crypto'
1
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
2
2
 
3
- const SECRET = process.env.SSO_STATE_SECRET ?? 'dev-only-insecure-sso-secret'
4
3
  const DEFAULT_TTL_MS = 60_000
5
4
 
5
+ let _cachedSecret: string | null = null
6
+ function getSecret(): string {
7
+ if (_cachedSecret) return _cachedSecret
8
+ const raw = process.env.SSO_STATE_SECRET
9
+ if (!raw) {
10
+ if (process.env.NODE_ENV === 'production') {
11
+ throw new Error('[SSO] SSO_STATE_SECRET is required in production')
12
+ }
13
+ console.warn('[SSO] SSO_STATE_SECRET not set, using random dev key')
14
+ _cachedSecret = randomBytes(32).toString('base64url')
15
+ return _cachedSecret
16
+ }
17
+ _cachedSecret = raw
18
+ return _cachedSecret
19
+ }
20
+
6
21
  function sign(payload: string): string {
7
- return createHmac('sha256', SECRET).update(payload).digest('base64url')
22
+ return createHmac('sha256', getSecret()).update(payload).digest('base64url')
8
23
  }
9
24
 
10
25
  function verify(token: string): string | null {
package/src/start.ts CHANGED
@@ -28,7 +28,7 @@ if (import.meta.env.SSR && !globalAny.__githubSyncTimer) {
28
28
  const n = await syncAllStaleGithubBookmarks()
29
29
  if (n > 0) console.log(`[github-sync] synced ${n} bookmarks`)
30
30
  } catch (e) {
31
- console.warn('[github-sync] error', e)
31
+ console.warn('[github-sync] error', e instanceof Error ? e.message : String(e))
32
32
  }
33
33
  }, 6 * 60 * 60 * 1000)
34
34
  }
@@ -62,7 +62,7 @@ const apiCorsMiddleware = createMiddleware().server(async ({ request, next }: an
62
62
  if (allowedOrigins.includes(origin)) {
63
63
  headers.set('Access-Control-Allow-Origin', origin)
64
64
  headers.set('Access-Control-Allow-Credentials', 'true')
65
- headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
65
+ headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
66
66
  headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization')
67
67
  }
68
68
  }
package/vite.shared.ts CHANGED
@@ -117,8 +117,8 @@ export function createWebViteConfig(opts: WebViteConfigOptions) {
117
117
  },
118
118
  },
119
119
  optimizeDeps: {
120
- exclude: ['@allbluecn/ai-gateway', '@allbluecn/database', '@allbluecn/shared', '@allbluecn/ui', '@allbluecn/plugins-core'],
121
- include: ['@tanstack/react-router'],
120
+ exclude: ['@allbluecn/ai-gateway', '@allbluecn/database', '@allbluecn/shared', '@allbluecn/ui', '@allbluecn/plugins-core', '@allbluecn/tiptap'],
121
+ include: ['@tanstack/react-router', 'use-sync-external-store', 'swr'],
122
122
  },
123
123
  ssr: {
124
124
  noExternal: [
@@ -128,6 +128,7 @@ export function createWebViteConfig(opts: WebViteConfigOptions) {
128
128
  '@allbluecn/database',
129
129
  '@allblue/devtools',
130
130
  '@allbluecn/plugins-core',
131
+ '@allbluecn/tiptap',
131
132
  ],
132
133
  },
133
134
  }