@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.

Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/bin/uplink.js +279 -0
  4. package/middleware/error-handler.js +69 -0
  5. package/package.json +93 -0
  6. package/public/css/agents.36b98c0f.css +1469 -0
  7. package/public/css/agents.css +1469 -0
  8. package/public/css/app.a6a7f8f5.css +2731 -0
  9. package/public/css/app.css +2731 -0
  10. package/public/css/artifacts.css +444 -0
  11. package/public/css/commands.css +55 -0
  12. package/public/css/connection.css +131 -0
  13. package/public/css/dashboard.css +233 -0
  14. package/public/css/developer.css +328 -0
  15. package/public/css/files.css +123 -0
  16. package/public/css/markdown.css +156 -0
  17. package/public/css/message-actions.css +278 -0
  18. package/public/css/mobile.css +614 -0
  19. package/public/css/panels-unified.css +483 -0
  20. package/public/css/premium.css +415 -0
  21. package/public/css/realtime.css +189 -0
  22. package/public/css/satellites.css +401 -0
  23. package/public/css/shortcuts.css +185 -0
  24. package/public/css/split-view.4def0262.css +673 -0
  25. package/public/css/split-view.css +673 -0
  26. package/public/css/theme-generator.css +391 -0
  27. package/public/css/themes.css +387 -0
  28. package/public/css/timestamps.css +54 -0
  29. package/public/css/variables.css +78 -0
  30. package/public/dist/bundle.b55050c4.js +15757 -0
  31. package/public/favicon.svg +24 -0
  32. package/public/img/agents/ada.png +0 -0
  33. package/public/img/agents/clarice.png +0 -0
  34. package/public/img/agents/dennis-nedry.png +0 -0
  35. package/public/img/agents/elliot-alderson.png +0 -0
  36. package/public/img/agents/main.png +0 -0
  37. package/public/img/agents/scotty.png +0 -0
  38. package/public/img/agents/top-flight-security.png +0 -0
  39. package/public/index.html +1083 -0
  40. package/public/js/agents-data.js +234 -0
  41. package/public/js/agents-ui.js +72 -0
  42. package/public/js/agents.js +1525 -0
  43. package/public/js/app.js +79 -0
  44. package/public/js/appearance-settings.js +111 -0
  45. package/public/js/artifacts.js +432 -0
  46. package/public/js/audio-queue.js +168 -0
  47. package/public/js/bootstrap.js +54 -0
  48. package/public/js/chat.js +1211 -0
  49. package/public/js/commands.js +581 -0
  50. package/public/js/connection-api.js +121 -0
  51. package/public/js/connection.js +1231 -0
  52. package/public/js/context-tracker.js +271 -0
  53. package/public/js/core.js +172 -0
  54. package/public/js/dashboard.js +452 -0
  55. package/public/js/developer.js +432 -0
  56. package/public/js/encryption.js +124 -0
  57. package/public/js/errors.js +122 -0
  58. package/public/js/event-bus.js +77 -0
  59. package/public/js/fetch-utils.js +171 -0
  60. package/public/js/file-handler.js +229 -0
  61. package/public/js/files.js +352 -0
  62. package/public/js/gateway-chat.js +538 -0
  63. package/public/js/logger.js +112 -0
  64. package/public/js/markdown.js +190 -0
  65. package/public/js/message-actions.js +431 -0
  66. package/public/js/message-renderer.js +288 -0
  67. package/public/js/missed-messages.js +235 -0
  68. package/public/js/mobile-debug.js +95 -0
  69. package/public/js/notifications.js +367 -0
  70. package/public/js/offline-queue.js +178 -0
  71. package/public/js/onboarding.js +543 -0
  72. package/public/js/panels.js +156 -0
  73. package/public/js/premium.js +412 -0
  74. package/public/js/realtime-voice.js +844 -0
  75. package/public/js/satellite-sync.js +256 -0
  76. package/public/js/satellite-ui.js +175 -0
  77. package/public/js/satellites.js +1516 -0
  78. package/public/js/settings.js +1087 -0
  79. package/public/js/shortcuts.js +381 -0
  80. package/public/js/split-chat.js +1234 -0
  81. package/public/js/split-resize.js +211 -0
  82. package/public/js/splitview.js +340 -0
  83. package/public/js/storage.js +408 -0
  84. package/public/js/streaming-handler.js +324 -0
  85. package/public/js/stt-settings.js +316 -0
  86. package/public/js/theme-generator.js +661 -0
  87. package/public/js/themes.js +164 -0
  88. package/public/js/timestamps.js +198 -0
  89. package/public/js/tts-settings.js +575 -0
  90. package/public/js/ui.js +267 -0
  91. package/public/js/update-notifier.js +143 -0
  92. package/public/js/utils/constants.js +165 -0
  93. package/public/js/utils/sanitize.js +93 -0
  94. package/public/js/utils/sse-parser.js +195 -0
  95. package/public/js/voice.js +883 -0
  96. package/public/manifest.json +58 -0
  97. package/public/moon_texture.jpg +0 -0
  98. package/public/sw.js +221 -0
  99. package/public/three.min.js +6 -0
  100. package/server/channel.js +529 -0
  101. package/server/chat.js +270 -0
  102. package/server/config-store.js +362 -0
  103. package/server/config.js +159 -0
  104. package/server/context.js +131 -0
  105. package/server/gateway-commands.js +211 -0
  106. package/server/gateway-proxy.js +318 -0
  107. package/server/index.js +22 -0
  108. package/server/logger.js +89 -0
  109. package/server/middleware/auth.js +188 -0
  110. package/server/middleware.js +218 -0
  111. package/server/openclaw-discover.js +308 -0
  112. package/server/premium/index.js +156 -0
  113. package/server/premium/license.js +140 -0
  114. package/server/realtime/bridge.js +837 -0
  115. package/server/realtime/index.js +349 -0
  116. package/server/realtime/tts-stream.js +446 -0
  117. package/server/routes/agents.js +564 -0
  118. package/server/routes/artifacts.js +174 -0
  119. package/server/routes/chat.js +311 -0
  120. package/server/routes/config-settings.js +345 -0
  121. package/server/routes/config.js +603 -0
  122. package/server/routes/files.js +307 -0
  123. package/server/routes/index.js +18 -0
  124. package/server/routes/media.js +451 -0
  125. package/server/routes/missed-messages.js +107 -0
  126. package/server/routes/premium.js +75 -0
  127. package/server/routes/push.js +156 -0
  128. package/server/routes/satellite.js +406 -0
  129. package/server/routes/status.js +251 -0
  130. package/server/routes/stt.js +35 -0
  131. package/server/routes/voice.js +260 -0
  132. package/server/routes/webhooks.js +203 -0
  133. package/server/routes.js +206 -0
  134. package/server/runtime-config.js +336 -0
  135. package/server/share.js +305 -0
  136. package/server/stt/faster-whisper.js +72 -0
  137. package/server/stt/groq.js +51 -0
  138. package/server/stt/index.js +196 -0
  139. package/server/stt/openai.js +49 -0
  140. package/server/sync.js +244 -0
  141. package/server/tailscale-https.js +175 -0
  142. package/server/tts.js +646 -0
  143. package/server/update-checker.js +172 -0
  144. package/server/utils/filename.js +129 -0
  145. package/server/utils.js +147 -0
  146. package/server/watchdog.js +318 -0
  147. package/server/websocket/broadcast.js +359 -0
  148. package/server/websocket/connections.js +339 -0
  149. package/server/websocket/index.js +215 -0
  150. package/server/websocket/routing.js +277 -0
  151. package/server/websocket/sync.js +102 -0
  152. package/server.js +404 -0
  153. package/utils/detect-tool-usage.js +93 -0
  154. package/utils/errors.js +158 -0
  155. package/utils/html-escape.js +84 -0
  156. package/utils/id-sanitize.js +94 -0
  157. package/utils/response.js +130 -0
  158. 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
+ }