@allbluecn/web-app 0.4.8 → 0.4.10

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 (43) hide show
  1. package/package.json +11 -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/{command-palette.tsx → command/command-palette.tsx} +95 -47
  5. package/src/components/hextaui/command-menu.tsx +460 -0
  6. package/src/components/knowledge/editor/knowledge-doc-editor.tsx +4 -4
  7. package/src/components/knowledge/knowledge-settings-panel.tsx +2 -2
  8. package/src/components/knowledge/knowledge-toolbar.tsx +4 -4
  9. package/src/components/knowledge/knowledge-tree-item.tsx +3 -3
  10. package/src/components/knowledge/knowledge-tree.tsx +2 -2
  11. package/src/components/layout/sidebar-03/sidebar-search-trigger.tsx +21 -7
  12. package/src/components/settings/personal-team/account/account-client.tsx +1 -1
  13. package/src/components/story/detail/activity-timeline.tsx +7 -6
  14. package/src/lib/anyhub/manager.ts +17 -4
  15. package/src/lib/auth/auth.config.ts +2 -2
  16. package/src/lib/auth/sso-authorize.ts +1 -1
  17. package/src/lib/commands/registry.ts +2 -2
  18. package/src/lib/sanitize.ts +5 -0
  19. package/src/routes/_nav/chat/route.lazy.tsx +1 -1
  20. package/src/routes/_nav/design/index.lazy.tsx +1 -1
  21. package/src/routes/_nav/route.lazy.tsx +1 -1
  22. package/src/routes/_nav/settings/ai/route.tsx +1 -1
  23. package/src/routes/api/design/upload.ts +9 -1
  24. package/src/routes/design-files/$projectId/$artboardId/$filename.ts +6 -2
  25. package/src/routes/server.ai-stream.ts +1 -1
  26. package/src/server/audit.ts +1 -1
  27. package/src/server/keepalive/middleware.ts +1 -1
  28. package/src/server/knowledge-public/github-sync.ts +21 -3
  29. package/src/server/middlewares/audit-recorder.ts +1 -1
  30. package/src/server/middlewares/rate-limit.ts +27 -0
  31. package/src/server/references/engine.ts +11 -8
  32. package/src/server/serverFns/auth/forgot-password.ts +3 -1
  33. package/src/server/serverFns/auth/invitation.ts +1 -0
  34. package/src/server/serverFns/auth/register.ts +2 -0
  35. package/src/server/serverFns/auth/reset-password.ts +2 -0
  36. package/src/server/serverFns/auth/session.ts +2 -1
  37. package/src/server/serverFns/billing.ts +1 -1
  38. package/src/server/serverFns/compliance.ts +3 -2
  39. package/src/server/serverFns/design/settings.ts +1 -0
  40. package/src/server/serverFns/team.ts +1 -1
  41. package/src/server/sso/tokens.ts +18 -3
  42. package/src/start.ts +2 -2
  43. package/vite.shared.ts +1 -1
@@ -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
 
@@ -38,8 +38,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
38
38
  { id: "create-prd", titleKey: "command.createPrd", icon: "file", group: "action", action: { kind: "callback", id: "create-prd" }, keywords: ["新建", "创建", "PRD", "xj"], shortcut: ["N", "P"] },
39
39
  { id: "create-conversation", titleKey: "command.createConversation", icon: "message", group: "action", action: { kind: "callback", id: "create-conversation" }, keywords: ["新建对话", "新对话", "xjdh"], shortcut: ["N", "C"] },
40
40
  { id: "create-story", titleKey: "command.createStory", icon: "clipboardList", group: "action", action: { kind: "callback", id: "create-story" }, keywords: ["新建需求", "xjxq"], shortcut: ["N", "S"] },
41
- { id: "toggle-theme", titleKey: "command.toggleTheme", icon: "earth", group: "action", action: { kind: "callback", id: "toggle-theme" }, keywords: ["切换主题", "暗黑", "主题", "qhzt"] },
42
- { id: "sign-out", titleKey: "command.signOut", icon: "users", group: "action", action: { kind: "callback", id: "sign-out" }, keywords: ["退出登录", "登出", "tcdl"] },
41
+ { id: "toggle-theme", titleKey: "command.toggleTheme", icon: "sunMoon", group: "action", action: { kind: "callback", id: "toggle-theme" }, keywords: ["切换主题", "暗黑", "主题", "qhzt"], shortcut: ["Mod", "Shift", "L"] },
42
+ { id: "sign-out", titleKey: "command.signOut", icon: "logOut", group: "action", action: { kind: "callback", id: "sign-out" }, keywords: ["退出登录", "登出", "tcdl"] },
43
43
  ] as const
44
44
 
45
45
  export function getSequencePairs(registry: CommandDefinition[]): string[] {
@@ -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
  }
@@ -20,7 +20,7 @@ import Sidebar03 from '@/components/layout/sidebar-03'
20
20
  import { GlobalChatPanel } from '@/components/ai/global-chat-panel'
21
21
  import { TimezoneBootstrap } from '@/components/shared/timezone-bootstrap'
22
22
  import { SubscriptionProvider } from '@/components/billing/subscription-provider'
23
- import { CommandPalette } from '@/components/command-palette'
23
+ import { CommandPalette } from '@/components/command/command-palette'
24
24
  import type { Session } from '@/lib/auth/auth-client'
25
25
 
26
26
  export const Route = createLazyFileRoute('/_nav')({
@@ -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
@@ -118,7 +118,7 @@ export function createWebViteConfig(opts: WebViteConfigOptions) {
118
118
  },
119
119
  optimizeDeps: {
120
120
  exclude: ['@allbluecn/ai-gateway', '@allbluecn/database', '@allbluecn/shared', '@allbluecn/ui', '@allbluecn/plugins-core', '@allbluecn/tiptap'],
121
- include: ['@tanstack/react-router'],
121
+ include: ['@tanstack/react-router', 'use-sync-external-store', 'swr'],
122
122
  },
123
123
  ssr: {
124
124
  noExternal: [