@allbluecn/web-app 0.4.8 → 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.
- package/package.json +9 -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/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/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/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/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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@allbluecn/web-app",
|
|
3
|
-
"version": "0.4.
|
|
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/
|
|
134
|
-
"@allbluecn/database": "0.4.8",
|
|
135
|
-
"@allbluecn/shared": "0.4.8",
|
|
136
|
-
"@allbluecn/prototype": "0.1.1",
|
|
134
|
+
"@allbluecn/database": "0.4.9",
|
|
137
135
|
"@allbluecn/kernel": "0.3.0",
|
|
138
|
-
"@allbluecn/
|
|
139
|
-
"@allbluecn/
|
|
140
|
-
"@allbluecn/
|
|
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 {
|
|
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
|
|
|
@@ -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
|
-
|
|
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: [
|