@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.
- package/package.json +11 -8
- package/src/components/chat/ai-chat-history/components/conversation-item.tsx +1 -1
- package/src/components/chat/ai-chat-history/index.tsx +1 -1
- package/src/components/{command-palette.tsx → command/command-palette.tsx} +95 -47
- package/src/components/hextaui/command-menu.tsx +460 -0
- package/src/components/knowledge/editor/knowledge-doc-editor.tsx +4 -4
- package/src/components/knowledge/knowledge-settings-panel.tsx +2 -2
- package/src/components/knowledge/knowledge-toolbar.tsx +4 -4
- package/src/components/knowledge/knowledge-tree-item.tsx +3 -3
- package/src/components/knowledge/knowledge-tree.tsx +2 -2
- package/src/components/layout/sidebar-03/sidebar-search-trigger.tsx +21 -7
- package/src/components/settings/personal-team/account/account-client.tsx +1 -1
- package/src/components/story/detail/activity-timeline.tsx +7 -6
- package/src/lib/anyhub/manager.ts +17 -4
- package/src/lib/auth/auth.config.ts +2 -2
- package/src/lib/auth/sso-authorize.ts +1 -1
- package/src/lib/commands/registry.ts +2 -2
- package/src/lib/sanitize.ts +5 -0
- package/src/routes/_nav/chat/route.lazy.tsx +1 -1
- package/src/routes/_nav/design/index.lazy.tsx +1 -1
- package/src/routes/_nav/route.lazy.tsx +1 -1
- package/src/routes/_nav/settings/ai/route.tsx +1 -1
- package/src/routes/api/design/upload.ts +9 -1
- package/src/routes/design-files/$projectId/$artboardId/$filename.ts +6 -2
- package/src/routes/server.ai-stream.ts +1 -1
- package/src/server/audit.ts +1 -1
- package/src/server/keepalive/middleware.ts +1 -1
- package/src/server/knowledge-public/github-sync.ts +21 -3
- package/src/server/middlewares/audit-recorder.ts +1 -1
- package/src/server/middlewares/rate-limit.ts +27 -0
- package/src/server/references/engine.ts +11 -8
- package/src/server/serverFns/auth/forgot-password.ts +3 -1
- package/src/server/serverFns/auth/invitation.ts +1 -0
- package/src/server/serverFns/auth/register.ts +2 -0
- package/src/server/serverFns/auth/reset-password.ts +2 -0
- package/src/server/serverFns/auth/session.ts +2 -1
- package/src/server/serverFns/billing.ts +1 -1
- package/src/server/serverFns/compliance.ts +3 -2
- package/src/server/serverFns/design/settings.ts +1 -0
- package/src/server/serverFns/team.ts +1 -1
- package/src/server/sso/tokens.ts +18 -3
- package/src/start.ts +2 -2
- 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 {
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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: "
|
|
42
|
-
{ id: "sign-out", titleKey: "command.signOut", icon: "
|
|
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[] {
|
|
@@ -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
|
-
|
|
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 {
|
|
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
|
|
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.',
|
package/src/server/audit.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
|
@@ -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!
|
package/src/server/sso/tokens.ts
CHANGED
|
@@ -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',
|
|
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,
|
|
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: [
|