@castlekit/castle 0.1.6 → 0.3.1
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/LICENSE +21 -0
- package/drizzle.config.ts +7 -0
- package/install.sh +20 -1
- package/next.config.ts +1 -0
- package/package.json +35 -3
- package/src/app/api/avatars/[id]/route.ts +57 -7
- package/src/app/api/openclaw/agents/route.ts +7 -1
- package/src/app/api/openclaw/agents/status/route.ts +55 -0
- package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
- package/src/app/api/openclaw/chat/channels/route.ts +217 -0
- package/src/app/api/openclaw/chat/route.ts +283 -0
- package/src/app/api/openclaw/chat/search/route.ts +150 -0
- package/src/app/api/openclaw/chat/storage/route.ts +75 -0
- package/src/app/api/openclaw/config/route.ts +2 -0
- package/src/app/api/openclaw/events/route.ts +23 -8
- package/src/app/api/openclaw/logs/route.ts +17 -3
- package/src/app/api/openclaw/ping/route.ts +5 -0
- package/src/app/api/openclaw/restart/route.ts +6 -1
- package/src/app/api/openclaw/session/context/route.ts +163 -0
- package/src/app/api/openclaw/session/status/route.ts +210 -0
- package/src/app/api/openclaw/sessions/route.ts +2 -0
- package/src/app/api/settings/avatar/route.ts +190 -0
- package/src/app/api/settings/route.ts +88 -0
- package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
- package/src/app/chat/[channelId]/page.tsx +385 -0
- package/src/app/chat/layout.tsx +96 -0
- package/src/app/chat/page.tsx +52 -0
- package/src/app/globals.css +99 -2
- package/src/app/layout.tsx +7 -1
- package/src/app/page.tsx +59 -25
- package/src/app/settings/page.tsx +300 -0
- package/src/components/chat/agent-mention-popup.tsx +89 -0
- package/src/components/chat/archived-channels.tsx +190 -0
- package/src/components/chat/channel-list.tsx +140 -0
- package/src/components/chat/chat-input.tsx +328 -0
- package/src/components/chat/create-channel-dialog.tsx +171 -0
- package/src/components/chat/markdown-content.tsx +205 -0
- package/src/components/chat/message-bubble.tsx +168 -0
- package/src/components/chat/message-list.tsx +666 -0
- package/src/components/chat/message-queue.tsx +68 -0
- package/src/components/chat/session-divider.tsx +61 -0
- package/src/components/chat/session-stats-panel.tsx +444 -0
- package/src/components/chat/storage-indicator.tsx +76 -0
- package/src/components/layout/sidebar.tsx +126 -45
- package/src/components/layout/user-menu.tsx +29 -4
- package/src/components/providers/presence-provider.tsx +8 -0
- package/src/components/providers/search-provider.tsx +110 -0
- package/src/components/search/search-dialog.tsx +269 -0
- package/src/components/ui/avatar.tsx +11 -9
- package/src/components/ui/dialog.tsx +10 -4
- package/src/components/ui/tooltip.tsx +25 -8
- package/src/components/ui/twemoji-text.tsx +37 -0
- package/src/lib/api-security.ts +125 -0
- package/src/lib/date-utils.ts +79 -0
- package/src/lib/db/index.ts +652 -0
- package/src/lib/db/queries.ts +1144 -0
- package/src/lib/db/schema.ts +164 -0
- package/src/lib/gateway-connection.ts +24 -3
- package/src/lib/hooks/use-agent-status.ts +251 -0
- package/src/lib/hooks/use-chat.ts +753 -0
- package/src/lib/hooks/use-compaction-events.ts +132 -0
- package/src/lib/hooks/use-context-boundary.ts +82 -0
- package/src/lib/hooks/use-openclaw.ts +122 -100
- package/src/lib/hooks/use-search.ts +114 -0
- package/src/lib/hooks/use-session-stats.ts +60 -0
- package/src/lib/hooks/use-user-settings.ts +46 -0
- package/src/lib/sse-singleton.ts +184 -0
- package/src/lib/types/chat.ts +202 -0
- package/src/lib/types/search.ts +60 -0
- package/src/middleware.ts +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Castle Kit Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/install.sh
CHANGED
|
@@ -569,8 +569,27 @@ install_castle() {
|
|
|
569
569
|
echo -e "${WARN}→${NC} Installing Castle (${INFO}${CASTLE_VERSION}${NC})..."
|
|
570
570
|
fi
|
|
571
571
|
|
|
572
|
-
|
|
572
|
+
# Run npm install in background with a spinner so the user knows it's working
|
|
573
|
+
local npm_log
|
|
574
|
+
npm_log="$(mktempfile)"
|
|
575
|
+
npm --loglevel "$NPM_LOGLEVEL" ${NPM_SILENT_FLAG:+$NPM_SILENT_FLAG} --no-fund --no-audit install -g "$install_spec" > "$npm_log" 2>&1 &
|
|
576
|
+
local npm_pid=$!
|
|
577
|
+
|
|
578
|
+
local spin_chars='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
|
|
579
|
+
local i=0
|
|
580
|
+
while kill -0 "$npm_pid" 2>/dev/null; do
|
|
581
|
+
local c="${spin_chars:i%${#spin_chars}:1}"
|
|
582
|
+
printf "\r ${ACCENT}%s${NC} Installing..." "$c"
|
|
583
|
+
((i++)) || true
|
|
584
|
+
sleep 0.1
|
|
585
|
+
done
|
|
586
|
+
printf "\r \r"
|
|
587
|
+
|
|
588
|
+
wait "$npm_pid"
|
|
589
|
+
local npm_exit=$?
|
|
590
|
+
if [[ "$npm_exit" -ne 0 ]]; then
|
|
573
591
|
echo -e "${ERROR}npm install failed${NC}"
|
|
592
|
+
cat "$npm_log"
|
|
574
593
|
echo -e "Try: ${INFO}npm install -g --force ${install_spec}${NC}"
|
|
575
594
|
exit 1
|
|
576
595
|
fi
|
package/next.config.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,16 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@castlekit/castle",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "The multi-agent workspace",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"castle": "./bin/castle.js"
|
|
8
8
|
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"index.js",
|
|
13
|
+
"install.sh",
|
|
14
|
+
"next.config.ts",
|
|
15
|
+
"postcss.config.mjs",
|
|
16
|
+
"tsconfig.json",
|
|
17
|
+
"drizzle.config.ts",
|
|
18
|
+
"LICENSE",
|
|
19
|
+
"README.md",
|
|
20
|
+
"!src/**/__tests__/**",
|
|
21
|
+
"!src/**/*.test.*"
|
|
22
|
+
],
|
|
9
23
|
"scripts": {
|
|
10
24
|
"dev": "next dev -p 3333",
|
|
11
25
|
"build": "next build",
|
|
12
26
|
"start": "next start -p 3333",
|
|
13
|
-
"lint": "next lint"
|
|
27
|
+
"lint": "next lint",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"test:watch": "vitest",
|
|
30
|
+
"ci": "npm audit --omit=dev --audit-level=high && npm test && npm run build"
|
|
14
31
|
},
|
|
15
32
|
"repository": {
|
|
16
33
|
"type": "git",
|
|
@@ -39,12 +56,15 @@
|
|
|
39
56
|
"@dnd-kit/utilities": "^3.2.2",
|
|
40
57
|
"@radix-ui/react-slider": "^1.3.6",
|
|
41
58
|
"@tailwindcss/postcss": "^4.1.18",
|
|
59
|
+
"@twemoji/api": "^17.0.2",
|
|
42
60
|
"@types/node": "^25.2.1",
|
|
43
61
|
"@types/react": "^19.2.13",
|
|
44
62
|
"@types/react-dom": "^19.2.3",
|
|
45
63
|
"@types/ws": "^8.18.1",
|
|
64
|
+
"better-sqlite3": "^12.6.2",
|
|
46
65
|
"clsx": "^2.1.1",
|
|
47
66
|
"commander": "^14.0.3",
|
|
67
|
+
"drizzle-orm": "^0.45.1",
|
|
48
68
|
"json5": "^2.2.3",
|
|
49
69
|
"lucide-react": "^0.563.0",
|
|
50
70
|
"next": "^16.1.6",
|
|
@@ -53,16 +73,28 @@
|
|
|
53
73
|
"picocolors": "^1.1.1",
|
|
54
74
|
"react": "^19.2.4",
|
|
55
75
|
"react-dom": "^19.2.4",
|
|
76
|
+
"react-markdown": "^10.1.0",
|
|
77
|
+
"react-syntax-highlighter": "^16.1.0",
|
|
56
78
|
"recharts": "^3.7.0",
|
|
79
|
+
"remark-gfm": "^4.0.1",
|
|
57
80
|
"sharp": "^0.34.5",
|
|
58
81
|
"swr": "^2.4.0",
|
|
59
82
|
"tailwind-merge": "^3.4.0",
|
|
60
83
|
"tailwindcss": "^4.1.18",
|
|
61
84
|
"tsx": "^4.21.0",
|
|
62
85
|
"typescript": "^5.9.3",
|
|
86
|
+
"uuid": "^13.0.0",
|
|
63
87
|
"ws": "^8.19.0"
|
|
64
88
|
},
|
|
65
89
|
"devDependencies": {
|
|
66
|
-
"@
|
|
90
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
91
|
+
"@testing-library/react": "^16.3.2",
|
|
92
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
93
|
+
"@types/react-syntax-highlighter": "^15.5.13",
|
|
94
|
+
"@types/sharp": "^0.31.1",
|
|
95
|
+
"@types/uuid": "^10.0.0",
|
|
96
|
+
"drizzle-kit": "^0.31.8",
|
|
97
|
+
"jsdom": "^28.0.0",
|
|
98
|
+
"vitest": "^4.0.18"
|
|
67
99
|
}
|
|
68
100
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
2
|
import { readFileSync, existsSync } from "fs";
|
|
3
|
-
import { join } from "path";
|
|
3
|
+
import { join, resolve } from "path";
|
|
4
4
|
import { homedir } from "os";
|
|
5
5
|
import { ensureGateway } from "@/lib/gateway-connection";
|
|
6
6
|
|
|
@@ -51,11 +51,16 @@ export async function GET(
|
|
|
51
51
|
if (storedUrl.startsWith("workspace://")) {
|
|
52
52
|
const pathPart = storedUrl.slice("workspace://".length);
|
|
53
53
|
// Resolve ~ in workspace path
|
|
54
|
-
const resolved =
|
|
55
|
-
? join(homedir(), pathPart.slice(1))
|
|
56
|
-
|
|
57
|
-
// Prevent traversal
|
|
58
|
-
|
|
54
|
+
const resolved = resolve(
|
|
55
|
+
pathPart.startsWith("~") ? join(homedir(), pathPart.slice(1)) : pathPart
|
|
56
|
+
);
|
|
57
|
+
// Prevent traversal: resolved path must not escape the workspace
|
|
58
|
+
// and must be a simple file path (no symlink-based escapes for known dirs)
|
|
59
|
+
if (
|
|
60
|
+
!resolved.includes("..") &&
|
|
61
|
+
!resolved.includes("\0") &&
|
|
62
|
+
existsSync(resolved)
|
|
63
|
+
) {
|
|
59
64
|
return serveFile(resolved);
|
|
60
65
|
}
|
|
61
66
|
}
|
|
@@ -92,8 +97,50 @@ export async function GET(
|
|
|
92
97
|
return new NextResponse("Not found", { status: 404 });
|
|
93
98
|
}
|
|
94
99
|
|
|
95
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Check if a URL resolves to a private/internal IP range (SSRF protection).
|
|
102
|
+
* Blocks access to localhost, private networks, link-local, and cloud metadata.
|
|
103
|
+
*/
|
|
104
|
+
function isPrivateUrl(urlStr: string): boolean {
|
|
105
|
+
try {
|
|
106
|
+
const parsed = new URL(urlStr);
|
|
107
|
+
const hostname = parsed.hostname;
|
|
108
|
+
|
|
109
|
+
// Block cloud metadata endpoints
|
|
110
|
+
if (hostname === "169.254.169.254" || hostname === "metadata.google.internal") {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Block localhost variants
|
|
115
|
+
if (
|
|
116
|
+
hostname === "localhost" ||
|
|
117
|
+
hostname === "127.0.0.1" ||
|
|
118
|
+
hostname === "::1" ||
|
|
119
|
+
hostname === "0.0.0.0"
|
|
120
|
+
) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Block private IP ranges (10.x, 172.16-31.x, 192.168.x)
|
|
125
|
+
const parts = hostname.split(".").map(Number);
|
|
126
|
+
if (parts.length === 4 && parts.every((p) => !isNaN(p))) {
|
|
127
|
+
if (parts[0] === 10) return true;
|
|
128
|
+
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
|
129
|
+
if (parts[0] === 192 && parts[1] === 168) return true;
|
|
130
|
+
if (parts[0] === 169 && parts[1] === 254) return true; // link-local
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return false;
|
|
134
|
+
} catch {
|
|
135
|
+
return true; // Reject malformed URLs
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Fetch an avatar from an HTTP URL with a short timeout and SSRF protection */
|
|
96
140
|
async function fetchAvatar(url: string): Promise<NextResponse | null> {
|
|
141
|
+
// Block requests to private/internal networks
|
|
142
|
+
if (isPrivateUrl(url)) return null;
|
|
143
|
+
|
|
97
144
|
try {
|
|
98
145
|
const resp = await fetch(url, { signal: AbortSignal.timeout(2000) });
|
|
99
146
|
if (!resp.ok) return null;
|
|
@@ -101,7 +148,10 @@ async function fetchAvatar(url: string): Promise<NextResponse | null> {
|
|
|
101
148
|
const contentType = resp.headers.get("content-type") || "image/png";
|
|
102
149
|
if (!contentType.startsWith("image/")) return null;
|
|
103
150
|
|
|
151
|
+
// Limit response size to prevent memory exhaustion
|
|
104
152
|
const data = Buffer.from(await resp.arrayBuffer());
|
|
153
|
+
if (data.length > 5 * 1024 * 1024) return null; // 5MB max
|
|
154
|
+
|
|
105
155
|
return new NextResponse(data, {
|
|
106
156
|
headers: { "Content-Type": contentType, ...CACHE_HEADERS },
|
|
107
157
|
});
|
|
@@ -105,10 +105,14 @@ export async function GET() {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
try {
|
|
108
|
+
const _start = Date.now();
|
|
108
109
|
// Fetch agents and config in parallel
|
|
109
110
|
const [agentsResult, configResult] = await Promise.all([
|
|
110
111
|
gw.request<AgentsListPayload>("agents.list", {}),
|
|
111
|
-
gw.request<ConfigGetPayload>("config.get", {}).catch(() =>
|
|
112
|
+
gw.request<ConfigGetPayload>("config.get", {}).catch((err) => {
|
|
113
|
+
console.warn("[Agents API] config.get failed (non-fatal):", (err as Error).message);
|
|
114
|
+
return null;
|
|
115
|
+
}),
|
|
112
116
|
]);
|
|
113
117
|
|
|
114
118
|
// Build workspace lookup from config
|
|
@@ -130,11 +134,13 @@ export async function GET() {
|
|
|
130
134
|
return { id: agent.id, name, description, avatar, emoji };
|
|
131
135
|
});
|
|
132
136
|
|
|
137
|
+
console.log(`[Agents API] GET list OK — ${agents.length} agents (${Date.now() - _start}ms)`);
|
|
133
138
|
return NextResponse.json({
|
|
134
139
|
agents,
|
|
135
140
|
defaultId: agentsResult?.defaultId,
|
|
136
141
|
});
|
|
137
142
|
} catch (err) {
|
|
143
|
+
console.error("[Agents API] GET list FAILED:", err instanceof Error ? err.message : "Unknown error");
|
|
138
144
|
return NextResponse.json(
|
|
139
145
|
{ error: err instanceof Error ? err.message : "Failed to list agents", agents: [] },
|
|
140
146
|
{ status: 500 }
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { getAgentStatuses, setAgentStatus, type AgentStatusValue } from "@/lib/db/queries";
|
|
3
|
+
import { checkCsrf } from "@/lib/api-security";
|
|
4
|
+
|
|
5
|
+
const VALID_STATUSES: AgentStatusValue[] = ["idle", "thinking", "active"];
|
|
6
|
+
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// GET /api/openclaw/agents/status — Get all agent statuses
|
|
9
|
+
// ============================================================================
|
|
10
|
+
|
|
11
|
+
export async function GET() {
|
|
12
|
+
try {
|
|
13
|
+
const statuses = getAgentStatuses();
|
|
14
|
+
return NextResponse.json({ statuses });
|
|
15
|
+
} catch (err) {
|
|
16
|
+
console.error("[Agent Status] GET failed:", (err as Error).message);
|
|
17
|
+
return NextResponse.json({ error: "Failed to get agent statuses" }, { status: 500 });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// POST /api/openclaw/agents/status — Set an agent's status
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
export async function POST(request: NextRequest) {
|
|
26
|
+
const csrfError = checkCsrf(request);
|
|
27
|
+
if (csrfError) return csrfError;
|
|
28
|
+
|
|
29
|
+
let body: { agentId?: string; status?: string };
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
body = await request.json();
|
|
33
|
+
} catch {
|
|
34
|
+
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!body.agentId || typeof body.agentId !== "string") {
|
|
38
|
+
return NextResponse.json({ error: "agentId is required" }, { status: 400 });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!body.status || !VALID_STATUSES.includes(body.status as AgentStatusValue)) {
|
|
42
|
+
return NextResponse.json(
|
|
43
|
+
{ error: `status must be one of: ${VALID_STATUSES.join(", ")}` },
|
|
44
|
+
{ status: 400 }
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
setAgentStatus(body.agentId, body.status as AgentStatusValue);
|
|
50
|
+
return NextResponse.json({ ok: true });
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error("[Agent Status] POST failed:", (err as Error).message);
|
|
53
|
+
return NextResponse.json({ error: "Failed to set agent status" }, { status: 500 });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, statSync } from "fs";
|
|
3
|
+
import { join, resolve } from "path";
|
|
4
|
+
import { platform } from "os";
|
|
5
|
+
import { v4 as uuid } from "uuid";
|
|
6
|
+
import { checkCsrf } from "@/lib/api-security";
|
|
7
|
+
import { getCastleDir } from "@/lib/config";
|
|
8
|
+
import { createAttachment, getAttachmentsByMessage } from "@/lib/db/queries";
|
|
9
|
+
|
|
10
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
11
|
+
const ALLOWED_MIME_TYPES = new Set([
|
|
12
|
+
"image/png",
|
|
13
|
+
"image/jpeg",
|
|
14
|
+
"image/webp",
|
|
15
|
+
"image/gif",
|
|
16
|
+
"audio/mpeg",
|
|
17
|
+
"audio/ogg",
|
|
18
|
+
"audio/wav",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
function getAttachmentsDir(): string {
|
|
22
|
+
return join(getCastleDir(), "data", "attachments");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Validate that resolved path stays within the attachments directory */
|
|
26
|
+
function isPathSafe(filePath: string, baseDir: string): boolean {
|
|
27
|
+
const resolved = resolve(filePath);
|
|
28
|
+
const resolvedBase = resolve(baseDir);
|
|
29
|
+
return resolved.startsWith(resolvedBase);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getMimeForExt(ext: string): string {
|
|
33
|
+
const map: Record<string, string> = {
|
|
34
|
+
".png": "image/png",
|
|
35
|
+
".jpg": "image/jpeg",
|
|
36
|
+
".jpeg": "image/jpeg",
|
|
37
|
+
".webp": "image/webp",
|
|
38
|
+
".gif": "image/gif",
|
|
39
|
+
".mp3": "audio/mpeg",
|
|
40
|
+
".ogg": "audio/ogg",
|
|
41
|
+
".wav": "audio/wav",
|
|
42
|
+
};
|
|
43
|
+
return map[ext.toLowerCase()] || "application/octet-stream";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ============================================================================
|
|
47
|
+
// POST /api/openclaw/chat/attachments — Upload attachment
|
|
48
|
+
// ============================================================================
|
|
49
|
+
|
|
50
|
+
export async function POST(request: NextRequest) {
|
|
51
|
+
const csrf = checkCsrf(request);
|
|
52
|
+
if (csrf) return csrf;
|
|
53
|
+
|
|
54
|
+
const formData = await request.formData();
|
|
55
|
+
const file = formData.get("file") as File | null;
|
|
56
|
+
const channelId = formData.get("channelId") as string | null;
|
|
57
|
+
const messageId = formData.get("messageId") as string | null;
|
|
58
|
+
|
|
59
|
+
if (!file || !channelId) {
|
|
60
|
+
return NextResponse.json(
|
|
61
|
+
{ error: "file and channelId are required" },
|
|
62
|
+
{ status: 400 }
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Validate file size
|
|
67
|
+
if (file.size > MAX_FILE_SIZE) {
|
|
68
|
+
return NextResponse.json(
|
|
69
|
+
{ error: `File too large (max ${MAX_FILE_SIZE / 1024 / 1024}MB)` },
|
|
70
|
+
{ status: 400 }
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Validate MIME type
|
|
75
|
+
if (!ALLOWED_MIME_TYPES.has(file.type)) {
|
|
76
|
+
return NextResponse.json(
|
|
77
|
+
{ error: `Unsupported file type: ${file.type}` },
|
|
78
|
+
{ status: 400 }
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
// Determine file extension from MIME type
|
|
84
|
+
const extMap: Record<string, string> = {
|
|
85
|
+
"image/png": ".png",
|
|
86
|
+
"image/jpeg": ".jpg",
|
|
87
|
+
"image/webp": ".webp",
|
|
88
|
+
"image/gif": ".gif",
|
|
89
|
+
"audio/mpeg": ".mp3",
|
|
90
|
+
"audio/ogg": ".ogg",
|
|
91
|
+
"audio/wav": ".wav",
|
|
92
|
+
};
|
|
93
|
+
const ext = extMap[file.type] || ".bin";
|
|
94
|
+
|
|
95
|
+
// Create UUID-based filename (never use user-supplied names)
|
|
96
|
+
const filename = `${uuid()}${ext}`;
|
|
97
|
+
const channelDir = join(getAttachmentsDir(), channelId);
|
|
98
|
+
|
|
99
|
+
// Ensure directory exists
|
|
100
|
+
if (!existsSync(channelDir)) {
|
|
101
|
+
mkdirSync(channelDir, { recursive: true, mode: 0o700 });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const filePath = join(channelDir, filename);
|
|
105
|
+
|
|
106
|
+
// Path traversal check
|
|
107
|
+
if (!isPathSafe(filePath, getAttachmentsDir())) {
|
|
108
|
+
return NextResponse.json(
|
|
109
|
+
{ error: "Invalid path" },
|
|
110
|
+
{ status: 400 }
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Write file
|
|
115
|
+
const buffer = Buffer.from(await file.arrayBuffer());
|
|
116
|
+
writeFileSync(filePath, buffer);
|
|
117
|
+
|
|
118
|
+
// Secure permissions
|
|
119
|
+
if (platform() !== "win32") {
|
|
120
|
+
try {
|
|
121
|
+
const { chmodSync } = await import("fs");
|
|
122
|
+
chmodSync(filePath, 0o600);
|
|
123
|
+
} catch {
|
|
124
|
+
// May fail on some filesystems
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Persist to DB (if messageId provided)
|
|
129
|
+
const attachmentType = file.type.startsWith("image/") ? "image" : "audio";
|
|
130
|
+
let attachmentRecord = null;
|
|
131
|
+
|
|
132
|
+
if (messageId) {
|
|
133
|
+
attachmentRecord = createAttachment({
|
|
134
|
+
messageId,
|
|
135
|
+
attachmentType: attachmentType as "image" | "audio",
|
|
136
|
+
filePath: `${channelId}/${filename}`,
|
|
137
|
+
mimeType: file.type,
|
|
138
|
+
fileSize: file.size,
|
|
139
|
+
originalName: file.name,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return NextResponse.json({
|
|
144
|
+
id: attachmentRecord?.id ?? uuid(),
|
|
145
|
+
filePath: `${channelId}/${filename}`,
|
|
146
|
+
mimeType: file.type,
|
|
147
|
+
fileSize: file.size,
|
|
148
|
+
originalName: file.name,
|
|
149
|
+
}, { status: 201 });
|
|
150
|
+
} catch (err) {
|
|
151
|
+
console.error("[Attachments] Upload failed:", (err as Error).message);
|
|
152
|
+
return NextResponse.json(
|
|
153
|
+
{ error: "Upload failed" },
|
|
154
|
+
{ status: 500 }
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ============================================================================
|
|
160
|
+
// GET /api/openclaw/chat/attachments?path=... — Serve attachment
|
|
161
|
+
// ============================================================================
|
|
162
|
+
|
|
163
|
+
export async function GET(request: NextRequest) {
|
|
164
|
+
const { searchParams } = new URL(request.url);
|
|
165
|
+
const filePath = searchParams.get("path");
|
|
166
|
+
const messageId = searchParams.get("messageId");
|
|
167
|
+
|
|
168
|
+
// If messageId provided, return all attachments for that message
|
|
169
|
+
if (messageId) {
|
|
170
|
+
try {
|
|
171
|
+
const attachments = getAttachmentsByMessage(messageId);
|
|
172
|
+
return NextResponse.json({ attachments });
|
|
173
|
+
} catch (err) {
|
|
174
|
+
console.error("[Attachments] List failed:", (err as Error).message);
|
|
175
|
+
return NextResponse.json(
|
|
176
|
+
{ error: "Failed to list attachments" },
|
|
177
|
+
{ status: 500 }
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Serve individual file
|
|
183
|
+
if (!filePath) {
|
|
184
|
+
return NextResponse.json(
|
|
185
|
+
{ error: "path or messageId parameter required" },
|
|
186
|
+
{ status: 400 }
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const baseDir = getAttachmentsDir();
|
|
191
|
+
const fullPath = join(baseDir, filePath);
|
|
192
|
+
|
|
193
|
+
// Path traversal check
|
|
194
|
+
if (!isPathSafe(fullPath, baseDir)) {
|
|
195
|
+
return NextResponse.json(
|
|
196
|
+
{ error: "Invalid path" },
|
|
197
|
+
{ status: 400 }
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!existsSync(fullPath)) {
|
|
202
|
+
return NextResponse.json(
|
|
203
|
+
{ error: "Attachment not found" },
|
|
204
|
+
{ status: 404 }
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const data = readFileSync(fullPath);
|
|
210
|
+
const ext = fullPath.substring(fullPath.lastIndexOf("."));
|
|
211
|
+
const mimeType = getMimeForExt(ext);
|
|
212
|
+
const stat = statSync(fullPath);
|
|
213
|
+
|
|
214
|
+
return new NextResponse(data, {
|
|
215
|
+
status: 200,
|
|
216
|
+
headers: {
|
|
217
|
+
"Content-Type": mimeType,
|
|
218
|
+
"Content-Length": String(stat.size),
|
|
219
|
+
"Content-Disposition": "inline",
|
|
220
|
+
"Cache-Control": "public, max-age=86400", // 24h cache
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
} catch (err) {
|
|
224
|
+
console.error("[Attachments] Serve failed:", (err as Error).message);
|
|
225
|
+
return NextResponse.json(
|
|
226
|
+
{ error: "Failed to serve attachment" },
|
|
227
|
+
{ status: 500 }
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
}
|