@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,336 @@
1
+ /**
2
+ * Runtime Config Module
3
+ *
4
+ * Manages runtime configuration stored in config.json.
5
+ * Falls back to environment variables for backwards compatibility.
6
+ */
7
+
8
+ import fs from 'fs/promises';
9
+ import path from 'path';
10
+ import { fileURLToPath } from 'url';
11
+ import lockfile from 'proper-lockfile';
12
+ import { isEdgeTTSAvailable } from './tts.js';
13
+ import { discoverGateway, getConfigPath } from './openclaw-discover.js';
14
+ import { createLogger } from './logger.js';
15
+
16
+ const log = createLogger('Config');
17
+
18
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
+ const CONFIG_PATH = path.join(__dirname, '..', 'config.json');
20
+
21
+ // Default configuration
22
+ const DEFAULTS = {
23
+ // Identity
24
+ userName: '',
25
+ assistantName: 'Assistant',
26
+
27
+ // Gateway
28
+ gatewayUrl: 'http://127.0.0.1:18789',
29
+ gatewayToken: '',
30
+
31
+ // Session
32
+ sessionUser: 'uplink-user',
33
+
34
+ // TTS
35
+ ttsProvider: 'none', // 'none', 'elevenlabs', 'openai', 'edge', 'piper', 'local'
36
+ elevenLabsApiKey: '',
37
+ elevenLabsVoiceId: '',
38
+ ttsVoiceName: 'Assistant',
39
+
40
+ // OpenAI TTS
41
+ openaiApiKey: '',
42
+ openaiTtsVoice: 'nova', // alloy, echo, fable, onyx, nova, shimmer
43
+ openaiTtsModel: 'tts-1', // tts-1, tts-1-hd
44
+
45
+ // Edge TTS
46
+ edgeTtsVoice: 'en-US-AriaNeural',
47
+
48
+ // XTTS (Local GPU)
49
+ localTtsUrl: '', // e.g. http://localhost:8020
50
+
51
+ // STT
52
+ sttProvider: 'none', // 'none', 'openai', 'groq', 'faster-whisper'
53
+ openaiSttModel: 'whisper-1',
54
+ groqApiKey: '',
55
+ groqSttModel: 'whisper-large-v3-turbo',
56
+ fasterWhisperUrl: '', // e.g. http://localhost:8000
57
+
58
+ // Real-time Voice
59
+ realtimeVoiceEnabled: false,
60
+ realtimeVoice: 'marin',
61
+ realtimeModel: 'gpt-realtime',
62
+
63
+ // Voice Mode
64
+ voiceMode: 'push-to-talk', // 'push-to-talk', 'live-voice', 'agent-voice'
65
+ agentVoiceTtsEngine: 'openai', // 'openai', 'edge'
66
+ agentVoiceTtsVoice: 'alloy',
67
+ voiceModel: '', // Model override for voice sessions (empty = use gateway default)
68
+ vadSilenceDurationMs: 400, // How long silence before VAD triggers (200-1500ms)
69
+
70
+ // Security
71
+ encryptHistory: false,
72
+
73
+ // UI
74
+ wakeWord: 'uplink',
75
+
76
+ // Server
77
+ port: 3456,
78
+ allowedOrigins: ['http://localhost:3456'],
79
+
80
+ // Premium
81
+ licenseKey: '',
82
+
83
+ // State
84
+ onboardingComplete: false,
85
+ };
86
+
87
+ let cachedConfig = null;
88
+ let cacheTimestamp = 0;
89
+ const CACHE_TTL_MS = 5000; // Cache for 5 seconds to detect external changes
90
+
91
+ // Cached auto-discovery result (only runs once at startup)
92
+ let discoveryResult = null;
93
+ let discoveryAttempted = false;
94
+
95
+ /**
96
+ * Load configuration from file, with env fallbacks
97
+ */
98
+ export async function loadConfig() {
99
+ // Check if cache is still fresh
100
+ const now = Date.now();
101
+ if (cachedConfig && (now - cacheTimestamp) < CACHE_TTL_MS) {
102
+ return cachedConfig;
103
+ }
104
+
105
+ let fileConfig = {};
106
+
107
+ try {
108
+ const data = await fs.readFile(CONFIG_PATH, 'utf8');
109
+ fileConfig = JSON.parse(data);
110
+ } catch (err) {
111
+ // File doesn't exist or is invalid - that's fine, use defaults
112
+ if (err.code !== 'ENOENT') {
113
+ log.warn('Failed to parse config.json:', err.message);
114
+ }
115
+ }
116
+
117
+ // Merge with defaults and env overrides
118
+ cachedConfig = {
119
+ ...DEFAULTS,
120
+ ...fileConfig,
121
+ // Env vars take precedence if set (for backwards compat)
122
+ ...(process.env.GATEWAY_URL && { gatewayUrl: process.env.GATEWAY_URL }),
123
+ ...(process.env.GATEWAY_TOKEN && { gatewayToken: process.env.GATEWAY_TOKEN }),
124
+ ...(process.env.SESSION_USER && { sessionUser: process.env.SESSION_USER }),
125
+ ...(process.env.ASSISTANT_NAME && { assistantName: process.env.ASSISTANT_NAME }),
126
+ ...(process.env.ELEVENLABS_API_KEY && { elevenLabsApiKey: process.env.ELEVENLABS_API_KEY }),
127
+ ...(process.env.ELEVENLABS_VOICE_ID && { elevenLabsVoiceId: process.env.ELEVENLABS_VOICE_ID }),
128
+ ...(process.env.TTS_VOICE_NAME && { ttsVoiceName: process.env.TTS_VOICE_NAME }),
129
+ ...(process.env.OPENAI_API_KEY && process.env.OPENAI_API_KEY.startsWith('sk-') && { openaiApiKey: process.env.OPENAI_API_KEY }),
130
+ ...(process.env.OPENAI_TTS_VOICE && { openaiTtsVoice: process.env.OPENAI_TTS_VOICE }),
131
+ ...(process.env.OPENAI_TTS_MODEL && { openaiTtsModel: process.env.OPENAI_TTS_MODEL }),
132
+ ...(process.env.EDGE_TTS_VOICE && { edgeTtsVoice: process.env.EDGE_TTS_VOICE }),
133
+ ...(process.env.LOCAL_TTS_URL && { localTtsUrl: process.env.LOCAL_TTS_URL }),
134
+ // STT env overrides
135
+ ...(process.env.STT_PROVIDER && { sttProvider: process.env.STT_PROVIDER }),
136
+ ...(process.env.GROQ_API_KEY && { groqApiKey: process.env.GROQ_API_KEY }),
137
+ ...(process.env.GROQ_STT_MODEL && { groqSttModel: process.env.GROQ_STT_MODEL }),
138
+ ...(process.env.FASTER_WHISPER_URL && { fasterWhisperUrl: process.env.FASTER_WHISPER_URL }),
139
+ ...(process.env.WAKE_WORD && { wakeWord: process.env.WAKE_WORD.toLowerCase() }),
140
+ ...(process.env.PORT && { port: parseInt(process.env.PORT, 10) }),
141
+ ...(process.env.ALLOWED_ORIGINS && {
142
+ allowedOrigins: process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim())
143
+ }),
144
+ };
145
+
146
+ // Auto-discover OpenClaw gateway if not explicitly configured
147
+ // Priority: env vars > config.json > auto-discovery > defaults
148
+ const hasGatewayConfig = process.env.GATEWAY_URL || process.env.GATEWAY_TOKEN ||
149
+ fileConfig.gatewayUrl || fileConfig.gatewayToken;
150
+
151
+ if (!hasGatewayConfig && !discoveryAttempted) {
152
+ discoveryAttempted = true;
153
+ try {
154
+ discoveryResult = await discoverGateway();
155
+ if (discoveryResult) {
156
+ log.info(`Auto-discovered OpenClaw gateway: ${discoveryResult.url} (source: ${discoveryResult.source})`);
157
+ }
158
+ } catch (err) {
159
+ log.warn('Gateway auto-discovery failed:', err.message);
160
+ }
161
+ }
162
+
163
+ // Apply discovered values if no explicit config exists
164
+ if (discoveryResult && !hasGatewayConfig) {
165
+ if (discoveryResult.url && cachedConfig.gatewayUrl === DEFAULTS.gatewayUrl) {
166
+ cachedConfig.gatewayUrl = discoveryResult.url;
167
+ }
168
+ if (discoveryResult.token && !cachedConfig.gatewayToken) {
169
+ cachedConfig.gatewayToken = discoveryResult.token;
170
+ }
171
+ // Mark onboarding as complete if we have both URL and token
172
+ if (discoveryResult.url && discoveryResult.token && discoveryResult.verified) {
173
+ cachedConfig.onboardingComplete = true;
174
+ cachedConfig._autoDiscovered = true; // Flag for UI to show discovery status
175
+ }
176
+ }
177
+
178
+ // Auto-detect TTS provider only if not explicitly set
179
+ // (undefined means not configured, 'none' means user explicitly disabled)
180
+ if (cachedConfig.ttsProvider === undefined) {
181
+ if (cachedConfig.elevenLabsApiKey) {
182
+ cachedConfig.ttsProvider = 'elevenlabs';
183
+ } else {
184
+ cachedConfig.ttsProvider = 'none';
185
+ }
186
+ }
187
+
188
+ // Update cache timestamp
189
+ cacheTimestamp = Date.now();
190
+
191
+ return cachedConfig;
192
+ }
193
+
194
+ /**
195
+ * Save configuration to file (with file locking to prevent race conditions)
196
+ */
197
+ export async function saveConfig(updates) {
198
+ // Acquire lock before read-modify-write
199
+ let release;
200
+ try {
201
+ // Create file if it doesn't exist (lockfile needs the file to exist)
202
+ try {
203
+ await fs.access(CONFIG_PATH);
204
+ } catch {
205
+ await fs.writeFile(CONFIG_PATH, JSON.stringify(DEFAULTS, null, 2), { mode: 0o600 });
206
+ }
207
+
208
+ release = await lockfile.lock(CONFIG_PATH, {
209
+ retries: { retries: 5, minTimeout: 100, maxTimeout: 1000 }
210
+ });
211
+
212
+ const current = await loadConfig();
213
+
214
+ // Merge updates
215
+ const newConfig = {
216
+ ...current,
217
+ ...updates,
218
+ };
219
+
220
+ // Don't save sensitive env-provided values to file
221
+ const toSave = { ...newConfig };
222
+
223
+ // If the value came from env or auto-discovery, don't persist it to file
224
+ if (process.env.GATEWAY_TOKEN === toSave.gatewayToken) {
225
+ delete toSave.gatewayToken;
226
+ }
227
+ if (discoveryResult?.token === toSave.gatewayToken && !toSave.onboardingComplete) {
228
+ delete toSave.gatewayToken; // Don't duplicate OpenClaw's token into Uplink config
229
+ }
230
+ if (discoveryResult?.url === toSave.gatewayUrl && !toSave.onboardingComplete) {
231
+ delete toSave.gatewayUrl; // Don't duplicate OpenClaw's URL into Uplink config
232
+ }
233
+ if (process.env.ELEVENLABS_API_KEY === toSave.elevenLabsApiKey) {
234
+ delete toSave.elevenLabsApiKey;
235
+ }
236
+ if (process.env.OPENAI_API_KEY === toSave.openaiApiKey) {
237
+ delete toSave.openaiApiKey;
238
+ }
239
+ if (process.env.GROQ_API_KEY === toSave.groqApiKey) {
240
+ delete toSave.groqApiKey;
241
+ }
242
+
243
+ // Remove internal flags before persisting
244
+ delete toSave._autoDiscovered;
245
+
246
+ await fs.writeFile(CONFIG_PATH, JSON.stringify(toSave, null, 2), { mode: 0o600 });
247
+
248
+ // Clear cache to reload
249
+ cachedConfig = null;
250
+ cacheTimestamp = 0;
251
+
252
+ return await loadConfig();
253
+ } finally {
254
+ // Always release lock
255
+ if (release) {
256
+ await release();
257
+ }
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Get config for client (hides sensitive values)
263
+ */
264
+ export async function getClientConfig() {
265
+ const config = await loadConfig();
266
+ const { detectLocalSTT } = await import('./stt/index.js');
267
+ const localSTT = await detectLocalSTT();
268
+
269
+ return {
270
+ userName: config.userName,
271
+ assistantName: config.assistantName,
272
+ gatewayUrl: config.gatewayUrl,
273
+ hasGatewayToken: !!config.gatewayToken,
274
+ sessionUser: config.sessionUser,
275
+ ttsProvider: config.ttsProvider,
276
+ hasElevenLabsKey: !!config.elevenLabsApiKey,
277
+ elevenLabsVoiceId: config.elevenLabsVoiceId,
278
+ ttsVoiceName: config.ttsVoiceName,
279
+ edgeTtsAvailable: isEdgeTTSAvailable(),
280
+ encryptHistory: config.encryptHistory,
281
+ wakeWord: config.wakeWord,
282
+ onboardingComplete: config.onboardingComplete,
283
+ autoDiscovered: !!config._autoDiscovered, // True if gateway was found via OpenClaw config
284
+ // OpenAI TTS
285
+ openaiTtsVoice: config.openaiTtsVoice,
286
+ openaiTtsModel: config.openaiTtsModel,
287
+ hasOpenaiKey: !!(config.openaiApiKey || process.env.OPENAI_API_KEY),
288
+ // Edge TTS
289
+ edgeTtsVoice: config.edgeTtsVoice,
290
+ // XTTS
291
+ localTtsUrl: config.localTtsUrl,
292
+ // Piper
293
+ piperConfigured: !!(process.env.PIPER_MODEL),
294
+ // STT
295
+ sttProvider: config.sttProvider,
296
+ hasGroqKey: !!config.groqApiKey,
297
+ groqSttModel: config.groqSttModel,
298
+ openaiSttModel: config.openaiSttModel,
299
+ fasterWhisperUrl: config.fasterWhisperUrl,
300
+ fasterWhisperDetected: localSTT.fasterWhisperAvailable,
301
+ // Real-time Voice
302
+ realtimeVoiceEnabled: config.realtimeVoiceEnabled,
303
+ realtimeVoice: config.realtimeVoice,
304
+ realtimeModel: config.realtimeModel,
305
+ // Voice Mode
306
+ voiceMode: config.voiceMode,
307
+ voiceModel: config.voiceModel,
308
+ agentVoiceTtsEngine: config.agentVoiceTtsEngine,
309
+ agentVoiceTtsVoice: config.agentVoiceTtsVoice,
310
+ vadSilenceDurationMs: config.vadSilenceDurationMs,
311
+ };
312
+ }
313
+
314
+ /**
315
+ * Check if onboarding is needed
316
+ */
317
+ export async function needsOnboarding() {
318
+ const config = await loadConfig();
319
+ return !config.onboardingComplete || !config.gatewayToken;
320
+ }
321
+
322
+ /**
323
+ * Clear cached config (for testing or hot reload)
324
+ */
325
+ export function clearConfigCache() {
326
+ cachedConfig = null;
327
+ cacheTimestamp = 0;
328
+ }
329
+
330
+ export default {
331
+ loadConfig,
332
+ saveConfig,
333
+ getClientConfig,
334
+ needsOnboarding,
335
+ clearConfigCache,
336
+ };
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Share Module - Public share links for conversations
3
+ */
4
+
5
+ import { randomUUID } from 'crypto';
6
+ import fs from 'fs/promises';
7
+ import path from 'path';
8
+ import { log } from './utils.js';
9
+ import { escapeHtml, escapeForJS } from '../utils/html-escape.js';
10
+ import { sanitizeShareId, parseNumericParam } from '../utils/id-sanitize.js';
11
+ import { sendSuccess, sendError } from '../utils/response.js';
12
+ import { errors } from '../utils/errors.js';
13
+ import { strictLimiter } from './middleware.js';
14
+
15
+ // Share configuration constants
16
+ const DEFAULT_EXPIRY_HOURS = 168; // 7 days
17
+ const MIN_EXPIRY_HOURS = 1;
18
+ const MAX_EXPIRY_HOURS = 8760; // 1 year
19
+ const MAX_MESSAGE_TEXT_LENGTH = 10000;
20
+ const MAX_MESSAGES_PER_SHARE = 500;
21
+
22
+ /**
23
+ * Sanitize message content for safe storage and display
24
+ * Strips potential XSS vectors while preserving legitimate content
25
+ * @param {string} text - Raw message text
26
+ * @returns {string} Sanitized text
27
+ */
28
+ function sanitizeMessageText(text) {
29
+ if (!text || typeof text !== 'string') {
30
+ return '';
31
+ }
32
+
33
+ // Truncate to max length
34
+ let sanitized = text.substring(0, MAX_MESSAGE_TEXT_LENGTH);
35
+
36
+ // Remove null bytes and control characters (except newlines/tabs)
37
+ sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
38
+
39
+ // Note: HTML escaping happens at render time in the client
40
+ // We store the raw text but ensure it doesn't contain script injection vectors
41
+
42
+ return sanitized;
43
+ }
44
+
45
+ /**
46
+ * Validate and sanitize message type
47
+ * @param {string} type - Message type
48
+ * @returns {string} Validated type
49
+ */
50
+ function sanitizeMessageType(type) {
51
+ const validTypes = ['user', 'assistant', 'system'];
52
+ if (typeof type === 'string' && validTypes.includes(type)) {
53
+ return type;
54
+ }
55
+ return 'user'; // Default to user if invalid
56
+ }
57
+
58
+ /**
59
+ * Setup share routes
60
+ * @param {Express} app - Express app instance
61
+ * @param {string} sharesDir - Directory to store shares
62
+ */
63
+ export function setupShareRoutes(app, sharesDir) {
64
+ // Ensure shares directory exists
65
+ fs.mkdir(sharesDir, { recursive: true }).catch(() => {});
66
+
67
+ // Create a shareable link for a conversation
68
+ app.post('/api/share/create', strictLimiter, async (req, res) => {
69
+ try {
70
+ const { title, messages, expiresInHours: rawExpiry } = req.body;
71
+
72
+ // Validate expiresInHours as number
73
+ const expiryResult = parseNumericParam(rawExpiry, {
74
+ min: MIN_EXPIRY_HOURS,
75
+ max: MAX_EXPIRY_HOURS,
76
+ defaultValue: DEFAULT_EXPIRY_HOURS,
77
+ paramName: 'expiresInHours'
78
+ });
79
+
80
+ if (!expiryResult.valid) {
81
+ return errors.badRequest(res, expiryResult.error);
82
+ }
83
+ const expiresInHours = expiryResult.value;
84
+
85
+ // Validate messages array
86
+ if (!messages || !Array.isArray(messages) || messages.length === 0) {
87
+ return errors.badRequest(res, 'messages array required');
88
+ }
89
+
90
+ if (messages.length > MAX_MESSAGES_PER_SHARE) {
91
+ return errors.badRequest(res, `Too many messages (max ${MAX_MESSAGES_PER_SHARE})`);
92
+ }
93
+
94
+ const shareId = randomUUID().replace(/-/g, '').substring(0, 12);
95
+ const shareFile = path.join(sharesDir, `${shareId}.json`);
96
+
97
+ // Sanitize title
98
+ const safeTitle = typeof title === 'string'
99
+ ? title.substring(0, 200).replace(/[\x00-\x1F\x7F]/g, '')
100
+ : 'Shared Conversation';
101
+
102
+ // Sanitize messages with server-side validation
103
+ const sanitizedMessages = messages.map(m => ({
104
+ type: sanitizeMessageType(m.type),
105
+ text: sanitizeMessageText(m.text),
106
+ timestamp: typeof m.timestamp === 'number' ? m.timestamp : Date.now()
107
+ }));
108
+
109
+ const shareData = {
110
+ id: shareId,
111
+ title: safeTitle,
112
+ messages: sanitizedMessages,
113
+ createdAt: Date.now(),
114
+ expiresAt: Date.now() + (expiresInHours * 60 * 60 * 1000),
115
+ viewCount: 0
116
+ };
117
+
118
+ // Atomic write: write to temp file first, then rename
119
+ const tempShareFile = path.join(sharesDir, `.tmp-${shareId}-${randomUUID()}.json`);
120
+ await fs.writeFile(tempShareFile, JSON.stringify(shareData, null, 2));
121
+ await fs.rename(tempShareFile, shareFile);
122
+
123
+ log('info', `[Share] Created share: ${shareId}`);
124
+
125
+ return sendSuccess(res, {
126
+ shareId,
127
+ shareUrl: `/share/${shareId}`,
128
+ expiresAt: shareData.expiresAt
129
+ });
130
+ } catch (e) {
131
+ log('error', '[Share] Create error:', e.message);
132
+ return errors.serverError(res, 'Failed to create share');
133
+ }
134
+ });
135
+
136
+ // Get shared conversation
137
+ app.get('/api/share/:shareId', async (req, res) => {
138
+ try {
139
+ const { shareId } = req.params;
140
+
141
+ // Validate shareId using centralized utility
142
+ const shareIdResult = sanitizeShareId(shareId);
143
+ if (!shareIdResult.valid) {
144
+ return errors.badRequest(res, shareIdResult.error);
145
+ }
146
+ const safeShareId = shareIdResult.sanitized;
147
+
148
+ const shareFile = path.join(sharesDir, `${safeShareId}.json`);
149
+
150
+ try {
151
+ const data = await fs.readFile(shareFile, 'utf8');
152
+ const shareData = JSON.parse(data);
153
+
154
+ if (Date.now() > shareData.expiresAt) {
155
+ await fs.unlink(shareFile).catch(() => {});
156
+ return errors.notFound(res, 'Share link has expired');
157
+ }
158
+
159
+ shareData.viewCount++;
160
+ await fs.writeFile(shareFile, JSON.stringify(shareData, null, 2));
161
+
162
+ return sendSuccess(res, shareData);
163
+ } catch (e) {
164
+ return errors.notFound(res, 'Share not found');
165
+ }
166
+ } catch (e) {
167
+ log('error', '[Share] Get error:', e.message);
168
+ return errors.serverError(res, 'Failed to get share');
169
+ }
170
+ });
171
+
172
+ // Delete a share
173
+ app.delete('/api/share/:shareId', strictLimiter, async (req, res) => {
174
+ try {
175
+ const { shareId } = req.params;
176
+
177
+ // Validate shareId using centralized utility
178
+ const shareIdResult = sanitizeShareId(shareId);
179
+ if (!shareIdResult.valid) {
180
+ return errors.badRequest(res, shareIdResult.error);
181
+ }
182
+ const safeShareId = shareIdResult.sanitized;
183
+
184
+ const shareFile = path.join(sharesDir, `${safeShareId}.json`);
185
+
186
+ await fs.unlink(shareFile);
187
+ return sendSuccess(res);
188
+ } catch (e) {
189
+ return errors.notFound(res, 'Share not found');
190
+ }
191
+ });
192
+
193
+ // Serve shared conversation page
194
+ app.get('/share/:shareId', async (req, res) => {
195
+ res.send(getSharePageHTML(req.params.shareId));
196
+ });
197
+ }
198
+
199
+ /**
200
+ * Generate HTML for share page
201
+ */
202
+ function getSharePageHTML(shareId) {
203
+ // Sanitize shareId to prevent XSS - only allow alphanumeric
204
+ const safeShareId = escapeForJS(shareId.replace(/[^a-zA-Z0-9]/g, '').substring(0, 12));
205
+
206
+ return `
207
+ <!DOCTYPE html>
208
+ <html lang="en">
209
+ <head>
210
+ <meta charset="UTF-8">
211
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
212
+ <title>Shared Conversation - Uplink</title>
213
+ <style>
214
+ * { box-sizing: border-box; margin: 0; padding: 0; }
215
+ body {
216
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
217
+ background: #0f0f1a;
218
+ color: #fff;
219
+ min-height: 100vh;
220
+ padding: 20px;
221
+ }
222
+ .container { max-width: 700px; margin: 0 auto; }
223
+ .header {
224
+ text-align: center;
225
+ padding: 20px 0 30px;
226
+ border-bottom: 1px solid #333;
227
+ margin-bottom: 20px;
228
+ }
229
+ .logo { font-size: 2em; margin-bottom: 8px; }
230
+ .title { font-size: 1.2em; color: #888; }
231
+ .messages { display: flex; flex-direction: column; gap: 12px; }
232
+ .message {
233
+ padding: 12px 16px;
234
+ border-radius: 12px;
235
+ max-width: 85%;
236
+ }
237
+ .message.user {
238
+ background: #6366f1;
239
+ align-self: flex-end;
240
+ border-bottom-right-radius: 4px;
241
+ }
242
+ .message.assistant {
243
+ background: #1e1e2e;
244
+ align-self: flex-start;
245
+ border-bottom-left-radius: 4px;
246
+ }
247
+ .footer {
248
+ text-align: center;
249
+ margin-top: 30px;
250
+ padding-top: 20px;
251
+ border-top: 1px solid #333;
252
+ color: #666;
253
+ font-size: 0.9em;
254
+ }
255
+ .footer a { color: #6366f1; text-decoration: none; }
256
+ .error { text-align: center; padding: 40px; color: #ef4444; }
257
+ .loading { text-align: center; padding: 40px; color: #888; }
258
+ </style>
259
+ </head>
260
+ <body>
261
+ <div class="container">
262
+ <div class="header">
263
+ <div class="logo">🛰️ Uplink</div>
264
+ <div class="title" id="shareTitle">Loading...</div>
265
+ </div>
266
+ <div class="messages" id="messages">
267
+ <div class="loading">Loading conversation...</div>
268
+ </div>
269
+ <div class="footer">
270
+ Shared via <a href="/">Uplink</a>
271
+ </div>
272
+ </div>
273
+ <script>
274
+ function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
275
+ const shareId = '${safeShareId}';
276
+ fetch('/api/share/' + shareId)
277
+ .then(r => r.json())
278
+ .then(data => {
279
+ if (!data.ok || data.error) {
280
+ document.getElementById('shareTitle').textContent = 'Error';
281
+ document.getElementById('messages').innerHTML = '<div class="error">' + esc(data.error || 'Unknown error') + '</div>';
282
+ return;
283
+ }
284
+ document.getElementById('shareTitle').textContent = data.title;
285
+ document.getElementById('messages').innerHTML = data.messages
286
+ .filter(m => m.type !== 'system')
287
+ .map(m => '<div class="message ' + m.type + '">' + escapeHtml(m.text) + '</div>')
288
+ .join('');
289
+ })
290
+ .catch(e => {
291
+ document.getElementById('shareTitle').textContent = 'Error';
292
+ document.getElementById('messages').innerHTML = '<div class="error">Failed to load</div>';
293
+ });
294
+ function escapeHtml(text) {
295
+ const div = document.createElement('div');
296
+ div.textContent = text;
297
+ return div.innerHTML.replace(/\\n/g, '<br>');
298
+ }
299
+ </script>
300
+ </body>
301
+ </html>
302
+ `;
303
+ }
304
+
305
+ export default { setupShareRoutes };