@mooncompany/uplink-chat 0.5.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.
Potentially problematic release.
This version of @mooncompany/uplink-chat might be problematic. Click here for more details.
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/bin/uplink.js +279 -0
- package/middleware/error-handler.js +69 -0
- package/package.json +93 -0
- package/public/css/agents.36b98c0f.css +1469 -0
- package/public/css/agents.css +1469 -0
- package/public/css/app.a6a7f8f5.css +2731 -0
- package/public/css/app.css +2731 -0
- package/public/css/artifacts.css +444 -0
- package/public/css/commands.css +55 -0
- package/public/css/connection.css +131 -0
- package/public/css/dashboard.css +233 -0
- package/public/css/developer.css +328 -0
- package/public/css/files.css +123 -0
- package/public/css/markdown.css +156 -0
- package/public/css/message-actions.css +278 -0
- package/public/css/mobile.css +614 -0
- package/public/css/panels-unified.css +483 -0
- package/public/css/premium.css +415 -0
- package/public/css/realtime.css +189 -0
- package/public/css/satellites.css +401 -0
- package/public/css/shortcuts.css +185 -0
- package/public/css/split-view.4def0262.css +673 -0
- package/public/css/split-view.css +673 -0
- package/public/css/theme-generator.css +391 -0
- package/public/css/themes.css +387 -0
- package/public/css/timestamps.css +54 -0
- package/public/css/variables.css +78 -0
- package/public/dist/bundle.b55050c4.js +15757 -0
- package/public/favicon.svg +24 -0
- package/public/img/agents/ada.png +0 -0
- package/public/img/agents/clarice.png +0 -0
- package/public/img/agents/dennis-nedry.png +0 -0
- package/public/img/agents/elliot-alderson.png +0 -0
- package/public/img/agents/main.png +0 -0
- package/public/img/agents/scotty.png +0 -0
- package/public/img/agents/top-flight-security.png +0 -0
- package/public/index.html +1083 -0
- package/public/js/agents-data.js +234 -0
- package/public/js/agents-ui.js +72 -0
- package/public/js/agents.js +1525 -0
- package/public/js/app.js +79 -0
- package/public/js/appearance-settings.js +111 -0
- package/public/js/artifacts.js +432 -0
- package/public/js/audio-queue.js +168 -0
- package/public/js/bootstrap.js +54 -0
- package/public/js/chat.js +1211 -0
- package/public/js/commands.js +581 -0
- package/public/js/connection-api.js +121 -0
- package/public/js/connection.js +1231 -0
- package/public/js/context-tracker.js +271 -0
- package/public/js/core.js +172 -0
- package/public/js/dashboard.js +452 -0
- package/public/js/developer.js +432 -0
- package/public/js/encryption.js +124 -0
- package/public/js/errors.js +122 -0
- package/public/js/event-bus.js +77 -0
- package/public/js/fetch-utils.js +171 -0
- package/public/js/file-handler.js +229 -0
- package/public/js/files.js +352 -0
- package/public/js/gateway-chat.js +538 -0
- package/public/js/logger.js +112 -0
- package/public/js/markdown.js +190 -0
- package/public/js/message-actions.js +431 -0
- package/public/js/message-renderer.js +288 -0
- package/public/js/missed-messages.js +235 -0
- package/public/js/mobile-debug.js +95 -0
- package/public/js/notifications.js +367 -0
- package/public/js/offline-queue.js +178 -0
- package/public/js/onboarding.js +543 -0
- package/public/js/panels.js +156 -0
- package/public/js/premium.js +412 -0
- package/public/js/realtime-voice.js +844 -0
- package/public/js/satellite-sync.js +256 -0
- package/public/js/satellite-ui.js +175 -0
- package/public/js/satellites.js +1516 -0
- package/public/js/settings.js +1087 -0
- package/public/js/shortcuts.js +381 -0
- package/public/js/split-chat.js +1234 -0
- package/public/js/split-resize.js +211 -0
- package/public/js/splitview.js +340 -0
- package/public/js/storage.js +408 -0
- package/public/js/streaming-handler.js +324 -0
- package/public/js/stt-settings.js +316 -0
- package/public/js/theme-generator.js +661 -0
- package/public/js/themes.js +164 -0
- package/public/js/timestamps.js +198 -0
- package/public/js/tts-settings.js +575 -0
- package/public/js/ui.js +267 -0
- package/public/js/update-notifier.js +143 -0
- package/public/js/utils/constants.js +165 -0
- package/public/js/utils/sanitize.js +93 -0
- package/public/js/utils/sse-parser.js +195 -0
- package/public/js/voice.js +883 -0
- package/public/manifest.json +58 -0
- package/public/moon_texture.jpg +0 -0
- package/public/sw.js +221 -0
- package/public/three.min.js +6 -0
- package/server/channel.js +529 -0
- package/server/chat.js +270 -0
- package/server/config-store.js +362 -0
- package/server/config.js +159 -0
- package/server/context.js +131 -0
- package/server/gateway-commands.js +211 -0
- package/server/gateway-proxy.js +318 -0
- package/server/index.js +22 -0
- package/server/logger.js +89 -0
- package/server/middleware/auth.js +188 -0
- package/server/middleware.js +218 -0
- package/server/openclaw-discover.js +308 -0
- package/server/premium/index.js +156 -0
- package/server/premium/license.js +140 -0
- package/server/realtime/bridge.js +837 -0
- package/server/realtime/index.js +349 -0
- package/server/realtime/tts-stream.js +446 -0
- package/server/routes/agents.js +564 -0
- package/server/routes/artifacts.js +174 -0
- package/server/routes/chat.js +311 -0
- package/server/routes/config-settings.js +345 -0
- package/server/routes/config.js +603 -0
- package/server/routes/files.js +307 -0
- package/server/routes/index.js +18 -0
- package/server/routes/media.js +451 -0
- package/server/routes/missed-messages.js +107 -0
- package/server/routes/premium.js +75 -0
- package/server/routes/push.js +156 -0
- package/server/routes/satellite.js +406 -0
- package/server/routes/status.js +251 -0
- package/server/routes/stt.js +35 -0
- package/server/routes/voice.js +260 -0
- package/server/routes/webhooks.js +203 -0
- package/server/routes.js +206 -0
- package/server/runtime-config.js +336 -0
- package/server/share.js +305 -0
- package/server/stt/faster-whisper.js +72 -0
- package/server/stt/groq.js +51 -0
- package/server/stt/index.js +196 -0
- package/server/stt/openai.js +49 -0
- package/server/sync.js +244 -0
- package/server/tailscale-https.js +175 -0
- package/server/tts.js +646 -0
- package/server/update-checker.js +172 -0
- package/server/utils/filename.js +129 -0
- package/server/utils.js +147 -0
- package/server/watchdog.js +318 -0
- package/server/websocket/broadcast.js +359 -0
- package/server/websocket/connections.js +339 -0
- package/server/websocket/index.js +215 -0
- package/server/websocket/routing.js +277 -0
- package/server/websocket/sync.js +102 -0
- package/server.js +404 -0
- package/utils/detect-tool-usage.js +93 -0
- package/utils/errors.js +158 -0
- package/utils/html-escape.js +84 -0
- package/utils/id-sanitize.js +94 -0
- package/utils/response.js +130 -0
- package/utils/with-retry.js +105 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media Routes - Image and video upload endpoints
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Sharp is optional — skip image compression if not installed
|
|
6
|
+
let sharp;
|
|
7
|
+
try {
|
|
8
|
+
sharp = (await import('sharp')).default;
|
|
9
|
+
} catch {
|
|
10
|
+
// sharp not available — images will be sent at original size
|
|
11
|
+
}
|
|
12
|
+
import fs from 'fs/promises';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import { fileTypeFromBuffer } from 'file-type';
|
|
15
|
+
import { randomUUID } from 'crypto';
|
|
16
|
+
import { badRequest, internalError, ErrorCodes } from '../../utils/errors.js';
|
|
17
|
+
import { IMAGE_COMPRESSION_THRESHOLD, AGENT_MEDIA_DIRS, AGENT_MEDIA_ALLOWED_EXTENSIONS, AGENT_MEDIA_MAX_SIZE } from '../config.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Sanitize user-provided caption to prevent prompt injection
|
|
21
|
+
* @param {string} caption - Raw user caption
|
|
22
|
+
* @returns {string} - Sanitized caption safe for AI prompts
|
|
23
|
+
*/
|
|
24
|
+
function sanitizeCaption(caption) {
|
|
25
|
+
if (!caption || typeof caption !== 'string') {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let sanitized = caption;
|
|
30
|
+
|
|
31
|
+
// 1. Limit length to prevent token abuse
|
|
32
|
+
const MAX_CAPTION_LENGTH = 500;
|
|
33
|
+
sanitized = sanitized.slice(0, MAX_CAPTION_LENGTH);
|
|
34
|
+
|
|
35
|
+
// 2. Remove markdown code blocks that could confuse AI
|
|
36
|
+
sanitized = sanitized.replace(/```[\s\S]*?```/g, '[code removed]');
|
|
37
|
+
sanitized = sanitized.replace(/`[^`]+`/g, '[code removed]');
|
|
38
|
+
|
|
39
|
+
// 3. Remove potential instruction patterns (prompt injection attempts)
|
|
40
|
+
// Patterns like "ignore previous instructions", "system:", "assistant:", etc.
|
|
41
|
+
const injectionPatterns = [
|
|
42
|
+
/\b(ignore|disregard|forget)\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|prompts?|context)/gi,
|
|
43
|
+
/\b(you\s+are\s+now|act\s+as|pretend\s+(to\s+be|you('re)?))\b/gi,
|
|
44
|
+
/\b(system|assistant|user)\s*:/gi,
|
|
45
|
+
/\[\s*(system|assistant|user)\s*\]/gi,
|
|
46
|
+
/<\s*(system|assistant|user)\s*>/gi,
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
for (const pattern of injectionPatterns) {
|
|
50
|
+
sanitized = sanitized.replace(pattern, '[removed]');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 4. Escape special characters that could be interpreted as delimiters
|
|
54
|
+
// Replace characters that might be used to break out of context
|
|
55
|
+
sanitized = sanitized
|
|
56
|
+
.replace(/[<>]/g, '') // Remove angle brackets
|
|
57
|
+
.replace(/\[{2,}/g, '[') // Collapse multiple brackets
|
|
58
|
+
.replace(/\]{2,}/g, ']')
|
|
59
|
+
.replace(/\n{3,}/g, '\n\n') // Collapse excessive newlines
|
|
60
|
+
.trim();
|
|
61
|
+
|
|
62
|
+
return sanitized;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Setup media routes
|
|
67
|
+
* @param {Express} app - Express app instance
|
|
68
|
+
* @param {Object} context - Request context
|
|
69
|
+
*/
|
|
70
|
+
export function setupMediaRoutes(app, context) {
|
|
71
|
+
const {
|
|
72
|
+
imageUpload,
|
|
73
|
+
videoUpload,
|
|
74
|
+
fetch: fetchWithTimeout,
|
|
75
|
+
generateTTS,
|
|
76
|
+
log,
|
|
77
|
+
config,
|
|
78
|
+
uploadsDir,
|
|
79
|
+
} = context;
|
|
80
|
+
|
|
81
|
+
const { ALLOWED_IMAGE_TYPES, GATEWAY_URL, GATEWAY_TOKEN, SESSION_USER, REQUEST_TIMEOUT } = config;
|
|
82
|
+
|
|
83
|
+
// ===========================================
|
|
84
|
+
// Image Upload
|
|
85
|
+
// ===========================================
|
|
86
|
+
|
|
87
|
+
app.post('/api/image', (req, res) => {
|
|
88
|
+
imageUpload(req, res, async (err) => {
|
|
89
|
+
if (err) {
|
|
90
|
+
log('error', '[Image] Upload error:', err);
|
|
91
|
+
// Always return generic error to client; detailed error logged server-side
|
|
92
|
+
return badRequest(res, 'Upload failed', ErrorCodes.UPLOAD_FAILED);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
if (!req.file) {
|
|
97
|
+
return badRequest(res, 'No image provided', ErrorCodes.MISSING_FIELD);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
log('debug', `[Image] Received: ${req.file.size} bytes, ${req.file.mimetype}`);
|
|
101
|
+
|
|
102
|
+
// Validate file type
|
|
103
|
+
const fileBuffer = await fs.readFile(req.file.path);
|
|
104
|
+
const detectedType = await fileTypeFromBuffer(fileBuffer);
|
|
105
|
+
|
|
106
|
+
if (!detectedType || !ALLOWED_IMAGE_TYPES.includes(detectedType.mime)) {
|
|
107
|
+
await fs.unlink(req.file.path).catch(err => log('warn', 'Image: Failed to cleanup invalid file:', err.message));
|
|
108
|
+
log('warn', `[Image] Rejected invalid file type: ${detectedType?.mime || 'unknown'}`);
|
|
109
|
+
return badRequest(res, 'Invalid image type. Allowed: JPEG, PNG, WebP, GIF', ErrorCodes.INVALID_FILE_TYPE);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Sanitize caption to prevent prompt injection
|
|
113
|
+
const caption = sanitizeCaption(req.body?.caption);
|
|
114
|
+
|
|
115
|
+
// Resize if needed (skip if sharp not installed)
|
|
116
|
+
let imageBuffer = fileBuffer;
|
|
117
|
+
if (sharp && imageBuffer.length > IMAGE_COMPRESSION_THRESHOLD) {
|
|
118
|
+
imageBuffer = await sharp(imageBuffer)
|
|
119
|
+
.resize(1024, 1024, { fit: 'inside', withoutEnlargement: true })
|
|
120
|
+
.jpeg({ quality: 80 })
|
|
121
|
+
.toBuffer();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Save image
|
|
125
|
+
await fs.mkdir(uploadsDir, { recursive: true });
|
|
126
|
+
const imageFilename = `upload-${randomUUID()}.jpg`;
|
|
127
|
+
const imagePath = path.join(uploadsDir, imageFilename);
|
|
128
|
+
await fs.writeFile(imagePath, imageBuffer);
|
|
129
|
+
|
|
130
|
+
// Send to OpenClaw
|
|
131
|
+
const imagePathForAgent = `uplink/uploads/${imageFilename}`;
|
|
132
|
+
const prompt = caption
|
|
133
|
+
? `[Voice chat - keep response brief] [Image attached at: ${imagePathForAgent}] ${caption}`
|
|
134
|
+
: `[Voice chat - keep response brief] [Image attached at: ${imagePathForAgent}] What do you see in this image? Use the image tool to view it.`;
|
|
135
|
+
|
|
136
|
+
// Use SSE to prevent Cloudflare timeout (sends keepalives while waiting)
|
|
137
|
+
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
|
138
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
139
|
+
res.setHeader('Connection', 'keep-alive');
|
|
140
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
141
|
+
res.flushHeaders();
|
|
142
|
+
|
|
143
|
+
// Send keepalive every 15s to prevent Cloudflare 524 timeout
|
|
144
|
+
const keepalive = setInterval(() => {
|
|
145
|
+
res.write(': keepalive\n\n');
|
|
146
|
+
}, 15000);
|
|
147
|
+
|
|
148
|
+
let reply = 'I could not process the image.';
|
|
149
|
+
let audioUrl = null;
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const response = await fetchWithTimeout(`${GATEWAY_URL}/v1/chat/completions`, {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers: {
|
|
155
|
+
'Content-Type': 'application/json',
|
|
156
|
+
'Authorization': `Bearer ${GATEWAY_TOKEN}`,
|
|
157
|
+
'x-openclaw-session-key': 'agent:main:main'
|
|
158
|
+
},
|
|
159
|
+
body: JSON.stringify({
|
|
160
|
+
model: 'openclaw',
|
|
161
|
+
user: SESSION_USER,
|
|
162
|
+
messages: [{ role: 'user', content: prompt }]
|
|
163
|
+
})
|
|
164
|
+
}, REQUEST_TIMEOUT);
|
|
165
|
+
|
|
166
|
+
if (!response.ok) {
|
|
167
|
+
const text = await response.text();
|
|
168
|
+
throw new Error(`Chat API error: ${response.status} - ${text}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const data = await response.json();
|
|
172
|
+
reply = data.choices?.[0]?.message?.content || reply;
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
audioUrl = await generateTTS(reply);
|
|
176
|
+
} catch (e) {
|
|
177
|
+
log('error', 'TTS failed:', e.message);
|
|
178
|
+
}
|
|
179
|
+
} finally {
|
|
180
|
+
clearInterval(keepalive);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Keep uploaded image for history (24h cleanup job handles expiry)
|
|
184
|
+
// Clean up the temp multer file if it differs from our saved path
|
|
185
|
+
if (req.file.path !== imagePath) {
|
|
186
|
+
await fs.unlink(req.file.path).catch(err => log('warn', 'Image: Failed to cleanup temp file:', err.message));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Send final result as SSE event — include imageUrl for client-side history
|
|
190
|
+
const imageUrl = `/uploads/${imageFilename}`;
|
|
191
|
+
res.write(`data: ${JSON.stringify({ response: reply, audioUrl, imageUrl })}\n\n`);
|
|
192
|
+
res.end();
|
|
193
|
+
} catch (error) {
|
|
194
|
+
log('error', 'Image error:', error);
|
|
195
|
+
if (res.headersSent) {
|
|
196
|
+
res.write(`data: ${JSON.stringify({ error: true, message: 'Image processing failed' })}\n\n`);
|
|
197
|
+
res.end();
|
|
198
|
+
} else {
|
|
199
|
+
internalError(res, 'Image processing failed', ErrorCodes.INTERNAL_ERROR);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ===========================================
|
|
206
|
+
// Video Upload
|
|
207
|
+
// ===========================================
|
|
208
|
+
|
|
209
|
+
const { ALLOWED_VIDEO_TYPES } = config;
|
|
210
|
+
|
|
211
|
+
app.post('/api/video', (req, res) => {
|
|
212
|
+
videoUpload(req, res, async (err) => {
|
|
213
|
+
if (err) {
|
|
214
|
+
log('error', '[Video] Upload error:', err);
|
|
215
|
+
// Always return generic error to client; detailed error logged server-side
|
|
216
|
+
return badRequest(res, 'Upload failed', ErrorCodes.UPLOAD_FAILED);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
if (!req.file) {
|
|
221
|
+
return badRequest(res, 'No video provided', ErrorCodes.MISSING_FIELD);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Validate video file type via magic bytes
|
|
225
|
+
const fileBuffer = await fs.readFile(req.file.path);
|
|
226
|
+
const detectedType = await fileTypeFromBuffer(fileBuffer);
|
|
227
|
+
|
|
228
|
+
if (!detectedType || !ALLOWED_VIDEO_TYPES.includes(detectedType.mime)) {
|
|
229
|
+
await fs.unlink(req.file.path).catch(err => log('warn', 'Video: Failed to cleanup invalid file:', err.message));
|
|
230
|
+
log('warn', `[Video] Rejected invalid type: ${detectedType?.mime || 'unknown'}`);
|
|
231
|
+
return badRequest(res, 'Invalid video type', ErrorCodes.INVALID_FILE_TYPE);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Sanitize caption to prevent prompt injection
|
|
235
|
+
const caption = sanitizeCaption(req.body?.caption);
|
|
236
|
+
|
|
237
|
+
await fs.mkdir(uploadsDir, { recursive: true });
|
|
238
|
+
|
|
239
|
+
// Use detected extension for proper file type
|
|
240
|
+
const ext = detectedType.ext || 'webm';
|
|
241
|
+
const videoFilename = `video-${randomUUID()}.${ext}`;
|
|
242
|
+
const videoPath = path.join(uploadsDir, videoFilename);
|
|
243
|
+
|
|
244
|
+
await fs.writeFile(videoPath, fileBuffer);
|
|
245
|
+
await fs.unlink(req.file.path).catch(err => log('warn', 'Video: Failed to cleanup file:', err.message));
|
|
246
|
+
|
|
247
|
+
const videoPathForAgent = `uplink/uploads/${videoFilename}`;
|
|
248
|
+
const prompt = caption
|
|
249
|
+
? `[Voice chat - keep response brief] [Video attached at: ${videoPathForAgent}] ${caption}`
|
|
250
|
+
: `[Voice chat - keep response brief] [Video attached at: ${videoPathForAgent}] A short video was recorded. Please acknowledge it.`;
|
|
251
|
+
|
|
252
|
+
// Use SSE to prevent Cloudflare timeout (sends keepalives while waiting)
|
|
253
|
+
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
|
254
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
255
|
+
res.setHeader('Connection', 'keep-alive');
|
|
256
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
257
|
+
res.flushHeaders();
|
|
258
|
+
|
|
259
|
+
// Send keepalive every 15s to prevent Cloudflare 524 timeout
|
|
260
|
+
const keepalive = setInterval(() => {
|
|
261
|
+
res.write(': keepalive\n\n');
|
|
262
|
+
}, 15000);
|
|
263
|
+
|
|
264
|
+
let reply = 'I received your video.';
|
|
265
|
+
let audioUrl = null;
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
const response = await fetchWithTimeout(`${GATEWAY_URL}/v1/chat/completions`, {
|
|
269
|
+
method: 'POST',
|
|
270
|
+
headers: {
|
|
271
|
+
'Content-Type': 'application/json',
|
|
272
|
+
'Authorization': `Bearer ${GATEWAY_TOKEN}`
|
|
273
|
+
},
|
|
274
|
+
body: JSON.stringify({
|
|
275
|
+
model: 'openclaw',
|
|
276
|
+
user: SESSION_USER,
|
|
277
|
+
messages: [{ role: 'user', content: prompt }]
|
|
278
|
+
})
|
|
279
|
+
}, REQUEST_TIMEOUT);
|
|
280
|
+
|
|
281
|
+
if (!response.ok) {
|
|
282
|
+
const text = await response.text();
|
|
283
|
+
throw new Error(`Chat API error: ${response.status} - ${text}`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const data = await response.json();
|
|
287
|
+
reply = data.choices?.[0]?.message?.content || reply;
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
audioUrl = await generateTTS(reply);
|
|
291
|
+
} catch (e) {
|
|
292
|
+
log('error', 'TTS failed:', e.message);
|
|
293
|
+
}
|
|
294
|
+
} finally {
|
|
295
|
+
clearInterval(keepalive);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Send final result as SSE event
|
|
299
|
+
res.write(`data: ${JSON.stringify({ response: reply, audioUrl })}\n\n`);
|
|
300
|
+
res.end();
|
|
301
|
+
} catch (error) {
|
|
302
|
+
log('error', 'Video error:', error);
|
|
303
|
+
if (res.headersSent) {
|
|
304
|
+
res.write(`data: ${JSON.stringify({ error: true, message: 'Video processing failed' })}\n\n`);
|
|
305
|
+
res.end();
|
|
306
|
+
} else {
|
|
307
|
+
internalError(res, 'Video processing failed', ErrorCodes.INTERNAL_ERROR);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// ===========================================
|
|
314
|
+
// Agent Media Endpoint
|
|
315
|
+
// Serves agent-generated files (screenshots, charts, TTS, etc.)
|
|
316
|
+
// ===========================================
|
|
317
|
+
|
|
318
|
+
// Registry: maps random IDs to file paths (in-memory for now)
|
|
319
|
+
const agentMediaRegistry = new Map();
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Register an agent media file and return a secure URL
|
|
323
|
+
* @param {string} filePath - Absolute path to the file
|
|
324
|
+
* @returns {string|null} - Proxy URL or null if registration failed
|
|
325
|
+
*/
|
|
326
|
+
function registerAgentMedia(filePath) {
|
|
327
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
328
|
+
log('warn', '[AgentMedia] Invalid file path');
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
// Normalize path for consistent comparison
|
|
334
|
+
const normalizedPath = path.normalize(path.resolve(filePath));
|
|
335
|
+
|
|
336
|
+
// Security: Validate file path is within allowed directories
|
|
337
|
+
const isAllowed = AGENT_MEDIA_DIRS.some(dir => {
|
|
338
|
+
const normalizedDir = path.normalize(path.resolve(dir));
|
|
339
|
+
return normalizedPath.startsWith(normalizedDir);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
if (!isAllowed) {
|
|
343
|
+
log('warn', `[AgentMedia] Path not in allowed directories: ${filePath}`);
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Security: Validate file extension
|
|
348
|
+
const ext = path.extname(normalizedPath).toLowerCase();
|
|
349
|
+
if (!AGENT_MEDIA_ALLOWED_EXTENSIONS.includes(ext)) {
|
|
350
|
+
log('warn', `[AgentMedia] Extension not allowed: ${ext}`);
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Generate random ID
|
|
355
|
+
const id = randomUUID();
|
|
356
|
+
agentMediaRegistry.set(id, normalizedPath);
|
|
357
|
+
|
|
358
|
+
// Auto-cleanup after 10 minutes
|
|
359
|
+
setTimeout(() => agentMediaRegistry.delete(id), 10 * 60 * 1000);
|
|
360
|
+
|
|
361
|
+
return `/api/media/agent/${id}`;
|
|
362
|
+
} catch (error) {
|
|
363
|
+
log('error', '[AgentMedia] Registration failed:', error);
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Serve agent-generated media file
|
|
370
|
+
* GET /api/media/agent/:id
|
|
371
|
+
*/
|
|
372
|
+
app.get('/api/media/agent/:id', async (req, res) => {
|
|
373
|
+
try {
|
|
374
|
+
const { id } = req.params;
|
|
375
|
+
|
|
376
|
+
// Validate ID format (UUID)
|
|
377
|
+
if (!id || !/^[a-f0-9-]{36}$/i.test(id)) {
|
|
378
|
+
return badRequest(res, 'Invalid media ID', ErrorCodes.INVALID_FILE_TYPE);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Lookup file path
|
|
382
|
+
const filePath = agentMediaRegistry.get(id);
|
|
383
|
+
if (!filePath) {
|
|
384
|
+
log('warn', `[AgentMedia] ID not found or expired: ${id}`);
|
|
385
|
+
return res.status(404).json({ error: 'Media not found or expired' });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Security: Double-check path is still within allowed directories
|
|
389
|
+
const normalizedPath = path.normalize(path.resolve(filePath));
|
|
390
|
+
const isAllowed = AGENT_MEDIA_DIRS.some(dir => {
|
|
391
|
+
const normalizedDir = path.normalize(path.resolve(dir));
|
|
392
|
+
return normalizedPath.startsWith(normalizedDir);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
if (!isAllowed) {
|
|
396
|
+
log('error', `[AgentMedia] Security violation: path outside allowed dirs: ${filePath}`);
|
|
397
|
+
agentMediaRegistry.delete(id);
|
|
398
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Check file exists
|
|
402
|
+
const stat = await fs.stat(filePath).catch(() => null);
|
|
403
|
+
if (!stat || !stat.isFile()) {
|
|
404
|
+
log('warn', `[AgentMedia] File not found: ${filePath}`);
|
|
405
|
+
agentMediaRegistry.delete(id);
|
|
406
|
+
return res.status(404).json({ error: 'File not found' });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Security: Check file size
|
|
410
|
+
if (stat.size > AGENT_MEDIA_MAX_SIZE) {
|
|
411
|
+
log('warn', `[AgentMedia] File too large: ${stat.size} bytes`);
|
|
412
|
+
return res.status(413).json({ error: 'File too large' });
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Detect MIME type
|
|
416
|
+
const buffer = await fs.readFile(filePath);
|
|
417
|
+
const detected = await fileTypeFromBuffer(buffer);
|
|
418
|
+
const contentType = detected?.mime || 'application/octet-stream';
|
|
419
|
+
|
|
420
|
+
// Set headers
|
|
421
|
+
res.setHeader('Content-Type', contentType);
|
|
422
|
+
res.setHeader('Content-Length', stat.size);
|
|
423
|
+
res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour cache
|
|
424
|
+
|
|
425
|
+
// Security: Prevent inline rendering for untrusted types
|
|
426
|
+
const safeInlineTypes = [
|
|
427
|
+
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
|
|
428
|
+
'audio/mpeg', 'audio/wav', 'audio/ogg',
|
|
429
|
+
];
|
|
430
|
+
if (!safeInlineTypes.includes(contentType)) {
|
|
431
|
+
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(filePath)}"`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Send file
|
|
435
|
+
res.send(buffer);
|
|
436
|
+
log('debug', `[AgentMedia] Served: ${filePath} (${contentType}, ${stat.size} bytes)`);
|
|
437
|
+
|
|
438
|
+
} catch (error) {
|
|
439
|
+
log('error', '[AgentMedia] Serve error:', error);
|
|
440
|
+
if (!res.headersSent) {
|
|
441
|
+
internalError(res, 'Failed to serve media', ErrorCodes.INTERNAL_ERROR);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// Export the registration function for use by other modules
|
|
447
|
+
app.registerAgentMedia = registerAgentMedia;
|
|
448
|
+
|
|
449
|
+
// Also expose globally for channel.js to access
|
|
450
|
+
globalThis.registerAgentMedia = registerAgentMedia;
|
|
451
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Missed Messages Routes - Simple polling fallback for when tab is closed
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from 'fs/promises';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { createLogger } from '../logger.js';
|
|
8
|
+
// Auth handled by requireAuth middleware on /api/ routes (server.js)
|
|
9
|
+
|
|
10
|
+
const log = createLogger('Missed Messages');
|
|
11
|
+
|
|
12
|
+
const MISSED_MESSAGES_FILE = path.join(process.cwd(), 'missed-messages.json');
|
|
13
|
+
|
|
14
|
+
// In-memory queue for missed messages
|
|
15
|
+
let missedMessages = [];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Load missed messages from file on startup
|
|
19
|
+
*/
|
|
20
|
+
async function loadMissedMessages() {
|
|
21
|
+
try {
|
|
22
|
+
const data = await fs.readFile(MISSED_MESSAGES_FILE, 'utf8');
|
|
23
|
+
missedMessages = JSON.parse(data);
|
|
24
|
+
log.info(`Loaded ${missedMessages.length} missed messages`);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
log.debug('No existing missed messages file');
|
|
27
|
+
missedMessages = [];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Save missed messages to file
|
|
33
|
+
*/
|
|
34
|
+
async function saveMissedMessages() {
|
|
35
|
+
try {
|
|
36
|
+
await fs.writeFile(MISSED_MESSAGES_FILE, JSON.stringify(missedMessages, null, 2));
|
|
37
|
+
} catch (error) {
|
|
38
|
+
log.error('Error saving to file:', error.message);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Add a message to the missed messages queue
|
|
44
|
+
*/
|
|
45
|
+
export async function addMissedMessage(message) {
|
|
46
|
+
const missedMessage = {
|
|
47
|
+
id: Date.now() + '_' + Math.random().toString(36),
|
|
48
|
+
timestamp: Date.now(),
|
|
49
|
+
type: message.type,
|
|
50
|
+
message: message.message,
|
|
51
|
+
satelliteId: message.satelliteId,
|
|
52
|
+
author: message.author || 'Assistant'
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
missedMessages.push(missedMessage);
|
|
56
|
+
|
|
57
|
+
// Keep only last 50 missed messages to prevent unbounded growth
|
|
58
|
+
if (missedMessages.length > 50) {
|
|
59
|
+
missedMessages = missedMessages.slice(-50);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
await saveMissedMessages();
|
|
63
|
+
log.debug(`Added message for satellite ${message.satelliteId}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get and clear missed messages for a user
|
|
68
|
+
*/
|
|
69
|
+
function getMissedMessages(userId = 'default') {
|
|
70
|
+
const messages = [...missedMessages]; // Copy the array
|
|
71
|
+
missedMessages = []; // Clear the queue
|
|
72
|
+
saveMissedMessages(); // Save empty queue
|
|
73
|
+
log.debug(`Retrieved ${messages.length} messages for ${userId}`);
|
|
74
|
+
return messages;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Setup missed messages routes
|
|
79
|
+
*/
|
|
80
|
+
export function setupMissedMessagesRoutes(app, context) {
|
|
81
|
+
const { log } = context;
|
|
82
|
+
|
|
83
|
+
// Load missed messages on startup
|
|
84
|
+
loadMissedMessages();
|
|
85
|
+
|
|
86
|
+
// GET /api/missed-messages - Get and clear missed messages
|
|
87
|
+
app.get('/api/missed-messages', (req, res) => {
|
|
88
|
+
const userId = req.query.userId || 'default';
|
|
89
|
+
const messages = getMissedMessages(userId);
|
|
90
|
+
|
|
91
|
+
res.json({
|
|
92
|
+
ok: true,
|
|
93
|
+
messages,
|
|
94
|
+
count: messages.length
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// GET /api/missed-messages/count - Just get count without clearing
|
|
99
|
+
app.get('/api/missed-messages/count', (req, res) => {
|
|
100
|
+
res.json({
|
|
101
|
+
ok: true,
|
|
102
|
+
count: missedMessages.length
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
log('info', '[Missed Messages] Routes initialized');
|
|
107
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Premium Routes — License activation and status
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { activateLicense, deactivateLicense, getPremiumStatus } from '../premium/index.js';
|
|
6
|
+
|
|
7
|
+
export function setupPremiumRoutes(app, context) {
|
|
8
|
+
const { log, saveConfig, loadConfig } = context;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* GET /api/premium/status
|
|
12
|
+
* Returns current premium status and feature flags
|
|
13
|
+
*/
|
|
14
|
+
app.get('/api/premium/status', (req, res) => {
|
|
15
|
+
res.json(getPremiumStatus());
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* POST /api/premium/activate
|
|
20
|
+
* Activate a license key
|
|
21
|
+
* Body: { key: "UPL-XXXXX-XXXXX-XXXXX-XXXXX" }
|
|
22
|
+
*/
|
|
23
|
+
app.post('/api/premium/activate', async (req, res) => {
|
|
24
|
+
const { key } = req.body || {};
|
|
25
|
+
|
|
26
|
+
if (!key || typeof key !== 'string') {
|
|
27
|
+
return res.status(400).json({
|
|
28
|
+
error: true,
|
|
29
|
+
message: 'License key is required',
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const result = activateLicense(key.trim());
|
|
34
|
+
|
|
35
|
+
if (result.success) {
|
|
36
|
+
// Persist the key to config
|
|
37
|
+
try {
|
|
38
|
+
await saveConfig({ licenseKey: key.trim() });
|
|
39
|
+
} catch (err) {
|
|
40
|
+
log('error', 'Failed to save license key:', err.message);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return res.json({
|
|
44
|
+
success: true,
|
|
45
|
+
message: 'Uplink Premium activated! 🎉',
|
|
46
|
+
...getPremiumStatus(),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return res.status(400).json({
|
|
51
|
+
error: true,
|
|
52
|
+
message: result.error || 'Invalid license key',
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* POST /api/premium/deactivate
|
|
58
|
+
* Remove license key and revert to free mode
|
|
59
|
+
*/
|
|
60
|
+
app.post('/api/premium/deactivate', async (req, res) => {
|
|
61
|
+
deactivateLicense();
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
await saveConfig({ licenseKey: '' });
|
|
65
|
+
} catch (err) {
|
|
66
|
+
log('error', 'Failed to remove license key:', err.message);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return res.json({
|
|
70
|
+
success: true,
|
|
71
|
+
message: 'License deactivated',
|
|
72
|
+
...getPremiumStatus(),
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|