@castlekit/castle 0.1.6 → 0.3.0
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/drizzle.config.ts +7 -0
- package/next.config.ts +1 -0
- package/package.json +20 -3
- package/src/app/api/avatars/[id]/route.ts +57 -7
- 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 +214 -0
- package/src/app/api/openclaw/chat/route.ts +272 -0
- package/src/app/api/openclaw/chat/search/route.ts +149 -0
- package/src/app/api/openclaw/chat/storage/route.ts +75 -0
- package/src/app/api/openclaw/logs/route.ts +17 -3
- package/src/app/api/openclaw/restart/route.ts +6 -1
- package/src/app/api/openclaw/session/status/route.ts +42 -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 +305 -0
- package/src/app/chat/layout.tsx +96 -0
- package/src/app/chat/page.tsx +52 -0
- package/src/app/globals.css +89 -2
- package/src/app/layout.tsx +7 -1
- package/src/app/page.tsx +49 -17
- 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 +310 -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 +152 -0
- package/src/components/chat/message-list.tsx +508 -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 +139 -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 +81 -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/__tests__/queries.test.ts +318 -0
- package/src/lib/db/index.ts +642 -0
- package/src/lib/db/queries.ts +1017 -0
- package/src/lib/db/schema.ts +160 -0
- package/src/lib/hooks/use-agent-status.ts +251 -0
- package/src/lib/hooks/use-chat.ts +775 -0
- package/src/lib/hooks/use-openclaw.ts +105 -70
- package/src/lib/hooks/use-search.ts +113 -0
- package/src/lib/hooks/use-session-stats.ts +57 -0
- package/src/lib/hooks/use-user-settings.ts +46 -0
- package/src/lib/types/chat.ts +186 -0
- package/src/lib/types/search.ts +60 -0
- package/src/middleware.ts +52 -0
- package/vitest.config.ts +13 -0
package/next.config.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@castlekit/castle",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "The multi-agent workspace",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
"dev": "next dev -p 3333",
|
|
11
11
|
"build": "next build",
|
|
12
12
|
"start": "next start -p 3333",
|
|
13
|
-
"lint": "next lint"
|
|
13
|
+
"lint": "next lint",
|
|
14
|
+
"test": "vitest run",
|
|
15
|
+
"test:watch": "vitest"
|
|
14
16
|
},
|
|
15
17
|
"repository": {
|
|
16
18
|
"type": "git",
|
|
@@ -39,12 +41,15 @@
|
|
|
39
41
|
"@dnd-kit/utilities": "^3.2.2",
|
|
40
42
|
"@radix-ui/react-slider": "^1.3.6",
|
|
41
43
|
"@tailwindcss/postcss": "^4.1.18",
|
|
44
|
+
"@twemoji/api": "^17.0.2",
|
|
42
45
|
"@types/node": "^25.2.1",
|
|
43
46
|
"@types/react": "^19.2.13",
|
|
44
47
|
"@types/react-dom": "^19.2.3",
|
|
45
48
|
"@types/ws": "^8.18.1",
|
|
49
|
+
"better-sqlite3": "^12.6.2",
|
|
46
50
|
"clsx": "^2.1.1",
|
|
47
51
|
"commander": "^14.0.3",
|
|
52
|
+
"drizzle-orm": "^0.45.1",
|
|
48
53
|
"json5": "^2.2.3",
|
|
49
54
|
"lucide-react": "^0.563.0",
|
|
50
55
|
"next": "^16.1.6",
|
|
@@ -53,16 +58,28 @@
|
|
|
53
58
|
"picocolors": "^1.1.1",
|
|
54
59
|
"react": "^19.2.4",
|
|
55
60
|
"react-dom": "^19.2.4",
|
|
61
|
+
"react-markdown": "^10.1.0",
|
|
62
|
+
"react-syntax-highlighter": "^16.1.0",
|
|
56
63
|
"recharts": "^3.7.0",
|
|
64
|
+
"remark-gfm": "^4.0.1",
|
|
57
65
|
"sharp": "^0.34.5",
|
|
58
66
|
"swr": "^2.4.0",
|
|
59
67
|
"tailwind-merge": "^3.4.0",
|
|
60
68
|
"tailwindcss": "^4.1.18",
|
|
61
69
|
"tsx": "^4.21.0",
|
|
62
70
|
"typescript": "^5.9.3",
|
|
71
|
+
"uuid": "^13.0.0",
|
|
63
72
|
"ws": "^8.19.0"
|
|
64
73
|
},
|
|
65
74
|
"devDependencies": {
|
|
66
|
-
"@
|
|
75
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
76
|
+
"@testing-library/react": "^16.3.2",
|
|
77
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
78
|
+
"@types/react-syntax-highlighter": "^15.5.13",
|
|
79
|
+
"@types/sharp": "^0.31.1",
|
|
80
|
+
"@types/uuid": "^10.0.0",
|
|
81
|
+
"drizzle-kit": "^0.31.8",
|
|
82
|
+
"jsdom": "^28.0.0",
|
|
83
|
+
"vitest": "^4.0.18"
|
|
67
84
|
}
|
|
68
85
|
}
|
|
@@ -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
|
});
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { checkCsrf, sanitizeForApi } from "@/lib/api-security";
|
|
3
|
+
import {
|
|
4
|
+
createChannel,
|
|
5
|
+
getChannels,
|
|
6
|
+
getChannel,
|
|
7
|
+
updateChannel,
|
|
8
|
+
deleteChannel,
|
|
9
|
+
archiveChannel,
|
|
10
|
+
restoreChannel,
|
|
11
|
+
touchChannel,
|
|
12
|
+
getLastAccessedChannelId,
|
|
13
|
+
} from "@/lib/db/queries";
|
|
14
|
+
|
|
15
|
+
const MAX_CHANNEL_NAME_LENGTH = 100;
|
|
16
|
+
|
|
17
|
+
/** Strip control characters from a string (keep printable + whitespace) */
|
|
18
|
+
function sanitizeName(name: string): string {
|
|
19
|
+
// eslint-disable-next-line no-control-regex
|
|
20
|
+
return name.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "").trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Validate agent ID format */
|
|
24
|
+
function isValidAgentId(id: string): boolean {
|
|
25
|
+
return /^[a-zA-Z0-9._-]+$/.test(id);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// GET /api/openclaw/chat/channels — List channels
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
export async function GET(request: NextRequest) {
|
|
33
|
+
const { searchParams } = new URL(request.url);
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
// GET /api/openclaw/chat/channels?last=1 — get last accessed channel ID
|
|
37
|
+
if (searchParams.get("last")) {
|
|
38
|
+
const lastId = getLastAccessedChannelId();
|
|
39
|
+
return NextResponse.json({ channelId: lastId });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const archived = searchParams.get("archived") === "1";
|
|
43
|
+
const all = getChannels(archived);
|
|
44
|
+
return NextResponse.json({ channels: all });
|
|
45
|
+
} catch (err) {
|
|
46
|
+
console.error("[Chat Channels] List failed:", (err as Error).message);
|
|
47
|
+
return NextResponse.json(
|
|
48
|
+
{ error: sanitizeForApi((err as Error).message) },
|
|
49
|
+
{ status: 500 }
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// POST /api/openclaw/chat/channels — Create / update / delete channel
|
|
56
|
+
// ============================================================================
|
|
57
|
+
|
|
58
|
+
export async function POST(request: NextRequest) {
|
|
59
|
+
const csrf = checkCsrf(request);
|
|
60
|
+
if (csrf) return csrf;
|
|
61
|
+
|
|
62
|
+
let body: {
|
|
63
|
+
action?: "create" | "update" | "delete" | "archive" | "restore" | "touch";
|
|
64
|
+
id?: string;
|
|
65
|
+
name?: string;
|
|
66
|
+
defaultAgentId?: string;
|
|
67
|
+
agents?: string[];
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
body = await request.json();
|
|
72
|
+
} catch {
|
|
73
|
+
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const action = body.action || "create";
|
|
77
|
+
|
|
78
|
+
// ------ TOUCH (mark as last accessed) ------
|
|
79
|
+
if (action === "touch") {
|
|
80
|
+
if (!body.id) {
|
|
81
|
+
return NextResponse.json({ error: "id is required" }, { status: 400 });
|
|
82
|
+
}
|
|
83
|
+
touchChannel(body.id);
|
|
84
|
+
return NextResponse.json({ ok: true });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ------ ARCHIVE ------
|
|
88
|
+
if (action === "archive") {
|
|
89
|
+
if (!body.id) {
|
|
90
|
+
return NextResponse.json({ error: "id is required" }, { status: 400 });
|
|
91
|
+
}
|
|
92
|
+
const archived = archiveChannel(body.id);
|
|
93
|
+
if (!archived) {
|
|
94
|
+
return NextResponse.json({ error: "Channel not found" }, { status: 404 });
|
|
95
|
+
}
|
|
96
|
+
return NextResponse.json({ ok: true });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ------ RESTORE ------
|
|
100
|
+
if (action === "restore") {
|
|
101
|
+
if (!body.id) {
|
|
102
|
+
return NextResponse.json({ error: "id is required" }, { status: 400 });
|
|
103
|
+
}
|
|
104
|
+
const restored = restoreChannel(body.id);
|
|
105
|
+
if (!restored) {
|
|
106
|
+
return NextResponse.json({ error: "Channel not found" }, { status: 404 });
|
|
107
|
+
}
|
|
108
|
+
return NextResponse.json({ ok: true });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ------ DELETE (permanent — only for archived channels) ------
|
|
112
|
+
if (action === "delete") {
|
|
113
|
+
if (!body.id) {
|
|
114
|
+
return NextResponse.json({ error: "id is required" }, { status: 400 });
|
|
115
|
+
}
|
|
116
|
+
// Only allow deleting archived channels
|
|
117
|
+
const ch = getChannel(body.id);
|
|
118
|
+
if (!ch) {
|
|
119
|
+
return NextResponse.json({ error: "Channel not found" }, { status: 404 });
|
|
120
|
+
}
|
|
121
|
+
if (!ch.archivedAt) {
|
|
122
|
+
return NextResponse.json(
|
|
123
|
+
{ error: "Channel must be archived before it can be permanently deleted" },
|
|
124
|
+
{ status: 400 }
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const deleted = deleteChannel(body.id);
|
|
129
|
+
if (!deleted) {
|
|
130
|
+
return NextResponse.json({ error: "Channel not found" }, { status: 404 });
|
|
131
|
+
}
|
|
132
|
+
return NextResponse.json({ ok: true });
|
|
133
|
+
} catch (err) {
|
|
134
|
+
console.error("[Chat Channels] Delete failed:", (err as Error).message);
|
|
135
|
+
return NextResponse.json(
|
|
136
|
+
{ error: sanitizeForApi((err as Error).message) },
|
|
137
|
+
{ status: 500 }
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ------ UPDATE ------
|
|
143
|
+
if (action === "update") {
|
|
144
|
+
if (!body.id) {
|
|
145
|
+
return NextResponse.json({ error: "id is required" }, { status: 400 });
|
|
146
|
+
}
|
|
147
|
+
const updates: { name?: string; defaultAgentId?: string } = {};
|
|
148
|
+
if (body.name) {
|
|
149
|
+
const cleanName = sanitizeName(body.name);
|
|
150
|
+
if (!cleanName || cleanName.length > MAX_CHANNEL_NAME_LENGTH) {
|
|
151
|
+
return NextResponse.json(
|
|
152
|
+
{ error: `Channel name must be 1-${MAX_CHANNEL_NAME_LENGTH} characters` },
|
|
153
|
+
{ status: 400 }
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
updates.name = cleanName;
|
|
157
|
+
}
|
|
158
|
+
if (body.defaultAgentId) {
|
|
159
|
+
if (!isValidAgentId(body.defaultAgentId)) {
|
|
160
|
+
return NextResponse.json({ error: "Invalid agent ID format" }, { status: 400 });
|
|
161
|
+
}
|
|
162
|
+
updates.defaultAgentId = body.defaultAgentId;
|
|
163
|
+
}
|
|
164
|
+
const updated = updateChannel(body.id, updates);
|
|
165
|
+
if (!updated) {
|
|
166
|
+
return NextResponse.json({ error: "Channel not found" }, { status: 404 });
|
|
167
|
+
}
|
|
168
|
+
const channel = getChannel(body.id);
|
|
169
|
+
return NextResponse.json({ channel });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ------ CREATE ------
|
|
173
|
+
if (!body.name || typeof body.name !== "string") {
|
|
174
|
+
return NextResponse.json({ error: "name is required" }, { status: 400 });
|
|
175
|
+
}
|
|
176
|
+
if (!body.defaultAgentId || typeof body.defaultAgentId !== "string") {
|
|
177
|
+
return NextResponse.json({ error: "defaultAgentId is required" }, { status: 400 });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const cleanName = sanitizeName(body.name);
|
|
181
|
+
if (!cleanName || cleanName.length > MAX_CHANNEL_NAME_LENGTH) {
|
|
182
|
+
return NextResponse.json(
|
|
183
|
+
{ error: `Channel name must be 1-${MAX_CHANNEL_NAME_LENGTH} characters` },
|
|
184
|
+
{ status: 400 }
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!isValidAgentId(body.defaultAgentId)) {
|
|
189
|
+
return NextResponse.json({ error: "Invalid agent ID format" }, { status: 400 });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Validate all agent IDs
|
|
193
|
+
if (body.agents) {
|
|
194
|
+
for (const agentId of body.agents) {
|
|
195
|
+
if (!isValidAgentId(agentId)) {
|
|
196
|
+
return NextResponse.json(
|
|
197
|
+
{ error: `Invalid agent ID format: ${agentId}` },
|
|
198
|
+
{ status: 400 }
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const channel = createChannel(cleanName, body.defaultAgentId, body.agents);
|
|
206
|
+
return NextResponse.json({ channel }, { status: 201 });
|
|
207
|
+
} catch (err) {
|
|
208
|
+
console.error("[Chat Channels] Create failed:", (err as Error).message);
|
|
209
|
+
return NextResponse.json(
|
|
210
|
+
{ error: sanitizeForApi((err as Error).message) },
|
|
211
|
+
{ status: 500 }
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|