clay-server 2.27.0-beta.8 → 2.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/lib/daemon-projects.js +164 -0
- package/lib/daemon.js +13 -126
- package/lib/mates-identity.js +132 -0
- package/lib/mates-knowledge.js +113 -0
- package/lib/mates-prompts.js +398 -0
- package/lib/mates.js +40 -599
- package/lib/project-connection.js +2 -0
- package/lib/project-debate.js +19 -12
- package/lib/project-http.js +4 -2
- package/lib/project-loop.js +110 -48
- package/lib/project-mate-interaction.js +4 -0
- package/lib/project-notifications.js +210 -0
- package/lib/project-sessions.js +5 -2
- package/lib/project-user-message.js +2 -1
- package/lib/project.js +26 -2
- package/lib/public/app.js +1193 -8521
- package/lib/public/css/command-palette.css +14 -0
- package/lib/public/css/loop.css +301 -0
- package/lib/public/css/notifications-center.css +190 -0
- package/lib/public/css/rewind.css +6 -0
- package/lib/public/index.html +89 -35
- package/lib/public/modules/app-connection.js +160 -0
- package/lib/public/modules/app-cursors.js +473 -0
- package/lib/public/modules/app-debate-ui.js +389 -0
- package/lib/public/modules/app-dm.js +627 -0
- package/lib/public/modules/app-favicon.js +212 -0
- package/lib/public/modules/app-header.js +229 -0
- package/lib/public/modules/app-home-hub.js +600 -0
- package/lib/public/modules/app-loop-ui.js +589 -0
- package/lib/public/modules/app-loop-wizard.js +439 -0
- package/lib/public/modules/app-messages.js +1560 -0
- package/lib/public/modules/app-misc.js +299 -0
- package/lib/public/modules/app-notifications.js +372 -0
- package/lib/public/modules/app-panels.js +888 -0
- package/lib/public/modules/app-projects.js +798 -0
- package/lib/public/modules/app-rate-limit.js +451 -0
- package/lib/public/modules/app-rendering.js +597 -0
- package/lib/public/modules/app-skills-install.js +234 -0
- package/lib/public/modules/command-palette.js +27 -4
- package/lib/public/modules/input.js +31 -20
- package/lib/public/modules/scheduler-config.js +1532 -0
- package/lib/public/modules/scheduler-history.js +79 -0
- package/lib/public/modules/scheduler.js +33 -1554
- package/lib/public/modules/session-search.js +13 -1
- package/lib/public/modules/sidebar-mates.js +812 -0
- package/lib/public/modules/sidebar-mobile.js +1269 -0
- package/lib/public/modules/sidebar-projects.js +1449 -0
- package/lib/public/modules/sidebar-sessions.js +986 -0
- package/lib/public/modules/sidebar.js +232 -4591
- package/lib/public/modules/store.js +27 -0
- package/lib/public/modules/ws-ref.js +7 -0
- package/lib/public/style.css +1 -0
- package/lib/sdk-bridge.js +96 -717
- package/lib/sdk-message-processor.js +587 -0
- package/lib/sdk-message-queue.js +42 -0
- package/lib/sdk-skill-discovery.js +131 -0
- package/lib/server-admin.js +712 -0
- package/lib/server-auth.js +737 -0
- package/lib/server-dm.js +221 -0
- package/lib/server-mates.js +281 -0
- package/lib/server-palette.js +110 -0
- package/lib/server-settings.js +479 -0
- package/lib/server-skills.js +280 -0
- package/lib/server.js +246 -2755
- package/lib/sessions.js +11 -4
- package/lib/users-auth.js +146 -0
- package/lib/users-permissions.js +118 -0
- package/lib/users-preferences.js +210 -0
- package/lib/users.js +48 -398
- package/lib/ws-schema.js +498 -0
- package/package.json +1 -1
package/lib/server.js
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
var http = require("http");
|
|
2
|
-
var crypto = require("crypto");
|
|
3
2
|
var fs = require("fs");
|
|
4
3
|
var path = require("path");
|
|
5
4
|
var { WebSocketServer } = require("ws");
|
|
6
|
-
var
|
|
5
|
+
var pages = require("./pages");
|
|
7
6
|
var smtp = require("./smtp");
|
|
8
7
|
var { createProjectContext } = require("./project");
|
|
9
8
|
var users = require("./users");
|
|
10
9
|
var dm = require("./dm");
|
|
11
10
|
var mates = require("./mates");
|
|
12
|
-
var
|
|
11
|
+
var serverAuth = require("./server-auth");
|
|
12
|
+
var serverSkills = require("./server-skills");
|
|
13
|
+
var serverDm = require("./server-dm");
|
|
14
|
+
var serverMates = require("./server-mates");
|
|
15
|
+
var serverAdmin = require("./server-admin");
|
|
16
|
+
var serverSettings = require("./server-settings");
|
|
17
|
+
var serverPalette = require("./server-palette");
|
|
13
18
|
|
|
14
19
|
var { CONFIG_DIR } = require("./config");
|
|
15
20
|
var { provisionLinuxUser } = require("./os-users");
|
|
@@ -21,23 +26,7 @@ var publicDir = path.join(__dirname, "public");
|
|
|
21
26
|
var bundledThemesDir = path.join(__dirname, "themes");
|
|
22
27
|
var userThemesDir = path.join(CONFIG_DIR, "themes");
|
|
23
28
|
|
|
24
|
-
// ---
|
|
25
|
-
var skillsCache = {};
|
|
26
|
-
|
|
27
|
-
function httpGet(url) {
|
|
28
|
-
return new Promise(function (resolve, reject) {
|
|
29
|
-
var mod = url.startsWith("https") ? https : http;
|
|
30
|
-
mod.get(url, { headers: { "User-Agent": "Clay/1.0" } }, function (resp) {
|
|
31
|
-
if (resp.statusCode >= 300 && resp.statusCode < 400 && resp.headers.location) {
|
|
32
|
-
return httpGet(resp.headers.location).then(resolve, reject);
|
|
33
|
-
}
|
|
34
|
-
var chunks = [];
|
|
35
|
-
resp.on("data", function (c) { chunks.push(c); });
|
|
36
|
-
resp.on("end", function () { resolve(Buffer.concat(chunks).toString("utf8")); });
|
|
37
|
-
resp.on("error", reject);
|
|
38
|
-
}).on("error", reject);
|
|
39
|
-
});
|
|
40
|
-
}
|
|
29
|
+
// --- HTTP helpers (used by skills proxy and extension download) ---
|
|
41
30
|
|
|
42
31
|
function httpGetBinary(url) {
|
|
43
32
|
return new Promise(function (resolve, reject) {
|
|
@@ -57,135 +46,6 @@ function httpGetBinary(url) {
|
|
|
57
46
|
});
|
|
58
47
|
}
|
|
59
48
|
|
|
60
|
-
function fetchSkillsPage(url) {
|
|
61
|
-
return httpGet(url).then(function (html) {
|
|
62
|
-
// Data is inside self.__next_f.push() with escaped quotes: \"initialSkills\":[{\"source\":...}]
|
|
63
|
-
var marker = 'initialSkills';
|
|
64
|
-
var idx = html.indexOf(marker);
|
|
65
|
-
if (idx < 0) return { skills: [] };
|
|
66
|
-
|
|
67
|
-
// Find the start of the array: look for \\\":[
|
|
68
|
-
var arrStart = html.indexOf(':[', idx);
|
|
69
|
-
if (arrStart < 0) return { skills: [] };
|
|
70
|
-
arrStart += 1; // point to '['
|
|
71
|
-
|
|
72
|
-
// Find matching ']' — track bracket depth
|
|
73
|
-
var depth = 0;
|
|
74
|
-
var arrEnd = -1;
|
|
75
|
-
for (var i = arrStart; i < html.length; i++) {
|
|
76
|
-
var ch = html[i];
|
|
77
|
-
if (ch === '[') depth++;
|
|
78
|
-
else if (ch === ']') {
|
|
79
|
-
depth--;
|
|
80
|
-
if (depth === 0) { arrEnd = i + 1; break; }
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
if (arrEnd < 0) return { skills: [] };
|
|
84
|
-
|
|
85
|
-
var raw = html.substring(arrStart, arrEnd);
|
|
86
|
-
// Unescape: \\\" → " and \\\\ → backslash
|
|
87
|
-
var unescaped = raw.replace(/\\\\"/g, '__BSLASH_QUOTE__').replace(/\\"/g, '"').replace(/__BSLASH_QUOTE__/g, '\\"');
|
|
88
|
-
|
|
89
|
-
try {
|
|
90
|
-
return { skills: JSON.parse(unescaped) };
|
|
91
|
-
} catch (e) {
|
|
92
|
-
return { skills: [] };
|
|
93
|
-
}
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function fetchSkillDetail(url) {
|
|
98
|
-
return httpGet(url).then(function (html) {
|
|
99
|
-
var result = {};
|
|
100
|
-
|
|
101
|
-
// Title: "skill-name by owner/repo"
|
|
102
|
-
var titleMatch = html.match(/<title>([^<]+)<\/title>/);
|
|
103
|
-
if (titleMatch) {
|
|
104
|
-
var parts = titleMatch[1].split(" by ");
|
|
105
|
-
result.name = parts[0].trim();
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Description from meta
|
|
109
|
-
var descMatch = html.match(/meta name="description" content="([^"]+)"/);
|
|
110
|
-
if (descMatch) result.description = descMatch[1];
|
|
111
|
-
|
|
112
|
-
// Install command
|
|
113
|
-
var cmdMatch = html.match(/npx skills add [^ ]+ --skill [^ "<]+/);
|
|
114
|
-
if (cmdMatch) result.command = cmdMatch[0];
|
|
115
|
-
|
|
116
|
-
// Weekly installs: "Weekly Installs</span></div><div ...>VALUE</div>"
|
|
117
|
-
var wiMatch = html.match(/Weekly Installs<\/span><\/div><div[^>]*>([\d,.]+K?)<\/div>/);
|
|
118
|
-
if (wiMatch) result.weeklyInstalls = wiMatch[1];
|
|
119
|
-
|
|
120
|
-
// GitHub Stars: after SVG icon, inside <span>X.XK</span>
|
|
121
|
-
var gsIdx = html.indexOf("GitHub Stars");
|
|
122
|
-
if (gsIdx > 0) {
|
|
123
|
-
var gsRegion = html.substring(gsIdx, gsIdx + 1000);
|
|
124
|
-
var gsVal = gsRegion.match(/<span>(\d[\d,.]*K?)<\/span>/);
|
|
125
|
-
if (gsVal) result.githubStars = gsVal[1];
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// First Seen
|
|
129
|
-
var fsMatch = html.match(/First Seen<\/span><\/div><div[^>]*>([^<]+)<\/div>/);
|
|
130
|
-
if (fsMatch) result.firstSeen = fsMatch[1].trim();
|
|
131
|
-
|
|
132
|
-
// Repository: from title "by owner/repo"
|
|
133
|
-
if (titleMatch) {
|
|
134
|
-
var byParts = titleMatch[1].split(" by ");
|
|
135
|
-
if (byParts[1]) result.repository = byParts[1].trim();
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Security audits: "text-foreground truncate">NAME</span><span ...>STATUS</span>"
|
|
139
|
-
var audits = [];
|
|
140
|
-
var auditRegex = /class="text-sm font-medium text-foreground truncate">([^<]+)<\/span><span class="[^"]*">(\w+)<\/span>/g;
|
|
141
|
-
var am;
|
|
142
|
-
while ((am = auditRegex.exec(html)) !== null) {
|
|
143
|
-
audits.push({ name: am[1], status: am[2].toLowerCase() });
|
|
144
|
-
}
|
|
145
|
-
if (audits.length) result.audits = audits;
|
|
146
|
-
|
|
147
|
-
// Installed on: "text-foreground">NAME</span><span class="text-muted-foreground font-mono">COUNT</span>
|
|
148
|
-
var ioIdx = html.indexOf("Installed On");
|
|
149
|
-
if (ioIdx > 0) {
|
|
150
|
-
var ioRegion = html.substring(ioIdx, ioIdx + 3000);
|
|
151
|
-
var platforms = [];
|
|
152
|
-
var platRegex = /text-foreground">([^<]+)<\/span><span class="text-muted-foreground font-mono">([\d,.]+K?)<\/span>/g;
|
|
153
|
-
var pm;
|
|
154
|
-
while ((pm = platRegex.exec(ioRegion)) !== null) {
|
|
155
|
-
platforms.push({ name: pm[1], installs: pm[2] });
|
|
156
|
-
}
|
|
157
|
-
if (platforms.length) result.installedOn = platforms;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// SKILL.md content: rendered HTML inside the main content area
|
|
161
|
-
var skillMdIdx = html.indexOf("SKILL.md");
|
|
162
|
-
if (skillMdIdx > 0) {
|
|
163
|
-
// Find the prose content div after SKILL.md marker
|
|
164
|
-
var proseIdx = html.indexOf("prose", skillMdIdx);
|
|
165
|
-
if (proseIdx > 0) {
|
|
166
|
-
var proseStart = html.indexOf(">", proseIdx) + 1;
|
|
167
|
-
// Find the closing of the prose div (heuristic: next major section boundary)
|
|
168
|
-
var endMarkers = ["<div class=\"bg-background", "<div class=\"sticky"];
|
|
169
|
-
var proseEnd = html.length;
|
|
170
|
-
for (var em = 0; em < endMarkers.length; em++) {
|
|
171
|
-
var endIdx = html.indexOf(endMarkers[em], proseStart);
|
|
172
|
-
if (endIdx > 0 && endIdx < proseEnd) proseEnd = endIdx;
|
|
173
|
-
}
|
|
174
|
-
var rawMd = html.substring(proseStart, proseEnd);
|
|
175
|
-
// Rebase relative URLs to absolute (skills.sh base)
|
|
176
|
-
result.skillMd = rawMd
|
|
177
|
-
.replace(/src="(?!https?:\/\/|data:)([^"]+)"/g, function (m, p) {
|
|
178
|
-
return 'src="' + new URL(p, url).href + '"';
|
|
179
|
-
})
|
|
180
|
-
.replace(/href="(?!https?:\/\/|mailto:|#)([^"]+)"/g, function (m, p) {
|
|
181
|
-
return 'href="' + new URL(p, url).href + '"';
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
return result;
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
49
|
|
|
190
50
|
var MIME_TYPES = {
|
|
191
51
|
".html": "text/html",
|
|
@@ -202,134 +62,8 @@ var MIME_TYPES = {
|
|
|
202
62
|
".ico": "image/x-icon",
|
|
203
63
|
};
|
|
204
64
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
var hash = crypto.scryptSync("clay:" + pin, salt, 64).toString("hex");
|
|
208
|
-
return salt + ":" + hash;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function verifyPin(pin, storedHash) {
|
|
212
|
-
if (!storedHash) return false;
|
|
213
|
-
// New scrypt format: salt_hex:hash_hex (contains colon)
|
|
214
|
-
if (storedHash.indexOf(":") !== -1) {
|
|
215
|
-
var parts = storedHash.split(":");
|
|
216
|
-
var salt = parts[0];
|
|
217
|
-
var hash = parts[1];
|
|
218
|
-
var derived = crypto.scryptSync("clay:" + pin, salt, 64).toString("hex");
|
|
219
|
-
return crypto.timingSafeEqual(Buffer.from(derived, "hex"), Buffer.from(hash, "hex"));
|
|
220
|
-
}
|
|
221
|
-
// Legacy SHA256 format (no colon)
|
|
222
|
-
var legacyHash = crypto.createHash("sha256").update("clay:" + pin).digest("hex");
|
|
223
|
-
var match = crypto.timingSafeEqual(Buffer.from(legacyHash, "hex"), Buffer.from(storedHash, "hex"));
|
|
224
|
-
return match;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function parseCookies(req) {
|
|
228
|
-
var cookies = {};
|
|
229
|
-
var header = req.headers.cookie || "";
|
|
230
|
-
header.split(";").forEach(function (part) {
|
|
231
|
-
var pair = part.trim().split("=");
|
|
232
|
-
if (pair.length === 2) cookies[pair[0]] = pair[1];
|
|
233
|
-
});
|
|
234
|
-
return cookies;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function isAuthed(req, authToken) {
|
|
238
|
-
if (!authToken) return true;
|
|
239
|
-
var cookies = parseCookies(req);
|
|
240
|
-
return cookies["relay_auth"] === authToken;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// --- Multi-user auth helpers ---
|
|
244
|
-
// Multi-user auth tokens: persisted to disk so they survive restarts
|
|
245
|
-
var _isDevMode = require("./config").isDevMode;
|
|
246
|
-
var TOKENS_FILE = path.join(CONFIG_DIR, _isDevMode ? "auth-tokens-dev.json" : "auth-tokens.json");
|
|
247
|
-
var MULTI_USER_COOKIE = _isDevMode ? "relay_auth_user_dev" : "relay_auth_user";
|
|
248
|
-
var multiUserTokens = {}; // token → userId
|
|
249
|
-
|
|
250
|
-
function loadTokens() {
|
|
251
|
-
try {
|
|
252
|
-
var raw = fs.readFileSync(TOKENS_FILE, "utf8");
|
|
253
|
-
var data = JSON.parse(raw);
|
|
254
|
-
if (data && typeof data === "object") {
|
|
255
|
-
multiUserTokens = data;
|
|
256
|
-
}
|
|
257
|
-
} catch (e) {
|
|
258
|
-
multiUserTokens = {};
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function saveTokens() {
|
|
263
|
-
try {
|
|
264
|
-
fs.mkdirSync(path.dirname(TOKENS_FILE), { recursive: true });
|
|
265
|
-
var tmpPath = TOKENS_FILE + ".tmp";
|
|
266
|
-
fs.writeFileSync(tmpPath, JSON.stringify(multiUserTokens));
|
|
267
|
-
fs.renameSync(tmpPath, TOKENS_FILE);
|
|
268
|
-
} catch (e) {}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
loadTokens();
|
|
272
|
-
|
|
273
|
-
function createMultiUserSession(userId, tlsOptions) {
|
|
274
|
-
var token = users.generateUserAuthToken(userId);
|
|
275
|
-
multiUserTokens[token] = userId;
|
|
276
|
-
saveTokens();
|
|
277
|
-
var cookie = MULTI_USER_COOKIE + "=" + token + "; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000" + (tlsOptions ? "; Secure" : "");
|
|
278
|
-
return { token: token, cookie: cookie };
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function getMultiUserFromReq(req) {
|
|
282
|
-
var cookies = parseCookies(req);
|
|
283
|
-
var token = cookies[MULTI_USER_COOKIE];
|
|
284
|
-
if (!token) return null;
|
|
285
|
-
var userId = multiUserTokens[token];
|
|
286
|
-
if (!userId) return null;
|
|
287
|
-
var user = users.findUserById(userId);
|
|
288
|
-
return user || null;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
function isMultiUserAuthed(req) {
|
|
292
|
-
return !!getMultiUserFromReq(req);
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
function revokeUserTokens(userId) {
|
|
296
|
-
var changed = false;
|
|
297
|
-
for (var token in multiUserTokens) {
|
|
298
|
-
if (multiUserTokens[token] === userId) {
|
|
299
|
-
delete multiUserTokens[token];
|
|
300
|
-
changed = true;
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
if (changed) saveTokens();
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// --- PIN rate limiting ---
|
|
307
|
-
var pinAttempts = {}; // ip → { count, lastAttempt }
|
|
308
|
-
var PIN_MAX_ATTEMPTS = 5;
|
|
309
|
-
var PIN_LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
|
|
310
|
-
|
|
311
|
-
function checkPinRateLimit(ip) {
|
|
312
|
-
var entry = pinAttempts[ip];
|
|
313
|
-
if (!entry) return null;
|
|
314
|
-
if (entry.count >= PIN_MAX_ATTEMPTS) {
|
|
315
|
-
var elapsed = Date.now() - entry.lastAttempt;
|
|
316
|
-
if (elapsed < PIN_LOCKOUT_MS) {
|
|
317
|
-
return Math.ceil((PIN_LOCKOUT_MS - elapsed) / 1000);
|
|
318
|
-
}
|
|
319
|
-
delete pinAttempts[ip];
|
|
320
|
-
}
|
|
321
|
-
return null;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
function recordPinFailure(ip) {
|
|
325
|
-
if (!pinAttempts[ip]) pinAttempts[ip] = { count: 0, lastAttempt: 0 };
|
|
326
|
-
pinAttempts[ip].count++;
|
|
327
|
-
pinAttempts[ip].lastAttempt = Date.now();
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function clearPinFailures(ip) {
|
|
331
|
-
delete pinAttempts[ip];
|
|
332
|
-
}
|
|
65
|
+
var generateAuthToken = serverAuth.generateAuthToken;
|
|
66
|
+
var verifyPin = serverAuth.verifyPin;
|
|
333
67
|
|
|
334
68
|
function serveStatic(urlPath, res) {
|
|
335
69
|
if (urlPath === "/") urlPath = "/index.html";
|
|
@@ -426,118 +160,71 @@ function createServer(opts) {
|
|
|
426
160
|
var onUserDeleted = opts.onUserDeleted || null;
|
|
427
161
|
var getRemovedProjects = opts.getRemovedProjects || function () { return []; };
|
|
428
162
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
+ '<title>Admin Password Recovery</title>'
|
|
445
|
-
+ '<style>'
|
|
446
|
-
+ '*{margin:0;padding:0;box-sizing:border-box}'
|
|
447
|
-
+ 'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0a0a0a;color:#e5e5e5;display:flex;align-items:center;justify-content:center;min-height:100vh}'
|
|
448
|
-
+ '.card{background:#171717;border:1px solid #262626;border-radius:12px;padding:32px;width:100%;max-width:380px}'
|
|
449
|
-
+ 'h1{font-size:18px;font-weight:600;margin-bottom:4px}'
|
|
450
|
-
+ '.sub{font-size:13px;color:#737373;margin-bottom:24px}'
|
|
451
|
-
+ 'label{display:block;font-size:13px;color:#a3a3a3;margin-bottom:6px}'
|
|
452
|
-
+ 'input{width:100%;padding:10px 12px;background:#0a0a0a;border:1px solid #333;border-radius:8px;color:#e5e5e5;font-size:14px;outline:none;margin-bottom:16px}'
|
|
453
|
-
+ 'input:focus{border-color:#7c3aed}'
|
|
454
|
-
+ 'button{width:100%;padding:10px;background:#7c3aed;color:#fff;border:none;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer}'
|
|
455
|
-
+ 'button:hover{background:#6d28d9}'
|
|
456
|
-
+ 'button:disabled{opacity:.5;cursor:not-allowed}'
|
|
457
|
-
+ '.error{color:#ef4444;font-size:13px;margin-bottom:12px;display:none}'
|
|
458
|
-
+ '.success{text-align:center;color:#22c55e;font-size:15px}'
|
|
459
|
-
+ '.hidden{display:none}'
|
|
460
|
-
+ '</style></head><body>'
|
|
461
|
-
+ '<div class="card">'
|
|
462
|
-
+ '<div id="step-verify">'
|
|
463
|
-
+ '<h1>Admin Recovery</h1>'
|
|
464
|
-
+ '<p class="sub">Enter the recovery password shown in your terminal.</p>'
|
|
465
|
-
+ '<div id="err-verify" class="error"></div>'
|
|
466
|
-
+ '<label for="recovery-pw">Recovery password</label>'
|
|
467
|
-
+ '<input id="recovery-pw" type="text" autocomplete="off" spellcheck="false" autofocus>'
|
|
468
|
-
+ '<button id="btn-verify">Verify</button>'
|
|
469
|
-
+ '</div>'
|
|
470
|
-
+ '<div id="step-reset" class="hidden">'
|
|
471
|
-
+ '<h1>Reset Admin PIN</h1>'
|
|
472
|
-
+ '<p class="sub">Enter a new 6-digit PIN for the admin account.</p>'
|
|
473
|
-
+ '<div id="err-reset" class="error"></div>'
|
|
474
|
-
+ '<label for="new-pin">New PIN</label>'
|
|
475
|
-
+ '<input id="new-pin" type="password" maxlength="6" pattern="\\d{6}" inputmode="numeric" placeholder="6 digits">'
|
|
476
|
-
+ '<label for="confirm-pin">Confirm PIN</label>'
|
|
477
|
-
+ '<input id="confirm-pin" type="password" maxlength="6" pattern="\\d{6}" inputmode="numeric" placeholder="6 digits">'
|
|
478
|
-
+ '<button id="btn-reset">Reset PIN</button>'
|
|
479
|
-
+ '</div>'
|
|
480
|
-
+ '<div id="step-done" class="hidden">'
|
|
481
|
-
+ '<p class="success">PIN has been reset successfully. You can now log in with your new PIN.</p>'
|
|
482
|
-
+ '</div>'
|
|
483
|
-
+ '</div>'
|
|
484
|
-
+ '<script>'
|
|
485
|
-
+ 'var pw="";\n'
|
|
486
|
-
+ 'document.getElementById("btn-verify").onclick=function(){\n'
|
|
487
|
-
+ ' var el=document.getElementById("recovery-pw");\n'
|
|
488
|
-
+ ' pw=el.value.trim();\n'
|
|
489
|
-
+ ' if(!pw)return;\n'
|
|
490
|
-
+ ' this.disabled=true;\n'
|
|
491
|
-
+ ' fetch(location.pathname,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({step:"verify",password:pw})})\n'
|
|
492
|
-
+ ' .then(function(r){return r.json()}).then(function(d){\n'
|
|
493
|
-
+ ' if(d.ok){document.getElementById("step-verify").classList.add("hidden");document.getElementById("step-reset").classList.remove("hidden");document.getElementById("new-pin").focus()}\n'
|
|
494
|
-
+ ' else{var e=document.getElementById("err-verify");e.textContent=d.error||"Invalid password";e.style.display="block";document.getElementById("btn-verify").disabled=false}\n'
|
|
495
|
-
+ ' }).catch(function(){document.getElementById("btn-verify").disabled=false})\n'
|
|
496
|
-
+ '};\n'
|
|
497
|
-
+ 'document.getElementById("recovery-pw").addEventListener("keydown",function(e){if(e.key==="Enter")document.getElementById("btn-verify").click()});\n'
|
|
498
|
-
+ 'document.getElementById("btn-reset").onclick=function(){\n'
|
|
499
|
-
+ ' var pin=document.getElementById("new-pin").value;\n'
|
|
500
|
-
+ ' var confirm=document.getElementById("confirm-pin").value;\n'
|
|
501
|
-
+ ' var errEl=document.getElementById("err-reset");\n'
|
|
502
|
-
+ ' if(!/^\\d{6}$/.test(pin)){errEl.textContent="PIN must be exactly 6 digits";errEl.style.display="block";return}\n'
|
|
503
|
-
+ ' if(pin!==confirm){errEl.textContent="PINs do not match";errEl.style.display="block";return}\n'
|
|
504
|
-
+ ' this.disabled=true;errEl.style.display="none";\n'
|
|
505
|
-
+ ' fetch(location.pathname,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({step:"reset",password:pw,pin:pin})})\n'
|
|
506
|
-
+ ' .then(function(r){return r.json()}).then(function(d){\n'
|
|
507
|
-
+ ' if(d.ok){document.getElementById("step-reset").classList.add("hidden");document.getElementById("step-done").classList.remove("hidden")}\n'
|
|
508
|
-
+ ' else{errEl.textContent=d.error||"Failed";errEl.style.display="block";document.getElementById("btn-reset").disabled=false}\n'
|
|
509
|
-
+ ' }).catch(function(){document.getElementById("btn-reset").disabled=false})\n'
|
|
510
|
-
+ '};\n'
|
|
511
|
-
+ 'document.getElementById("confirm-pin").addEventListener("keydown",function(e){if(e.key==="Enter")document.getElementById("btn-reset").click()});\n'
|
|
512
|
-
+ '</script></body></html>';
|
|
513
|
-
}
|
|
163
|
+
// --- Auth module ---
|
|
164
|
+
var auth = serverAuth.attachAuth({
|
|
165
|
+
users: users,
|
|
166
|
+
smtp: smtp,
|
|
167
|
+
pages: pages,
|
|
168
|
+
tlsOptions: tlsOptions,
|
|
169
|
+
osUsers: osUsers,
|
|
170
|
+
pinHash: pinHash,
|
|
171
|
+
provisionLinuxUser: provisionLinuxUser,
|
|
172
|
+
onUpgradePin: onUpgradePin,
|
|
173
|
+
onUserProvisioned: onUserProvisioned,
|
|
174
|
+
});
|
|
175
|
+
var getMultiUserFromReq = auth.getMultiUserFromReq;
|
|
176
|
+
var isRequestAuthed = auth.isRequestAuthed;
|
|
177
|
+
var parseCookies = auth.parseCookies;
|
|
514
178
|
|
|
515
179
|
var realVersion = require("../package.json").version;
|
|
516
180
|
var currentVersion = debug ? "0.0.9" : realVersion;
|
|
517
181
|
|
|
518
182
|
var caContent = caPath ? (function () { try { return fs.readFileSync(caPath); } catch (e) { return null; } })() : null;
|
|
519
|
-
var pinPage = pinPageHtml();
|
|
520
|
-
var adminSetupPage = adminSetupPageHtml();
|
|
521
|
-
var loginPage = multiUserLoginPageHtml();
|
|
522
|
-
var smtpLoginPage = smtpLoginPageHtml();
|
|
523
|
-
|
|
524
|
-
// Multi-user auth: determine which page to show for unauthenticated requests
|
|
525
|
-
function getAuthPage() {
|
|
526
|
-
if (!users.isMultiUser()) return pinPage;
|
|
527
|
-
if (!users.hasAdmin()) return adminSetupPage;
|
|
528
|
-
if (smtp.isEmailLoginEnabled()) return smtpLoginPage;
|
|
529
|
-
return loginPage;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
// Unified auth check: works in both single-user and multi-user mode
|
|
533
|
-
function isRequestAuthed(req) {
|
|
534
|
-
if (users.isMultiUser()) return isMultiUserAuthed(req);
|
|
535
|
-
return isAuthed(req, authToken);
|
|
536
|
-
}
|
|
537
183
|
|
|
538
184
|
// --- Project registry ---
|
|
539
185
|
var projects = new Map(); // slug → projectContext
|
|
540
186
|
|
|
187
|
+
// --- Admin module ---
|
|
188
|
+
var admin = serverAdmin.attachAdmin({
|
|
189
|
+
users: users,
|
|
190
|
+
smtp: smtp,
|
|
191
|
+
getMultiUserFromReq: getMultiUserFromReq,
|
|
192
|
+
projects: projects,
|
|
193
|
+
osUsers: osUsers,
|
|
194
|
+
tlsOptions: tlsOptions,
|
|
195
|
+
portNum: portNum,
|
|
196
|
+
provisionLinuxUser: provisionLinuxUser,
|
|
197
|
+
onUserProvisioned: onUserProvisioned,
|
|
198
|
+
onUserDeleted: onUserDeleted,
|
|
199
|
+
revokeUserTokens: auth.revokeUserTokens,
|
|
200
|
+
onSetProjectVisibility: onSetProjectVisibility,
|
|
201
|
+
onSetProjectAllowedUsers: onSetProjectAllowedUsers,
|
|
202
|
+
onGetProjectAccess: onGetProjectAccess,
|
|
203
|
+
onProjectOwnerChanged: onProjectOwnerChanged,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
var skills = serverSkills.attachSkills({
|
|
207
|
+
users: users,
|
|
208
|
+
osUsers: osUsers,
|
|
209
|
+
getMultiUserFromReq: getMultiUserFromReq,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
var settings = serverSettings.attachSettings({
|
|
213
|
+
users: users,
|
|
214
|
+
mates: mates,
|
|
215
|
+
getMultiUserFromReq: getMultiUserFromReq,
|
|
216
|
+
projects: projects,
|
|
217
|
+
opts: opts,
|
|
218
|
+
CONFIG_DIR: CONFIG_DIR,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
var palette = serverPalette.attachPalette({
|
|
222
|
+
users: users,
|
|
223
|
+
projects: projects,
|
|
224
|
+
getMultiUserFromReq: getMultiUserFromReq,
|
|
225
|
+
onGetProjectAccess: onGetProjectAccess,
|
|
226
|
+
});
|
|
227
|
+
|
|
541
228
|
// --- Push module (global) ---
|
|
542
229
|
var pushModule = null;
|
|
543
230
|
try {
|
|
@@ -545,6 +232,13 @@ function createServer(opts) {
|
|
|
545
232
|
pushModule = initPush();
|
|
546
233
|
} catch (e) {}
|
|
547
234
|
|
|
235
|
+
// --- Notifications module (global singleton, shared by all projects) ---
|
|
236
|
+
var { attachNotifications: _attachNotifications } = require("./project-notifications");
|
|
237
|
+
var _globalNotifications = _attachNotifications({
|
|
238
|
+
broadcastAll: function (msg) { broadcastAll(msg); },
|
|
239
|
+
pushModule: pushModule,
|
|
240
|
+
});
|
|
241
|
+
|
|
548
242
|
// --- Security headers ---
|
|
549
243
|
var securityHeaders = {
|
|
550
244
|
"X-Content-Type-Options": "nosniff",
|
|
@@ -567,546 +261,88 @@ function createServer(opts) {
|
|
|
567
261
|
setSecurityHeaders(res);
|
|
568
262
|
var fullUrl = req.url.split("?")[0];
|
|
569
263
|
|
|
570
|
-
// ---
|
|
571
|
-
if (
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
if (remaining !== null) {
|
|
581
|
-
res.writeHead(429, { "Content-Type": "application/json" });
|
|
582
|
-
res.end(JSON.stringify({ ok: false, locked: true, retryAfter: remaining }));
|
|
583
|
-
return;
|
|
584
|
-
}
|
|
585
|
-
var body = "";
|
|
586
|
-
req.on("data", function (chunk) { body += chunk; });
|
|
587
|
-
req.on("end", function () {
|
|
588
|
-
try {
|
|
589
|
-
var data = JSON.parse(body);
|
|
590
|
-
if (data.step === "verify") {
|
|
591
|
-
if (!data.password || data.password !== recovery.password) {
|
|
592
|
-
recordPinFailure(ip);
|
|
593
|
-
res.writeHead(401, { "Content-Type": "application/json" });
|
|
594
|
-
res.end('{"error":"Invalid recovery password"}');
|
|
595
|
-
return;
|
|
596
|
-
}
|
|
597
|
-
clearPinFailures(ip);
|
|
598
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
599
|
-
res.end('{"ok":true}');
|
|
600
|
-
} else if (data.step === "reset") {
|
|
601
|
-
if (!data.password || data.password !== recovery.password) {
|
|
602
|
-
res.writeHead(401, { "Content-Type": "application/json" });
|
|
603
|
-
res.end('{"error":"Invalid recovery password"}');
|
|
604
|
-
return;
|
|
605
|
-
}
|
|
606
|
-
if (!data.pin || !/^\d{6}$/.test(data.pin)) {
|
|
607
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
608
|
-
res.end('{"error":"PIN must be exactly 6 digits"}');
|
|
609
|
-
return;
|
|
610
|
-
}
|
|
611
|
-
var admin = users.findAdmin();
|
|
612
|
-
if (!admin) {
|
|
613
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
614
|
-
res.end('{"error":"No admin account found"}');
|
|
615
|
-
return;
|
|
616
|
-
}
|
|
617
|
-
users.updateUserPin(admin.id, data.pin);
|
|
618
|
-
recovery = null;
|
|
619
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
620
|
-
res.end('{"ok":true}');
|
|
621
|
-
} else {
|
|
622
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
623
|
-
res.end('{"error":"Invalid step"}');
|
|
624
|
-
}
|
|
625
|
-
} catch (e) {
|
|
626
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
627
|
-
res.end('{"error":"Invalid request"}');
|
|
628
|
-
}
|
|
629
|
-
});
|
|
630
|
-
return;
|
|
631
|
-
}
|
|
264
|
+
// --- Auth routes (delegated to server-auth) ---
|
|
265
|
+
if (auth.handleRequest(req, res, fullUrl)) return;
|
|
266
|
+
// CA certificate download
|
|
267
|
+
if (req.url === "/ca/download" && req.method === "GET" && caContent) {
|
|
268
|
+
res.writeHead(200, {
|
|
269
|
+
"Content-Type": "application/x-pem-file",
|
|
270
|
+
"Content-Disposition": 'attachment; filename="clay-ca.pem"',
|
|
271
|
+
});
|
|
272
|
+
res.end(caContent);
|
|
273
|
+
return;
|
|
632
274
|
}
|
|
633
275
|
|
|
634
|
-
//
|
|
635
|
-
if (
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
res.writeHead(429, { "Content-Type": "application/json" });
|
|
640
|
-
res.end(JSON.stringify({ ok: false, locked: true, retryAfter: remaining }));
|
|
276
|
+
// Chrome extension download (proxy from GitHub)
|
|
277
|
+
if (fullUrl === "/api/extension/download" && req.method === "GET") {
|
|
278
|
+
if (!isRequestAuthed(req)) {
|
|
279
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
280
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
641
281
|
return;
|
|
642
282
|
}
|
|
643
|
-
var
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
if (typeof onUpgradePin === "function") {
|
|
655
|
-
onUpgradePin(upgraded);
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
res.writeHead(200, {
|
|
659
|
-
"Set-Cookie": "relay_auth=" + authToken + "; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000" + (tlsOptions ? "; Secure" : ""),
|
|
660
|
-
"Content-Type": "application/json",
|
|
661
|
-
});
|
|
662
|
-
res.end('{"ok":true}');
|
|
663
|
-
} else {
|
|
664
|
-
recordPinFailure(ip);
|
|
665
|
-
var attemptsLeft = PIN_MAX_ATTEMPTS - (pinAttempts[ip] ? pinAttempts[ip].count : 0);
|
|
666
|
-
res.writeHead(401, { "Content-Type": "application/json" });
|
|
667
|
-
res.end(JSON.stringify({ ok: false, attemptsLeft: Math.max(attemptsLeft, 0) }));
|
|
668
|
-
}
|
|
669
|
-
} catch (e) {
|
|
670
|
-
res.writeHead(400);
|
|
671
|
-
res.end("Bad request");
|
|
672
|
-
}
|
|
283
|
+
var archiveUrl = "https://github.com/chadbyte/clay-chrome/archive/refs/heads/main.zip";
|
|
284
|
+
httpGetBinary(archiveUrl).then(function (buf) {
|
|
285
|
+
res.writeHead(200, {
|
|
286
|
+
"Content-Type": "application/zip",
|
|
287
|
+
"Content-Disposition": 'attachment; filename="clay-chrome-extension.zip"',
|
|
288
|
+
"Content-Length": buf.length,
|
|
289
|
+
});
|
|
290
|
+
res.end(buf);
|
|
291
|
+
}).catch(function (err) {
|
|
292
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
293
|
+
res.end(JSON.stringify({ error: "Failed to download extension: " + (err.message || "unknown error") }));
|
|
673
294
|
});
|
|
674
295
|
return;
|
|
675
296
|
}
|
|
676
297
|
|
|
677
|
-
//
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
return;
|
|
685
|
-
}
|
|
686
|
-
if (users.hasAdmin()) {
|
|
687
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
688
|
-
res.end('{"error":"Admin already exists"}');
|
|
689
|
-
return;
|
|
690
|
-
}
|
|
691
|
-
var body = "";
|
|
692
|
-
req.on("data", function (chunk) { body += chunk; });
|
|
693
|
-
req.on("end", function () {
|
|
694
|
-
try {
|
|
695
|
-
var data = JSON.parse(body);
|
|
696
|
-
if (!users.validateSetupCode(data.setupCode)) {
|
|
697
|
-
res.writeHead(401, { "Content-Type": "application/json" });
|
|
698
|
-
res.end('{"error":"Invalid setup code"}');
|
|
699
|
-
return;
|
|
700
|
-
}
|
|
701
|
-
if (!data.username || data.username.trim().length < 1 || data.username.length > 100) {
|
|
702
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
703
|
-
res.end('{"error":"Username is required"}');
|
|
704
|
-
return;
|
|
705
|
-
}
|
|
706
|
-
if (!data.pin || !/^\d{6}$/.test(data.pin)) {
|
|
707
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
708
|
-
res.end('{"error":"PIN must be exactly 6 digits"}');
|
|
709
|
-
return;
|
|
710
|
-
}
|
|
711
|
-
// Migrate existing profile.json to admin profile
|
|
712
|
-
var adminProfile = undefined;
|
|
713
|
-
try {
|
|
714
|
-
var existingProfile = JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, "profile.json"), "utf8"));
|
|
715
|
-
adminProfile = {
|
|
716
|
-
name: data.displayName || data.username,
|
|
717
|
-
lang: existingProfile.lang || "en-US",
|
|
718
|
-
avatarColor: existingProfile.avatarColor || "#7c3aed",
|
|
719
|
-
avatarStyle: existingProfile.avatarStyle || "thumbs",
|
|
720
|
-
avatarSeed: existingProfile.avatarSeed || crypto.randomBytes(4).toString("hex"),
|
|
721
|
-
};
|
|
722
|
-
} catch (e) {}
|
|
723
|
-
var result = users.createAdmin({
|
|
724
|
-
username: data.username.trim(),
|
|
725
|
-
displayName: data.displayName || data.username.trim(),
|
|
726
|
-
pin: data.pin,
|
|
727
|
-
profile: adminProfile,
|
|
728
|
-
});
|
|
729
|
-
if (result.error) {
|
|
730
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
731
|
-
res.end(JSON.stringify({ error: result.error }));
|
|
732
|
-
return;
|
|
733
|
-
}
|
|
734
|
-
// Auto-provision Linux account if OS users mode is enabled
|
|
735
|
-
if (osUsers && !result.user.linuxUser) {
|
|
736
|
-
var provision = provisionLinuxUser(result.user.username);
|
|
737
|
-
if (provision.ok) {
|
|
738
|
-
users.updateLinuxUser(result.user.id, provision.linuxUser);
|
|
739
|
-
if (onUserProvisioned) onUserProvisioned(result.user.id, provision.linuxUser);
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
users.clearSetupCode();
|
|
743
|
-
var session = createMultiUserSession(result.user.id, tlsOptions);
|
|
744
|
-
res.writeHead(200, {
|
|
745
|
-
"Set-Cookie": session.cookie,
|
|
746
|
-
"Content-Type": "application/json",
|
|
747
|
-
});
|
|
748
|
-
res.end(JSON.stringify({ ok: true, user: { id: result.user.id, username: result.user.username, role: result.user.role } }));
|
|
749
|
-
} catch (e) {
|
|
750
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
751
|
-
res.end('{"error":"Invalid request"}');
|
|
752
|
-
}
|
|
298
|
+
// CORS preflight for cross-origin requests (HTTP onboarding → HTTPS)
|
|
299
|
+
if (req.method === "OPTIONS") {
|
|
300
|
+
res.writeHead(204, {
|
|
301
|
+
"Access-Control-Allow-Origin": "*",
|
|
302
|
+
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
303
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
304
|
+
"Access-Control-Max-Age": "86400",
|
|
753
305
|
});
|
|
306
|
+
res.end();
|
|
754
307
|
return;
|
|
755
308
|
}
|
|
756
309
|
|
|
757
|
-
//
|
|
758
|
-
if (
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
res.writeHead(429, { "Content-Type": "application/json" });
|
|
768
|
-
res.end(JSON.stringify({ ok: false, locked: true, retryAfter: remaining }));
|
|
769
|
-
return;
|
|
770
|
-
}
|
|
771
|
-
var body = "";
|
|
772
|
-
req.on("data", function (chunk) { body += chunk; });
|
|
773
|
-
req.on("end", function () {
|
|
774
|
-
try {
|
|
775
|
-
var data = JSON.parse(body);
|
|
776
|
-
var user = users.authenticateUser(data.username, data.pin);
|
|
777
|
-
if (!user) {
|
|
778
|
-
recordPinFailure(ip);
|
|
779
|
-
var attemptsLeft = PIN_MAX_ATTEMPTS - (pinAttempts[ip] ? pinAttempts[ip].count : 0);
|
|
780
|
-
res.writeHead(401, { "Content-Type": "application/json" });
|
|
781
|
-
res.end(JSON.stringify({ ok: false, error: "Invalid username or PIN", attemptsLeft: Math.max(attemptsLeft, 0) }));
|
|
782
|
-
return;
|
|
783
|
-
}
|
|
784
|
-
clearPinFailures(ip);
|
|
785
|
-
var session = createMultiUserSession(user.id, tlsOptions);
|
|
786
|
-
var loginResp = { ok: true, user: { id: user.id, username: user.username, role: user.role } };
|
|
787
|
-
if (user.mustChangePin) loginResp.mustChangePin = true;
|
|
788
|
-
res.writeHead(200, {
|
|
789
|
-
"Set-Cookie": session.cookie,
|
|
790
|
-
"Content-Type": "application/json",
|
|
791
|
-
});
|
|
792
|
-
res.end(JSON.stringify(loginResp));
|
|
793
|
-
} catch (e) {
|
|
794
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
795
|
-
res.end('{"error":"Invalid request"}');
|
|
796
|
-
}
|
|
310
|
+
// Setup page
|
|
311
|
+
if (fullUrl === "/setup" && req.method === "GET") {
|
|
312
|
+
var host = req.headers.host || "localhost";
|
|
313
|
+
var hostname = host.split(":")[0];
|
|
314
|
+
var protocol = tlsOptions ? "https" : "http";
|
|
315
|
+
var setupUrl = protocol + "://" + hostname + ":" + portNum;
|
|
316
|
+
var lanMode = /[?&]mode=lan/.test(req.url);
|
|
317
|
+
res.writeHead(200, {
|
|
318
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
319
|
+
"Access-Control-Allow-Origin": "*",
|
|
797
320
|
});
|
|
321
|
+
res.end(pages.setupPageHtml(setupUrl, setupUrl, !!caContent, lanMode));
|
|
798
322
|
return;
|
|
799
323
|
}
|
|
800
324
|
|
|
801
|
-
//
|
|
802
|
-
if (
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
var remaining = checkPinRateLimit(ip);
|
|
810
|
-
if (remaining !== null) {
|
|
811
|
-
res.writeHead(429, { "Content-Type": "application/json" });
|
|
812
|
-
res.end(JSON.stringify({ ok: false, locked: true, retryAfter: remaining }));
|
|
813
|
-
return;
|
|
814
|
-
}
|
|
815
|
-
var body = "";
|
|
816
|
-
req.on("data", function (chunk) { body += chunk; });
|
|
817
|
-
req.on("end", function () {
|
|
818
|
-
try {
|
|
819
|
-
var data = JSON.parse(body);
|
|
820
|
-
if (!data.email) {
|
|
821
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
822
|
-
res.end('{"error":"Email is required"}');
|
|
823
|
-
return;
|
|
824
|
-
}
|
|
825
|
-
var user = users.findUserByEmail(data.email);
|
|
826
|
-
if (!user) {
|
|
827
|
-
// Don't reveal whether user exists — still say ok
|
|
828
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
829
|
-
res.end('{"ok":true}');
|
|
830
|
-
return;
|
|
831
|
-
}
|
|
832
|
-
var result = smtp.requestOtp(data.email);
|
|
833
|
-
if (result.error) {
|
|
834
|
-
res.writeHead(429, { "Content-Type": "application/json" });
|
|
835
|
-
res.end(JSON.stringify({ error: result.error }));
|
|
836
|
-
return;
|
|
837
|
-
}
|
|
838
|
-
smtp.sendOtpEmail(data.email, result.code).then(function () {
|
|
839
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
840
|
-
res.end('{"ok":true}');
|
|
841
|
-
}).catch(function () {
|
|
842
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
843
|
-
res.end('{"error":"Failed to send email"}');
|
|
844
|
-
});
|
|
845
|
-
} catch (e) {
|
|
846
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
847
|
-
res.end('{"error":"Invalid request"}');
|
|
848
|
-
}
|
|
325
|
+
// PWA install guide (builtin cert mode, no CA step needed)
|
|
326
|
+
if (fullUrl === "/pwa" && req.method === "GET") {
|
|
327
|
+
var host = req.headers.host || "localhost";
|
|
328
|
+
var hostname = host.split(":")[0];
|
|
329
|
+
var protocol = tlsOptions ? "https" : "http";
|
|
330
|
+
var pwaUrl = protocol + "://" + hostname + ":" + portNum;
|
|
331
|
+
res.writeHead(200, {
|
|
332
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
849
333
|
});
|
|
334
|
+
res.end(pages.setupPageHtml(pwaUrl, pwaUrl, false, true));
|
|
850
335
|
return;
|
|
851
336
|
}
|
|
852
337
|
|
|
853
|
-
//
|
|
854
|
-
if (req.method === "
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
var remaining = checkPinRateLimit(ip);
|
|
862
|
-
if (remaining !== null) {
|
|
863
|
-
res.writeHead(429, { "Content-Type": "application/json" });
|
|
864
|
-
res.end(JSON.stringify({ ok: false, locked: true, retryAfter: remaining }));
|
|
865
|
-
return;
|
|
866
|
-
}
|
|
867
|
-
var body = "";
|
|
868
|
-
req.on("data", function (chunk) { body += chunk; });
|
|
869
|
-
req.on("end", function () {
|
|
870
|
-
try {
|
|
871
|
-
var data = JSON.parse(body);
|
|
872
|
-
if (!data.email || !data.code) {
|
|
873
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
874
|
-
res.end('{"error":"Email and code are required"}');
|
|
875
|
-
return;
|
|
876
|
-
}
|
|
877
|
-
var otpResult = smtp.verifyOtp(data.email, data.code);
|
|
878
|
-
if (!otpResult.valid) {
|
|
879
|
-
recordPinFailure(ip);
|
|
880
|
-
res.writeHead(401, { "Content-Type": "application/json" });
|
|
881
|
-
res.end(JSON.stringify({ ok: false, error: otpResult.error, attemptsLeft: otpResult.attemptsLeft }));
|
|
882
|
-
return;
|
|
883
|
-
}
|
|
884
|
-
var user = users.findUserByEmail(data.email);
|
|
885
|
-
if (!user) {
|
|
886
|
-
res.writeHead(401, { "Content-Type": "application/json" });
|
|
887
|
-
res.end('{"ok":false,"error":"Account not found"}');
|
|
888
|
-
return;
|
|
889
|
-
}
|
|
890
|
-
clearPinFailures(ip);
|
|
891
|
-
var session = createMultiUserSession(user.id, tlsOptions);
|
|
892
|
-
res.writeHead(200, {
|
|
893
|
-
"Set-Cookie": session.cookie,
|
|
894
|
-
"Content-Type": "application/json",
|
|
895
|
-
});
|
|
896
|
-
res.end(JSON.stringify({ ok: true, user: { id: user.id, username: user.username, role: user.role } }));
|
|
897
|
-
} catch (e) {
|
|
898
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
899
|
-
res.end('{"error":"Invalid request"}');
|
|
900
|
-
}
|
|
901
|
-
});
|
|
902
|
-
return;
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
// Invite registration
|
|
906
|
-
if (req.method === "POST" && fullUrl === "/auth/register") {
|
|
907
|
-
if (!users.isMultiUser()) {
|
|
908
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
909
|
-
res.end('{"error":"Multi-user mode is not enabled"}');
|
|
910
|
-
return;
|
|
911
|
-
}
|
|
912
|
-
var body = "";
|
|
913
|
-
req.on("data", function (chunk) { body += chunk; });
|
|
914
|
-
req.on("end", function () {
|
|
915
|
-
try {
|
|
916
|
-
var data = JSON.parse(body);
|
|
917
|
-
var validation = users.validateInvite(data.inviteCode);
|
|
918
|
-
if (!validation.valid) {
|
|
919
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
920
|
-
res.end(JSON.stringify({ error: validation.error }));
|
|
921
|
-
return;
|
|
922
|
-
}
|
|
923
|
-
if (!data.username || data.username.trim().length < 1 || data.username.length > 100) {
|
|
924
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
925
|
-
res.end('{"error":"Username is required"}');
|
|
926
|
-
return;
|
|
927
|
-
}
|
|
928
|
-
var result;
|
|
929
|
-
if (smtp.isEmailLoginEnabled() && !data.pin) {
|
|
930
|
-
// SMTP mode: username + email required, no PIN
|
|
931
|
-
if (!data.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
|
932
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
933
|
-
res.end('{"error":"A valid email address is required"}');
|
|
934
|
-
return;
|
|
935
|
-
}
|
|
936
|
-
result = users.createUserWithoutPin({
|
|
937
|
-
username: data.username.trim(),
|
|
938
|
-
email: data.email,
|
|
939
|
-
displayName: data.displayName || data.username.trim(),
|
|
940
|
-
role: "user",
|
|
941
|
-
});
|
|
942
|
-
} else {
|
|
943
|
-
// PIN mode: username + PIN, no email required
|
|
944
|
-
if (!data.pin || !/^\d{6}$/.test(data.pin)) {
|
|
945
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
946
|
-
res.end('{"error":"PIN must be exactly 6 digits"}');
|
|
947
|
-
return;
|
|
948
|
-
}
|
|
949
|
-
result = users.createUser({
|
|
950
|
-
username: data.username.trim(),
|
|
951
|
-
email: data.email || null,
|
|
952
|
-
displayName: data.displayName || data.username.trim(),
|
|
953
|
-
pin: data.pin,
|
|
954
|
-
role: "user",
|
|
955
|
-
});
|
|
956
|
-
}
|
|
957
|
-
if (result.error) {
|
|
958
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
959
|
-
res.end(JSON.stringify({ error: result.error }));
|
|
960
|
-
return;
|
|
961
|
-
}
|
|
962
|
-
// Auto-provision Linux account if OS users mode is enabled
|
|
963
|
-
if (osUsers && !result.user.linuxUser) {
|
|
964
|
-
var provision = provisionLinuxUser(result.user.username);
|
|
965
|
-
if (provision.ok) {
|
|
966
|
-
users.updateLinuxUser(result.user.id, provision.linuxUser);
|
|
967
|
-
if (onUserProvisioned) onUserProvisioned(result.user.id, provision.linuxUser);
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
users.markInviteUsed(data.inviteCode);
|
|
971
|
-
var session = createMultiUserSession(result.user.id, tlsOptions);
|
|
972
|
-
res.writeHead(200, {
|
|
973
|
-
"Set-Cookie": session.cookie,
|
|
974
|
-
"Content-Type": "application/json",
|
|
975
|
-
});
|
|
976
|
-
res.end(JSON.stringify({ ok: true, user: { id: result.user.id, username: result.user.username, role: result.user.role } }));
|
|
977
|
-
} catch (e) {
|
|
978
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
979
|
-
res.end('{"error":"Invalid request"}');
|
|
980
|
-
}
|
|
981
|
-
});
|
|
982
|
-
return;
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
// Logout
|
|
986
|
-
if (req.method === "POST" && fullUrl === "/auth/logout") {
|
|
987
|
-
if (users.isMultiUser()) {
|
|
988
|
-
var cookies = parseCookies(req);
|
|
989
|
-
var token = cookies[MULTI_USER_COOKIE];
|
|
990
|
-
if (token && multiUserTokens[token]) {
|
|
991
|
-
delete multiUserTokens[token];
|
|
992
|
-
saveTokens();
|
|
993
|
-
}
|
|
994
|
-
res.writeHead(200, {
|
|
995
|
-
"Set-Cookie": MULTI_USER_COOKIE + "=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0" + (tlsOptions ? "; Secure" : ""),
|
|
996
|
-
"Content-Type": "application/json",
|
|
997
|
-
});
|
|
998
|
-
} else {
|
|
999
|
-
res.writeHead(200, {
|
|
1000
|
-
"Set-Cookie": "relay_auth=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0" + (tlsOptions ? "; Secure" : ""),
|
|
1001
|
-
"Content-Type": "application/json",
|
|
1002
|
-
});
|
|
1003
|
-
}
|
|
1004
|
-
res.end('{"ok":true}');
|
|
1005
|
-
return;
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
// Invite page (magic link)
|
|
1009
|
-
if (req.method === "GET" && fullUrl.indexOf("/invite/") === 0) {
|
|
1010
|
-
var inviteCode = fullUrl.substring("/invite/".length);
|
|
1011
|
-
if (!users.isMultiUser()) {
|
|
1012
|
-
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
1013
|
-
res.end("Not found");
|
|
1014
|
-
return;
|
|
1015
|
-
}
|
|
1016
|
-
var validation = users.validateInvite(inviteCode);
|
|
1017
|
-
if (!validation.valid) {
|
|
1018
|
-
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
1019
|
-
res.end('<!DOCTYPE html><html><head><title>Clay</title>' +
|
|
1020
|
-
'<style>body{background:#2F2E2B;color:#E8E5DE;font-family:system-ui;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}' +
|
|
1021
|
-
'.c{text-align:center;max-width:360px;padding:20px}h1{color:#DA7756;margin-bottom:16px}p{color:#908B81}</style></head>' +
|
|
1022
|
-
'<body><div class="c"><h1>Clay</h1><p>' + (validation.error === "Invite expired" ? "This invite link has expired." : validation.error === "Invite already used" ? "This invite link has already been used." : "Invalid invite link.") + '</p></div></body></html>');
|
|
1023
|
-
return;
|
|
1024
|
-
}
|
|
1025
|
-
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
1026
|
-
res.end(smtp.isEmailLoginEnabled() ? smtpInvitePageHtml(inviteCode) : invitePageHtml(inviteCode));
|
|
1027
|
-
return;
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
// CA certificate download
|
|
1031
|
-
if (req.url === "/ca/download" && req.method === "GET" && caContent) {
|
|
1032
|
-
res.writeHead(200, {
|
|
1033
|
-
"Content-Type": "application/x-pem-file",
|
|
1034
|
-
"Content-Disposition": 'attachment; filename="clay-ca.pem"',
|
|
1035
|
-
});
|
|
1036
|
-
res.end(caContent);
|
|
1037
|
-
return;
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
// Chrome extension download (proxy from GitHub)
|
|
1041
|
-
if (fullUrl === "/api/extension/download" && req.method === "GET") {
|
|
1042
|
-
if (!isRequestAuthed(req)) {
|
|
1043
|
-
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1044
|
-
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
1045
|
-
return;
|
|
1046
|
-
}
|
|
1047
|
-
var archiveUrl = "https://github.com/chadbyte/clay-chrome/archive/refs/heads/main.zip";
|
|
1048
|
-
httpGetBinary(archiveUrl).then(function (buf) {
|
|
1049
|
-
res.writeHead(200, {
|
|
1050
|
-
"Content-Type": "application/zip",
|
|
1051
|
-
"Content-Disposition": 'attachment; filename="clay-chrome-extension.zip"',
|
|
1052
|
-
"Content-Length": buf.length,
|
|
1053
|
-
});
|
|
1054
|
-
res.end(buf);
|
|
1055
|
-
}).catch(function (err) {
|
|
1056
|
-
res.writeHead(502, { "Content-Type": "application/json" });
|
|
1057
|
-
res.end(JSON.stringify({ error: "Failed to download extension: " + (err.message || "unknown error") }));
|
|
1058
|
-
});
|
|
1059
|
-
return;
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
// CORS preflight for cross-origin requests (HTTP onboarding → HTTPS)
|
|
1063
|
-
if (req.method === "OPTIONS") {
|
|
1064
|
-
res.writeHead(204, {
|
|
1065
|
-
"Access-Control-Allow-Origin": "*",
|
|
1066
|
-
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
1067
|
-
"Access-Control-Allow-Headers": "Content-Type",
|
|
1068
|
-
"Access-Control-Max-Age": "86400",
|
|
1069
|
-
});
|
|
1070
|
-
res.end();
|
|
1071
|
-
return;
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
// Setup page
|
|
1075
|
-
if (fullUrl === "/setup" && req.method === "GET") {
|
|
1076
|
-
var host = req.headers.host || "localhost";
|
|
1077
|
-
var hostname = host.split(":")[0];
|
|
1078
|
-
var protocol = tlsOptions ? "https" : "http";
|
|
1079
|
-
var setupUrl = protocol + "://" + hostname + ":" + portNum;
|
|
1080
|
-
var lanMode = /[?&]mode=lan/.test(req.url);
|
|
1081
|
-
res.writeHead(200, {
|
|
1082
|
-
"Content-Type": "text/html; charset=utf-8",
|
|
1083
|
-
"Access-Control-Allow-Origin": "*",
|
|
1084
|
-
});
|
|
1085
|
-
res.end(setupPageHtml(setupUrl, setupUrl, !!caContent, lanMode));
|
|
1086
|
-
return;
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
// PWA install guide (builtin cert mode, no CA step needed)
|
|
1090
|
-
if (fullUrl === "/pwa" && req.method === "GET") {
|
|
1091
|
-
var host = req.headers.host || "localhost";
|
|
1092
|
-
var hostname = host.split(":")[0];
|
|
1093
|
-
var protocol = tlsOptions ? "https" : "http";
|
|
1094
|
-
var pwaUrl = protocol + "://" + hostname + ":" + portNum;
|
|
1095
|
-
res.writeHead(200, {
|
|
1096
|
-
"Content-Type": "text/html; charset=utf-8",
|
|
1097
|
-
});
|
|
1098
|
-
res.end(setupPageHtml(pwaUrl, pwaUrl, false, true));
|
|
1099
|
-
return;
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
// Global push endpoints (used by setup page)
|
|
1103
|
-
if (req.method === "GET" && fullUrl === "/api/vapid-public-key" && pushModule) {
|
|
1104
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1105
|
-
res.end(JSON.stringify({ publicKey: pushModule.publicKey }));
|
|
1106
|
-
return;
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
if (req.method === "POST" && fullUrl === "/api/push-subscribe" && pushModule) {
|
|
338
|
+
// Global push endpoints (used by setup page)
|
|
339
|
+
if (req.method === "GET" && fullUrl === "/api/vapid-public-key" && pushModule) {
|
|
340
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
341
|
+
res.end(JSON.stringify({ publicKey: pushModule.publicKey }));
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (req.method === "POST" && fullUrl === "/api/push-subscribe" && pushModule) {
|
|
1110
346
|
var body = "";
|
|
1111
347
|
req.on("data", function (chunk) { body += chunk; });
|
|
1112
348
|
req.on("end", function () {
|
|
@@ -1117,1315 +353,88 @@ function createServer(opts) {
|
|
|
1117
353
|
pushModule.addSubscription(sub, parsed.replaceEndpoint, _httpPushUser ? _httpPushUser.id : null);
|
|
1118
354
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1119
355
|
res.end('{"ok":true}');
|
|
1120
|
-
} catch (e) {
|
|
1121
|
-
res.writeHead(400);
|
|
1122
|
-
res.end("Bad request");
|
|
1123
|
-
}
|
|
1124
|
-
});
|
|
1125
|
-
return;
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
// Health check endpoint
|
|
1129
|
-
// Unauthenticated: minimal liveness info only
|
|
1130
|
-
// Authenticated: full system details (memory, pid, version, sessions)
|
|
1131
|
-
if (req.method === "GET" && fullUrl === "/api/health") {
|
|
1132
|
-
var health = {
|
|
1133
|
-
status: "ok",
|
|
1134
|
-
timestamp: new Date().toISOString(),
|
|
1135
|
-
};
|
|
1136
|
-
if (isRequestAuthed(req)) {
|
|
1137
|
-
var mem = process.memoryUsage();
|
|
1138
|
-
var activeSessions = 0;
|
|
1139
|
-
projects.forEach(function (ctx) {
|
|
1140
|
-
if (ctx && ctx.clients) {
|
|
1141
|
-
activeSessions += ctx.clients.size || 0;
|
|
1142
|
-
}
|
|
1143
|
-
});
|
|
1144
|
-
health.uptime = process.uptime();
|
|
1145
|
-
health.version = pkg.version;
|
|
1146
|
-
health.node = process.version;
|
|
1147
|
-
health.sessions = activeSessions;
|
|
1148
|
-
health.projects = projects.size;
|
|
1149
|
-
health.memory = {
|
|
1150
|
-
rss: mem.rss,
|
|
1151
|
-
heapUsed: mem.heapUsed,
|
|
1152
|
-
heapTotal: mem.heapTotal,
|
|
1153
|
-
};
|
|
1154
|
-
health.pid = process.pid;
|
|
1155
|
-
}
|
|
1156
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1157
|
-
res.end(JSON.stringify(health));
|
|
1158
|
-
return;
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
// Theme list: bundled (lib/themes/) + user (~/.clay/themes/)
|
|
1162
|
-
if (req.method === "GET" && fullUrl === "/api/themes") {
|
|
1163
|
-
var bundled = {};
|
|
1164
|
-
var custom = {};
|
|
1165
|
-
// Read bundled themes
|
|
1166
|
-
try {
|
|
1167
|
-
var bFiles = fs.readdirSync(bundledThemesDir);
|
|
1168
|
-
for (var i = 0; i < bFiles.length; i++) {
|
|
1169
|
-
if (!bFiles[i].endsWith(".json")) continue;
|
|
1170
|
-
try {
|
|
1171
|
-
var raw = fs.readFileSync(path.join(bundledThemesDir, bFiles[i]), "utf8");
|
|
1172
|
-
var id = bFiles[i].replace(/\.json$/, "");
|
|
1173
|
-
bundled[id] = JSON.parse(raw);
|
|
1174
|
-
} catch (e) {}
|
|
1175
|
-
}
|
|
1176
|
-
} catch (e) {}
|
|
1177
|
-
// Read user themes (override bundled if same id)
|
|
1178
|
-
try {
|
|
1179
|
-
var uFiles = fs.readdirSync(userThemesDir);
|
|
1180
|
-
for (var j = 0; j < uFiles.length; j++) {
|
|
1181
|
-
if (!uFiles[j].endsWith(".json")) continue;
|
|
1182
|
-
try {
|
|
1183
|
-
var uRaw = fs.readFileSync(path.join(userThemesDir, uFiles[j]), "utf8");
|
|
1184
|
-
var uid = uFiles[j].replace(/\.json$/, "");
|
|
1185
|
-
custom[uid] = JSON.parse(uRaw);
|
|
1186
|
-
} catch (e) {}
|
|
1187
|
-
}
|
|
1188
|
-
} catch (e) {}
|
|
1189
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1190
|
-
res.end(JSON.stringify({ bundled: bundled, custom: custom }));
|
|
1191
|
-
return;
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
|
-
// User profile — user-scoped in multi-user mode, global in single-user mode
|
|
1195
|
-
var profilePath = path.join(CONFIG_DIR, "profile.json");
|
|
1196
|
-
|
|
1197
|
-
if (req.method === "GET" && fullUrl === "/api/profile") {
|
|
1198
|
-
if (users.isMultiUser()) {
|
|
1199
|
-
var mu = getMultiUserFromReq(req);
|
|
1200
|
-
if (!mu) {
|
|
1201
|
-
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1202
|
-
res.end('{"error":"unauthorized"}');
|
|
1203
|
-
return;
|
|
1204
|
-
}
|
|
1205
|
-
var profile = mu.profile || { name: "", lang: "en-US", avatarColor: "#7c3aed", avatarStyle: "thumbs", avatarSeed: "", avatarCustom: "" };
|
|
1206
|
-
profile.username = mu.username;
|
|
1207
|
-
profile.userId = mu.id;
|
|
1208
|
-
profile.role = mu.role;
|
|
1209
|
-
profile.autoContinueOnRateLimit = !!mu.autoContinueOnRateLimit;
|
|
1210
|
-
profile.chatLayout = mu.chatLayout || "channel";
|
|
1211
|
-
profile.mateOnboardingShown = !!mu.mateOnboardingShown;
|
|
1212
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1213
|
-
res.end(JSON.stringify(profile));
|
|
1214
|
-
return;
|
|
1215
|
-
}
|
|
1216
|
-
var profile = { name: "", lang: "en-US", avatarColor: "#7c3aed", avatarStyle: "thumbs", avatarSeed: "", avatarCustom: "" };
|
|
1217
|
-
try {
|
|
1218
|
-
var raw = fs.readFileSync(profilePath, "utf8");
|
|
1219
|
-
var saved = JSON.parse(raw);
|
|
1220
|
-
if (saved.name !== undefined) profile.name = saved.name;
|
|
1221
|
-
if (saved.lang) profile.lang = saved.lang;
|
|
1222
|
-
if (saved.avatarColor) profile.avatarColor = saved.avatarColor;
|
|
1223
|
-
if (saved.avatarStyle) profile.avatarStyle = saved.avatarStyle;
|
|
1224
|
-
if (saved.avatarSeed) profile.avatarSeed = saved.avatarSeed;
|
|
1225
|
-
if (saved.avatarCustom) profile.avatarCustom = saved.avatarCustom;
|
|
1226
|
-
} catch (e) { /* file doesn't exist yet */ }
|
|
1227
|
-
// Single-user settings from daemon config
|
|
1228
|
-
if (typeof opts.onGetDaemonConfig === "function") {
|
|
1229
|
-
var dc = opts.onGetDaemonConfig();
|
|
1230
|
-
profile.autoContinueOnRateLimit = !!dc.autoContinueOnRateLimit;
|
|
1231
|
-
profile.chatLayout = dc.chatLayout || "channel";
|
|
1232
|
-
profile.mateOnboardingShown = !!dc.mateOnboardingShown;
|
|
1233
|
-
}
|
|
1234
|
-
// Check if custom avatar file exists
|
|
1235
|
-
try {
|
|
1236
|
-
var avatarFiles = fs.readdirSync(path.join(CONFIG_DIR, "avatars"));
|
|
1237
|
-
for (var afi = 0; afi < avatarFiles.length; afi++) {
|
|
1238
|
-
if (avatarFiles[afi].startsWith("default.")) {
|
|
1239
|
-
profile.avatarCustom = "/api/avatar/default?v=" + fs.statSync(path.join(CONFIG_DIR, "avatars", avatarFiles[afi])).mtimeMs;
|
|
1240
|
-
break;
|
|
1241
|
-
}
|
|
1242
|
-
}
|
|
1243
|
-
} catch (e) {}
|
|
1244
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1245
|
-
res.end(JSON.stringify(profile));
|
|
1246
|
-
return;
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
if (req.method === "PUT" && fullUrl === "/api/profile") {
|
|
1250
|
-
var body = "";
|
|
1251
|
-
req.on("data", function (chunk) { body += chunk; });
|
|
1252
|
-
req.on("end", function () {
|
|
1253
|
-
try {
|
|
1254
|
-
var data = JSON.parse(body);
|
|
1255
|
-
var profile = {};
|
|
1256
|
-
if (typeof data.name === "string") profile.name = data.name.substring(0, 50);
|
|
1257
|
-
if (typeof data.lang === "string") profile.lang = data.lang.substring(0, 10);
|
|
1258
|
-
if (typeof data.avatarColor === "string" && /^#[0-9a-fA-F]{6}$/.test(data.avatarColor)) {
|
|
1259
|
-
profile.avatarColor = data.avatarColor;
|
|
1260
|
-
}
|
|
1261
|
-
if (typeof data.avatarStyle === "string") profile.avatarStyle = data.avatarStyle.substring(0, 30);
|
|
1262
|
-
if (typeof data.avatarSeed === "string") profile.avatarSeed = data.avatarSeed.substring(0, 30);
|
|
1263
|
-
if (typeof data.avatarCustom === "string") profile.avatarCustom = data.avatarCustom;
|
|
1264
|
-
if (data.avatarCustom === null || data.avatarCustom === "") profile.avatarCustom = undefined;
|
|
1265
|
-
if (users.isMultiUser()) {
|
|
1266
|
-
var mu = getMultiUserFromReq(req);
|
|
1267
|
-
if (!mu) {
|
|
1268
|
-
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1269
|
-
res.end('{"error":"unauthorized"}');
|
|
1270
|
-
return;
|
|
1271
|
-
}
|
|
1272
|
-
users.updateUserProfile(mu.id, profile);
|
|
1273
|
-
// Broadcast updated avatar/presence to all projects
|
|
1274
|
-
projects.forEach(function (pCtx) {
|
|
1275
|
-
pCtx.refreshUserProfile(mu.id);
|
|
1276
|
-
});
|
|
1277
|
-
} else {
|
|
1278
|
-
fs.writeFileSync(profilePath, JSON.stringify(profile, null, 2), "utf8");
|
|
1279
|
-
if (process.platform !== "win32") {
|
|
1280
|
-
try { fs.chmodSync(profilePath, 0o600); } catch (chmodErr) {}
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1284
|
-
res.end(JSON.stringify(profile));
|
|
1285
|
-
} catch (e) {
|
|
1286
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1287
|
-
res.end(JSON.stringify({ error: "Invalid request" }));
|
|
1288
|
-
}
|
|
1289
|
-
});
|
|
1290
|
-
return;
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
// Upload custom avatar image
|
|
1294
|
-
if (req.method === "POST" && fullUrl === "/api/avatar") {
|
|
1295
|
-
var chunks = [];
|
|
1296
|
-
var totalSize = 0;
|
|
1297
|
-
var maxSize = 2 * 1024 * 1024; // 2MB
|
|
1298
|
-
req.on("data", function (chunk) {
|
|
1299
|
-
totalSize += chunk.length;
|
|
1300
|
-
if (totalSize <= maxSize) chunks.push(chunk);
|
|
1301
|
-
});
|
|
1302
|
-
req.on("end", function () {
|
|
1303
|
-
if (totalSize > maxSize) {
|
|
1304
|
-
res.writeHead(413, { "Content-Type": "application/json" });
|
|
1305
|
-
res.end('{"error":"File too large (max 2MB)"}');
|
|
1306
|
-
return;
|
|
1307
|
-
}
|
|
1308
|
-
var raw = Buffer.concat(chunks);
|
|
1309
|
-
// Detect content type from magic bytes
|
|
1310
|
-
var ct = null;
|
|
1311
|
-
if (raw[0] === 0xFF && raw[1] === 0xD8) ct = "image/jpeg";
|
|
1312
|
-
else if (raw[0] === 0x89 && raw[1] === 0x50) ct = "image/png";
|
|
1313
|
-
else if (raw[0] === 0x47 && raw[1] === 0x49) ct = "image/gif";
|
|
1314
|
-
else if (raw[0] === 0x52 && raw[1] === 0x49) ct = "image/webp";
|
|
1315
|
-
if (!ct) {
|
|
1316
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1317
|
-
res.end('{"error":"Unsupported image format"}');
|
|
1318
|
-
return;
|
|
1319
|
-
}
|
|
1320
|
-
var ext = ct.split("/")[1] === "jpeg" ? "jpg" : ct.split("/")[1];
|
|
1321
|
-
var avatarDir = path.join(CONFIG_DIR, "avatars");
|
|
1322
|
-
fs.mkdirSync(avatarDir, { recursive: true });
|
|
1323
|
-
|
|
1324
|
-
var userId = "default";
|
|
1325
|
-
if (users.isMultiUser()) {
|
|
1326
|
-
var mu = getMultiUserFromReq(req);
|
|
1327
|
-
if (!mu) {
|
|
1328
|
-
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1329
|
-
res.end('{"error":"unauthorized"}');
|
|
1330
|
-
return;
|
|
1331
|
-
}
|
|
1332
|
-
userId = mu.id;
|
|
1333
|
-
}
|
|
1334
|
-
var filename = userId + "." + ext;
|
|
1335
|
-
// Remove old avatar files for this user
|
|
1336
|
-
try {
|
|
1337
|
-
var existing = fs.readdirSync(avatarDir);
|
|
1338
|
-
for (var ei = 0; ei < existing.length; ei++) {
|
|
1339
|
-
if (existing[ei].startsWith(userId + ".")) {
|
|
1340
|
-
fs.unlinkSync(path.join(avatarDir, existing[ei]));
|
|
1341
|
-
}
|
|
1342
|
-
}
|
|
1343
|
-
} catch (e) {}
|
|
1344
|
-
var avatarFilePath = path.join(avatarDir, filename);
|
|
1345
|
-
fs.writeFileSync(avatarFilePath, raw);
|
|
1346
|
-
try { fs.chmodSync(avatarFilePath, 0o644); } catch (e) {}
|
|
1347
|
-
try { fs.chmodSync(avatarDir, 0o755); } catch (e) {}
|
|
1348
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1349
|
-
res.end(JSON.stringify({ ok: true, avatar: "/api/avatar/" + userId + "?v=" + Date.now() }));
|
|
1350
|
-
});
|
|
1351
|
-
return;
|
|
1352
|
-
}
|
|
1353
|
-
|
|
1354
|
-
// Serve custom avatar image
|
|
1355
|
-
if (req.method === "GET" && fullUrl.startsWith("/api/avatar/")) {
|
|
1356
|
-
var avatarUserId = fullUrl.split("/api/avatar/")[1].split("?")[0];
|
|
1357
|
-
var avatarDir = path.join(CONFIG_DIR, "avatars");
|
|
1358
|
-
try {
|
|
1359
|
-
var files = fs.readdirSync(avatarDir);
|
|
1360
|
-
var match = null;
|
|
1361
|
-
for (var fi = 0; fi < files.length; fi++) {
|
|
1362
|
-
if (files[fi].startsWith(avatarUserId + ".")) {
|
|
1363
|
-
match = files[fi];
|
|
1364
|
-
break;
|
|
1365
|
-
}
|
|
1366
|
-
}
|
|
1367
|
-
if (match) {
|
|
1368
|
-
var ext = match.split(".").pop();
|
|
1369
|
-
var ctMap = { jpg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp" };
|
|
1370
|
-
res.writeHead(200, {
|
|
1371
|
-
"Content-Type": ctMap[ext] || "application/octet-stream",
|
|
1372
|
-
"Cache-Control": "public, max-age=31536000, immutable",
|
|
1373
|
-
});
|
|
1374
|
-
res.end(fs.readFileSync(path.join(avatarDir, match)));
|
|
1375
|
-
return;
|
|
1376
|
-
}
|
|
1377
|
-
} catch (e) {}
|
|
1378
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1379
|
-
res.end('{"error":"not found"}');
|
|
1380
|
-
return;
|
|
1381
|
-
}
|
|
1382
|
-
|
|
1383
|
-
// Upload custom avatar for a mate
|
|
1384
|
-
if (req.method === "POST" && fullUrl.startsWith("/api/mate-avatar/")) {
|
|
1385
|
-
var mateIdFromUrl = fullUrl.split("/api/mate-avatar/")[1].split("?")[0];
|
|
1386
|
-
if (!mateIdFromUrl) {
|
|
1387
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1388
|
-
res.end('{"error":"Missing mate ID"}');
|
|
1389
|
-
return;
|
|
1390
|
-
}
|
|
1391
|
-
var chunks = [];
|
|
1392
|
-
var totalSize = 0;
|
|
1393
|
-
var maxSize = 2 * 1024 * 1024; // 2MB
|
|
1394
|
-
req.on("data", function (chunk) {
|
|
1395
|
-
totalSize += chunk.length;
|
|
1396
|
-
if (totalSize <= maxSize) chunks.push(chunk);
|
|
1397
|
-
});
|
|
1398
|
-
req.on("end", function () {
|
|
1399
|
-
if (totalSize > maxSize) {
|
|
1400
|
-
res.writeHead(413, { "Content-Type": "application/json" });
|
|
1401
|
-
res.end('{"error":"File too large (max 2MB)"}');
|
|
1402
|
-
return;
|
|
1403
|
-
}
|
|
1404
|
-
var raw = Buffer.concat(chunks);
|
|
1405
|
-
var ct = null;
|
|
1406
|
-
if (raw[0] === 0xFF && raw[1] === 0xD8) ct = "image/jpeg";
|
|
1407
|
-
else if (raw[0] === 0x89 && raw[1] === 0x50) ct = "image/png";
|
|
1408
|
-
else if (raw[0] === 0x47 && raw[1] === 0x49) ct = "image/gif";
|
|
1409
|
-
else if (raw[0] === 0x52 && raw[1] === 0x49) ct = "image/webp";
|
|
1410
|
-
if (!ct) {
|
|
1411
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1412
|
-
res.end('{"error":"Unsupported image format"}');
|
|
1413
|
-
return;
|
|
1414
|
-
}
|
|
1415
|
-
var userId = null;
|
|
1416
|
-
if (users.isMultiUser()) {
|
|
1417
|
-
var mu = getMultiUserFromReq(req);
|
|
1418
|
-
if (!mu) {
|
|
1419
|
-
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1420
|
-
res.end('{"error":"unauthorized"}');
|
|
1421
|
-
return;
|
|
1422
|
-
}
|
|
1423
|
-
userId = mu.id;
|
|
1424
|
-
}
|
|
1425
|
-
var mateCtx = mates.buildMateCtx(userId);
|
|
1426
|
-
var mate = mates.getMate(mateCtx, mateIdFromUrl);
|
|
1427
|
-
if (!mate) {
|
|
1428
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1429
|
-
res.end('{"error":"Mate not found"}');
|
|
1430
|
-
return;
|
|
1431
|
-
}
|
|
1432
|
-
var ext = ct.split("/")[1] === "jpeg" ? "jpg" : ct.split("/")[1];
|
|
1433
|
-
var avatarDir = path.join(CONFIG_DIR, "mate-avatars");
|
|
1434
|
-
fs.mkdirSync(avatarDir, { recursive: true });
|
|
1435
|
-
var filename = mateIdFromUrl + "." + ext;
|
|
1436
|
-
// Remove old avatar files for this mate
|
|
1437
|
-
try {
|
|
1438
|
-
var existing = fs.readdirSync(avatarDir);
|
|
1439
|
-
for (var ei = 0; ei < existing.length; ei++) {
|
|
1440
|
-
if (existing[ei].startsWith(mateIdFromUrl + ".")) {
|
|
1441
|
-
fs.unlinkSync(path.join(avatarDir, existing[ei]));
|
|
1442
|
-
}
|
|
1443
|
-
}
|
|
1444
|
-
} catch (e) {}
|
|
1445
|
-
var mateAvatarFilePath = path.join(avatarDir, filename);
|
|
1446
|
-
fs.writeFileSync(mateAvatarFilePath, raw);
|
|
1447
|
-
try { fs.chmodSync(mateAvatarFilePath, 0o644); } catch (e) {}
|
|
1448
|
-
try { fs.chmodSync(avatarDir, 0o755); } catch (e) {}
|
|
1449
|
-
var avatarPath = "/api/mate-avatar/" + mateIdFromUrl + "?v=" + Date.now();
|
|
1450
|
-
// Update mate profile with custom avatar URL
|
|
1451
|
-
var profile = mate.profile || {};
|
|
1452
|
-
profile.avatarCustom = avatarPath;
|
|
1453
|
-
mates.updateMate(mateCtx, mateIdFromUrl, { profile: profile });
|
|
1454
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1455
|
-
res.end(JSON.stringify({ ok: true, avatar: avatarPath }));
|
|
1456
|
-
});
|
|
1457
|
-
return;
|
|
1458
|
-
}
|
|
1459
|
-
|
|
1460
|
-
// Serve custom mate avatar image
|
|
1461
|
-
if (req.method === "GET" && fullUrl.startsWith("/api/mate-avatar/")) {
|
|
1462
|
-
var mateAvatarId = fullUrl.split("/api/mate-avatar/")[1].split("?")[0];
|
|
1463
|
-
var mateAvatarDir = path.join(CONFIG_DIR, "mate-avatars");
|
|
1464
|
-
try {
|
|
1465
|
-
var files = fs.readdirSync(mateAvatarDir);
|
|
1466
|
-
var match = null;
|
|
1467
|
-
for (var fi = 0; fi < files.length; fi++) {
|
|
1468
|
-
if (files[fi].startsWith(mateAvatarId + ".")) {
|
|
1469
|
-
match = files[fi];
|
|
1470
|
-
break;
|
|
1471
|
-
}
|
|
1472
|
-
}
|
|
1473
|
-
if (match) {
|
|
1474
|
-
var ext = match.split(".").pop();
|
|
1475
|
-
var ctMap = { jpg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp" };
|
|
1476
|
-
res.writeHead(200, {
|
|
1477
|
-
"Content-Type": ctMap[ext] || "application/octet-stream",
|
|
1478
|
-
"Cache-Control": "public, max-age=31536000, immutable",
|
|
1479
|
-
});
|
|
1480
|
-
res.end(fs.readFileSync(path.join(mateAvatarDir, match)));
|
|
1481
|
-
return;
|
|
1482
|
-
}
|
|
1483
|
-
} catch (e) {}
|
|
1484
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1485
|
-
res.end('{"error":"not found"}');
|
|
1486
|
-
return;
|
|
1487
|
-
}
|
|
1488
|
-
|
|
1489
|
-
// Change own PIN (multi-user mode)
|
|
1490
|
-
if (req.method === "PUT" && fullUrl === "/api/user/pin") {
|
|
1491
|
-
if (!users.isMultiUser()) {
|
|
1492
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1493
|
-
res.end('{"error":"Not found"}');
|
|
1494
|
-
return;
|
|
1495
|
-
}
|
|
1496
|
-
var mu = getMultiUserFromReq(req);
|
|
1497
|
-
if (!mu) {
|
|
1498
|
-
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1499
|
-
res.end('{"error":"unauthorized"}');
|
|
1500
|
-
return;
|
|
1501
|
-
}
|
|
1502
|
-
var body = "";
|
|
1503
|
-
req.on("data", function (chunk) { body += chunk; });
|
|
1504
|
-
req.on("end", function () {
|
|
1505
|
-
try {
|
|
1506
|
-
var data = JSON.parse(body);
|
|
1507
|
-
if (!data.newPin || typeof data.newPin !== "string" || !/^\d{6}$/.test(data.newPin)) {
|
|
1508
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1509
|
-
res.end('{"error":"PIN must be exactly 6 digits"}');
|
|
1510
|
-
return;
|
|
1511
|
-
}
|
|
1512
|
-
var result = users.updateUserPin(mu.id, data.newPin);
|
|
1513
|
-
if (result.error) {
|
|
1514
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1515
|
-
res.end(JSON.stringify({ error: result.error }));
|
|
1516
|
-
return;
|
|
1517
|
-
}
|
|
1518
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1519
|
-
res.end('{"ok":true}');
|
|
1520
|
-
} catch (e) {
|
|
1521
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1522
|
-
res.end('{"error":"Invalid request"}');
|
|
1523
|
-
}
|
|
1524
|
-
});
|
|
1525
|
-
return;
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
|
-
// PUT /api/user/auto-continue
|
|
1529
|
-
if (req.method === "PUT" && fullUrl === "/api/user/auto-continue") {
|
|
1530
|
-
var mu = getMultiUserFromReq(req);
|
|
1531
|
-
if (!mu) {
|
|
1532
|
-
// Single-user: use daemon config fallback
|
|
1533
|
-
var body = "";
|
|
1534
|
-
req.on("data", function (chunk) { body += chunk; });
|
|
1535
|
-
req.on("end", function () {
|
|
1536
|
-
try {
|
|
1537
|
-
var data = JSON.parse(body);
|
|
1538
|
-
if (typeof opts.onSetAutoContinue === "function") {
|
|
1539
|
-
opts.onSetAutoContinue(!!data.enabled);
|
|
1540
|
-
}
|
|
1541
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1542
|
-
res.end(JSON.stringify({ ok: true, autoContinueOnRateLimit: !!data.enabled }));
|
|
1543
|
-
} catch (e) {
|
|
1544
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1545
|
-
res.end('{"error":"Invalid request"}');
|
|
1546
|
-
}
|
|
1547
|
-
});
|
|
1548
|
-
return;
|
|
1549
|
-
}
|
|
1550
|
-
var body = "";
|
|
1551
|
-
req.on("data", function (chunk) { body += chunk; });
|
|
1552
|
-
req.on("end", function () {
|
|
1553
|
-
try {
|
|
1554
|
-
var data = JSON.parse(body);
|
|
1555
|
-
var result = users.setAutoContinue(mu.id, !!data.enabled);
|
|
1556
|
-
if (result.error) {
|
|
1557
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1558
|
-
res.end(JSON.stringify({ error: result.error }));
|
|
1559
|
-
return;
|
|
1560
|
-
}
|
|
1561
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1562
|
-
res.end(JSON.stringify({ ok: true, autoContinueOnRateLimit: result.autoContinueOnRateLimit }));
|
|
1563
|
-
} catch (e) {
|
|
1564
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1565
|
-
res.end('{"error":"Invalid request"}');
|
|
1566
|
-
}
|
|
1567
|
-
});
|
|
1568
|
-
return;
|
|
1569
|
-
}
|
|
1570
|
-
|
|
1571
|
-
// PUT /api/user/chat-layout
|
|
1572
|
-
if (req.method === "PUT" && fullUrl === "/api/user/chat-layout") {
|
|
1573
|
-
var mu = getMultiUserFromReq(req);
|
|
1574
|
-
if (!mu) {
|
|
1575
|
-
// Single-user: save to daemon config
|
|
1576
|
-
var body = "";
|
|
1577
|
-
req.on("data", function (chunk) { body += chunk; });
|
|
1578
|
-
req.on("end", function () {
|
|
1579
|
-
try {
|
|
1580
|
-
var data = JSON.parse(body);
|
|
1581
|
-
var val = (data.layout === "bubble") ? "bubble" : "channel";
|
|
1582
|
-
if (typeof opts.onSetChatLayout === "function") {
|
|
1583
|
-
opts.onSetChatLayout(val);
|
|
1584
|
-
}
|
|
1585
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1586
|
-
res.end(JSON.stringify({ ok: true, chatLayout: val }));
|
|
1587
|
-
} catch (e) {
|
|
1588
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1589
|
-
res.end('{"error":"Invalid request"}');
|
|
1590
|
-
}
|
|
1591
|
-
});
|
|
1592
|
-
return;
|
|
1593
|
-
}
|
|
1594
|
-
var body = "";
|
|
1595
|
-
req.on("data", function (chunk) { body += chunk; });
|
|
1596
|
-
req.on("end", function () {
|
|
1597
|
-
try {
|
|
1598
|
-
var data = JSON.parse(body);
|
|
1599
|
-
var result = users.setChatLayout(mu.id, data.layout);
|
|
1600
|
-
if (result.error) {
|
|
1601
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1602
|
-
res.end(JSON.stringify({ error: result.error }));
|
|
1603
|
-
return;
|
|
1604
|
-
}
|
|
1605
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1606
|
-
res.end(JSON.stringify({ ok: true, chatLayout: result.chatLayout }));
|
|
1607
|
-
} catch (e) {
|
|
1608
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1609
|
-
res.end('{"error":"Invalid request"}');
|
|
1610
|
-
}
|
|
1611
|
-
});
|
|
1612
|
-
return;
|
|
1613
|
-
}
|
|
1614
|
-
|
|
1615
|
-
// POST /api/user/mate-onboarded
|
|
1616
|
-
if (req.method === "POST" && fullUrl === "/api/user/mate-onboarded") {
|
|
1617
|
-
var mu = getMultiUserFromReq(req);
|
|
1618
|
-
if (!mu) {
|
|
1619
|
-
// Single-user: save to daemon config
|
|
1620
|
-
if (typeof opts.onSetMateOnboarded === "function") {
|
|
1621
|
-
opts.onSetMateOnboarded();
|
|
1622
|
-
}
|
|
1623
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1624
|
-
res.end('{"ok":true}');
|
|
1625
|
-
} else {
|
|
1626
|
-
users.setMateOnboarded(mu.id);
|
|
1627
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1628
|
-
res.end('{"ok":true}');
|
|
1629
|
-
}
|
|
1630
|
-
return;
|
|
1631
|
-
}
|
|
1632
|
-
|
|
1633
|
-
// GET /api/user/auto-continue
|
|
1634
|
-
if (req.method === "GET" && fullUrl === "/api/user/auto-continue") {
|
|
1635
|
-
var mu = getMultiUserFromReq(req);
|
|
1636
|
-
if (!mu) {
|
|
1637
|
-
// Single-user: read from daemon config
|
|
1638
|
-
var enabled = false;
|
|
1639
|
-
if (typeof opts.onGetDaemonConfig === "function") {
|
|
1640
|
-
var dc = opts.onGetDaemonConfig();
|
|
1641
|
-
enabled = !!dc.autoContinueOnRateLimit;
|
|
1642
|
-
}
|
|
1643
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1644
|
-
res.end(JSON.stringify({ autoContinueOnRateLimit: enabled }));
|
|
1645
|
-
return;
|
|
1646
|
-
}
|
|
1647
|
-
var val = users.getAutoContinue(mu.id);
|
|
1648
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1649
|
-
res.end(JSON.stringify({ autoContinueOnRateLimit: val }));
|
|
1650
|
-
return;
|
|
1651
|
-
}
|
|
1652
|
-
|
|
1653
|
-
// --- Admin API endpoints (multi-user mode only) ---
|
|
1654
|
-
|
|
1655
|
-
// List all users (admin only)
|
|
1656
|
-
if (req.method === "GET" && fullUrl === "/api/admin/users") {
|
|
1657
|
-
if (!users.isMultiUser()) {
|
|
1658
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1659
|
-
res.end('{"error":"Not found"}');
|
|
1660
|
-
return;
|
|
1661
|
-
}
|
|
1662
|
-
var mu = getMultiUserFromReq(req);
|
|
1663
|
-
if (!mu) {
|
|
1664
|
-
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1665
|
-
res.end('{"error":"Authentication required"}');
|
|
1666
|
-
return;
|
|
1667
|
-
}
|
|
1668
|
-
// Admins get full user list; project owners get limited list (id, displayName, username)
|
|
1669
|
-
if (mu.role === "admin") {
|
|
1670
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1671
|
-
res.end(JSON.stringify({ users: users.getAllUsers() }));
|
|
1672
|
-
} else {
|
|
1673
|
-
var allU = users.getAllUsers();
|
|
1674
|
-
var safeUsers = allU.map(function (u) {
|
|
1675
|
-
return { id: u.id, displayName: u.displayName, username: u.username };
|
|
1676
|
-
});
|
|
1677
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1678
|
-
res.end(JSON.stringify({ users: safeUsers }));
|
|
1679
|
-
}
|
|
1680
|
-
return;
|
|
1681
|
-
}
|
|
1682
|
-
|
|
1683
|
-
// Remove user (admin only)
|
|
1684
|
-
if (req.method === "DELETE" && fullUrl.indexOf("/api/admin/users/") === 0) {
|
|
1685
|
-
if (!users.isMultiUser()) {
|
|
1686
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1687
|
-
res.end('{"error":"Not found"}');
|
|
1688
|
-
return;
|
|
1689
|
-
}
|
|
1690
|
-
var mu = getMultiUserFromReq(req);
|
|
1691
|
-
if (!mu || mu.role !== "admin") {
|
|
1692
|
-
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1693
|
-
res.end('{"error":"Admin access required"}');
|
|
1694
|
-
return;
|
|
1695
|
-
}
|
|
1696
|
-
var targetUserId = fullUrl.substring("/api/admin/users/".length);
|
|
1697
|
-
if (targetUserId === mu.id) {
|
|
1698
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1699
|
-
res.end('{"error":"Cannot remove yourself"}');
|
|
1700
|
-
return;
|
|
1701
|
-
}
|
|
1702
|
-
// Look up the user before deletion to get linuxUser for deactivation
|
|
1703
|
-
var targetUser = users.findUserById(targetUserId);
|
|
1704
|
-
var targetLinuxUser = targetUser ? targetUser.linuxUser : null;
|
|
1705
|
-
var result = users.removeUser(targetUserId);
|
|
1706
|
-
if (result.error) {
|
|
1707
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1708
|
-
res.end(JSON.stringify({ error: result.error }));
|
|
1709
|
-
return;
|
|
1710
|
-
}
|
|
1711
|
-
// Remove auth tokens for deleted user
|
|
1712
|
-
revokeUserTokens(targetUserId);
|
|
1713
|
-
// Deactivate the Linux account if applicable
|
|
1714
|
-
if (onUserDeleted && targetLinuxUser) {
|
|
1715
|
-
onUserDeleted(targetUserId, targetLinuxUser);
|
|
1716
|
-
}
|
|
1717
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1718
|
-
res.end('{"ok":true}');
|
|
1719
|
-
return;
|
|
1720
|
-
}
|
|
1721
|
-
|
|
1722
|
-
// Create user (admin only) — generates a temporary PIN that must be changed on first login
|
|
1723
|
-
if (req.method === "POST" && fullUrl === "/api/admin/users") {
|
|
1724
|
-
if (!users.isMultiUser()) {
|
|
1725
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1726
|
-
res.end('{"error":"Not found"}');
|
|
1727
|
-
return;
|
|
1728
|
-
}
|
|
1729
|
-
var mu = getMultiUserFromReq(req);
|
|
1730
|
-
if (!mu || mu.role !== "admin") {
|
|
1731
|
-
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1732
|
-
res.end('{"error":"Admin access required"}');
|
|
1733
|
-
return;
|
|
1734
|
-
}
|
|
1735
|
-
var body = "";
|
|
1736
|
-
req.on("data", function (chunk) { body += chunk; });
|
|
1737
|
-
req.on("end", function () {
|
|
1738
|
-
try {
|
|
1739
|
-
var data = JSON.parse(body);
|
|
1740
|
-
if (!data.username || typeof data.username !== "string" || data.username.trim().length < 1) {
|
|
1741
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1742
|
-
res.end('{"error":"Username is required"}');
|
|
1743
|
-
return;
|
|
1744
|
-
}
|
|
1745
|
-
var result = users.createUserByAdmin({
|
|
1746
|
-
username: data.username.trim(),
|
|
1747
|
-
displayName: data.displayName ? data.displayName.trim() : data.username.trim(),
|
|
1748
|
-
email: data.email ? data.email.trim() : null,
|
|
1749
|
-
role: data.role === "admin" ? "admin" : "user",
|
|
1750
|
-
});
|
|
1751
|
-
if (result.error) {
|
|
1752
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1753
|
-
res.end(JSON.stringify({ error: result.error }));
|
|
1754
|
-
return;
|
|
1755
|
-
}
|
|
1756
|
-
// Auto-provision Linux account if OS users mode is enabled
|
|
1757
|
-
if (osUsers && !result.user.linuxUser) {
|
|
1758
|
-
var provision = provisionLinuxUser(result.user.username);
|
|
1759
|
-
if (provision.ok) {
|
|
1760
|
-
users.updateLinuxUser(result.user.id, provision.linuxUser);
|
|
1761
|
-
if (onUserProvisioned) onUserProvisioned(result.user.id, provision.linuxUser);
|
|
1762
|
-
}
|
|
1763
|
-
}
|
|
1764
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1765
|
-
res.end(JSON.stringify({
|
|
1766
|
-
ok: true,
|
|
1767
|
-
user: {
|
|
1768
|
-
id: result.user.id,
|
|
1769
|
-
username: result.user.username,
|
|
1770
|
-
displayName: result.user.displayName,
|
|
1771
|
-
role: result.user.role,
|
|
1772
|
-
},
|
|
1773
|
-
tempPin: result.tempPin,
|
|
1774
|
-
}));
|
|
1775
|
-
} catch (e) {
|
|
1776
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1777
|
-
res.end('{"error":"Invalid request"}');
|
|
1778
|
-
}
|
|
1779
|
-
});
|
|
1780
|
-
return;
|
|
1781
|
-
}
|
|
1782
|
-
|
|
1783
|
-
// Reset user PIN (admin only) — generates a new temp PIN
|
|
1784
|
-
if (req.method === "POST" && fullUrl.match(/^\/api\/admin\/users\/[^/]+\/reset-pin$/)) {
|
|
1785
|
-
if (!users.isMultiUser()) {
|
|
1786
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1787
|
-
res.end('{"error":"Not found"}');
|
|
1788
|
-
return;
|
|
1789
|
-
}
|
|
1790
|
-
var mu = getMultiUserFromReq(req);
|
|
1791
|
-
if (!mu || mu.role !== "admin") {
|
|
1792
|
-
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1793
|
-
res.end('{"error":"Admin access required"}');
|
|
1794
|
-
return;
|
|
1795
|
-
}
|
|
1796
|
-
var urlParts = fullUrl.split("/");
|
|
1797
|
-
var targetUserId = urlParts[4]; // /api/admin/users/{userId}/reset-pin
|
|
1798
|
-
var targetUser = users.findUserById(targetUserId);
|
|
1799
|
-
if (!targetUser) {
|
|
1800
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1801
|
-
res.end('{"error":"User not found"}');
|
|
1802
|
-
return;
|
|
1803
|
-
}
|
|
1804
|
-
var newPin = users.generatePin();
|
|
1805
|
-
var pinResult = users.updateUserPin(targetUserId, newPin);
|
|
1806
|
-
if (pinResult.error) {
|
|
1807
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1808
|
-
res.end(JSON.stringify({ error: pinResult.error }));
|
|
1809
|
-
return;
|
|
1810
|
-
}
|
|
1811
|
-
// Mark as must change on next login
|
|
1812
|
-
var data = users.loadUsers();
|
|
1813
|
-
for (var i = 0; i < data.users.length; i++) {
|
|
1814
|
-
if (data.users[i].id === targetUserId) {
|
|
1815
|
-
data.users[i].mustChangePin = true;
|
|
1816
|
-
users.saveUsers(data);
|
|
1817
|
-
break;
|
|
1818
|
-
}
|
|
1819
|
-
}
|
|
1820
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1821
|
-
res.end(JSON.stringify({ ok: true, tempPin: newPin }));
|
|
1822
|
-
return;
|
|
1823
|
-
}
|
|
1824
|
-
|
|
1825
|
-
// Set Linux user mapping (admin only, OS-level multi-user)
|
|
1826
|
-
if (req.method === "PUT" && fullUrl.match(/^\/api\/admin\/users\/[^/]+\/linux-user$/)) {
|
|
1827
|
-
if (!users.isMultiUser()) {
|
|
1828
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1829
|
-
res.end('{"error":"Not found"}');
|
|
1830
|
-
return;
|
|
1831
|
-
}
|
|
1832
|
-
var mu = getMultiUserFromReq(req);
|
|
1833
|
-
if (!mu || mu.role !== "admin") {
|
|
1834
|
-
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1835
|
-
res.end('{"error":"Admin access required"}');
|
|
1836
|
-
return;
|
|
1837
|
-
}
|
|
1838
|
-
var urlParts = fullUrl.split("/");
|
|
1839
|
-
var targetUserId = urlParts[4]; // /api/admin/users/{userId}/linux-user
|
|
1840
|
-
var body = "";
|
|
1841
|
-
req.on("data", function(chunk) { body += chunk; });
|
|
1842
|
-
req.on("end", function() {
|
|
1843
|
-
try {
|
|
1844
|
-
var parsed = JSON.parse(body);
|
|
1845
|
-
var result = users.updateLinuxUser(targetUserId, parsed.linuxUser || null);
|
|
1846
|
-
if (result.error) {
|
|
1847
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1848
|
-
res.end(JSON.stringify({ error: result.error }));
|
|
1849
|
-
} else {
|
|
1850
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1851
|
-
res.end('{"ok":true}');
|
|
1852
|
-
}
|
|
1853
|
-
} catch (e) {
|
|
1854
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1855
|
-
res.end('{"error":"Invalid request body"}');
|
|
1856
|
-
}
|
|
1857
|
-
});
|
|
1858
|
-
return;
|
|
1859
|
-
}
|
|
1860
|
-
|
|
1861
|
-
// Update user permissions (admin only)
|
|
1862
|
-
if (req.method === "PUT" && fullUrl.match(/^\/api\/admin\/users\/[^/]+\/permissions$/)) {
|
|
1863
|
-
if (!users.isMultiUser()) {
|
|
1864
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1865
|
-
res.end('{"error":"Not found"}');
|
|
1866
|
-
return;
|
|
1867
|
-
}
|
|
1868
|
-
var mu = getMultiUserFromReq(req);
|
|
1869
|
-
if (!mu || mu.role !== "admin") {
|
|
1870
|
-
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1871
|
-
res.end('{"error":"Admin access required"}');
|
|
1872
|
-
return;
|
|
1873
|
-
}
|
|
1874
|
-
var urlParts = fullUrl.split("/");
|
|
1875
|
-
var targetUserId = urlParts[4]; // /api/admin/users/{userId}/permissions
|
|
1876
|
-
var body = "";
|
|
1877
|
-
req.on("data", function(chunk) { body += chunk; });
|
|
1878
|
-
req.on("end", function() {
|
|
1879
|
-
try {
|
|
1880
|
-
var parsed = JSON.parse(body);
|
|
1881
|
-
var result = users.updateUserPermissions(targetUserId, parsed.permissions || {});
|
|
1882
|
-
if (result.error) {
|
|
1883
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1884
|
-
res.end(JSON.stringify({ error: result.error }));
|
|
1885
|
-
} else {
|
|
1886
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1887
|
-
res.end(JSON.stringify({ ok: true, permissions: result.permissions }));
|
|
1888
|
-
}
|
|
1889
|
-
} catch (e) {
|
|
1890
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1891
|
-
res.end('{"error":"Invalid request body"}');
|
|
1892
|
-
}
|
|
1893
|
-
});
|
|
1894
|
-
return;
|
|
1895
|
-
}
|
|
1896
|
-
|
|
1897
|
-
// Create invite (admin only)
|
|
1898
|
-
if (req.method === "POST" && fullUrl === "/api/admin/invites") {
|
|
1899
|
-
if (!users.isMultiUser()) {
|
|
1900
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1901
|
-
res.end('{"error":"Not found"}');
|
|
1902
|
-
return;
|
|
1903
|
-
}
|
|
1904
|
-
var mu = getMultiUserFromReq(req);
|
|
1905
|
-
if (!mu || mu.role !== "admin") {
|
|
1906
|
-
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1907
|
-
res.end('{"error":"Admin access required"}');
|
|
1908
|
-
return;
|
|
1909
|
-
}
|
|
1910
|
-
var invite = users.createInvite(mu.id);
|
|
1911
|
-
var proto = tlsOptions ? "https" : "http";
|
|
1912
|
-
var host = req.headers.host || ("localhost:" + portNum);
|
|
1913
|
-
var inviteUrl = proto + "://" + host + "/invite/" + invite.code;
|
|
1914
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1915
|
-
res.end(JSON.stringify({ ok: true, invite: invite, url: inviteUrl }));
|
|
1916
|
-
return;
|
|
1917
|
-
}
|
|
1918
|
-
|
|
1919
|
-
// List invites (admin only)
|
|
1920
|
-
if (req.method === "GET" && fullUrl === "/api/admin/invites") {
|
|
1921
|
-
if (!users.isMultiUser()) {
|
|
1922
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1923
|
-
res.end('{"error":"Not found"}');
|
|
1924
|
-
return;
|
|
1925
|
-
}
|
|
1926
|
-
var mu = getMultiUserFromReq(req);
|
|
1927
|
-
if (!mu || mu.role !== "admin") {
|
|
1928
|
-
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1929
|
-
res.end('{"error":"Admin access required"}');
|
|
1930
|
-
return;
|
|
1931
|
-
}
|
|
1932
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1933
|
-
res.end(JSON.stringify({ invites: users.getInvites() }));
|
|
1934
|
-
return;
|
|
1935
|
-
}
|
|
1936
|
-
|
|
1937
|
-
// Revoke invite (admin only)
|
|
1938
|
-
if (req.method === "DELETE" && fullUrl.indexOf("/api/admin/invites/") === 0) {
|
|
1939
|
-
if (!users.isMultiUser()) {
|
|
1940
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1941
|
-
res.end('{"error":"Not found"}');
|
|
1942
|
-
return;
|
|
1943
|
-
}
|
|
1944
|
-
var mu = getMultiUserFromReq(req);
|
|
1945
|
-
if (!mu || mu.role !== "admin") {
|
|
1946
|
-
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1947
|
-
res.end('{"error":"Admin access required"}');
|
|
1948
|
-
return;
|
|
1949
|
-
}
|
|
1950
|
-
var inviteCode = decodeURIComponent(fullUrl.replace("/api/admin/invites/", ""));
|
|
1951
|
-
if (!inviteCode) {
|
|
1952
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1953
|
-
res.end('{"error":"Invite code is required"}');
|
|
1954
|
-
return;
|
|
1955
|
-
}
|
|
1956
|
-
var result = users.revokeInvite(inviteCode);
|
|
1957
|
-
if (result.error) {
|
|
1958
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1959
|
-
res.end(JSON.stringify({ error: result.error }));
|
|
1960
|
-
return;
|
|
1961
|
-
}
|
|
1962
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1963
|
-
res.end('{"ok":true}');
|
|
1964
|
-
return;
|
|
1965
|
-
}
|
|
1966
|
-
|
|
1967
|
-
// Send invite via email (admin only)
|
|
1968
|
-
if (req.method === "POST" && fullUrl === "/api/admin/invites/email") {
|
|
1969
|
-
if (!users.isMultiUser() || !smtp.isSmtpConfigured()) {
|
|
1970
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1971
|
-
res.end('{"error":"SMTP not configured"}');
|
|
1972
|
-
return;
|
|
1973
|
-
}
|
|
1974
|
-
var mu = getMultiUserFromReq(req);
|
|
1975
|
-
if (!mu || mu.role !== "admin") {
|
|
1976
|
-
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1977
|
-
res.end('{"error":"Admin access required"}');
|
|
1978
|
-
return;
|
|
1979
|
-
}
|
|
1980
|
-
var body = "";
|
|
1981
|
-
req.on("data", function (chunk) { body += chunk; });
|
|
1982
|
-
req.on("end", function () {
|
|
1983
|
-
try {
|
|
1984
|
-
var data = JSON.parse(body);
|
|
1985
|
-
if (!data.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
|
1986
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1987
|
-
res.end('{"error":"Valid email is required"}');
|
|
1988
|
-
return;
|
|
1989
|
-
}
|
|
1990
|
-
var invite = users.createInvite(mu.id, data.email);
|
|
1991
|
-
var proto = tlsOptions ? "https" : "http";
|
|
1992
|
-
var host = req.headers.host || ("localhost:" + portNum);
|
|
1993
|
-
var inviteUrl = proto + "://" + host + "/invite/" + invite.code;
|
|
1994
|
-
smtp.sendInviteEmail(data.email, inviteUrl, mu.displayName || mu.username).then(function () {
|
|
1995
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1996
|
-
res.end(JSON.stringify({ ok: true, invite: invite, url: inviteUrl }));
|
|
1997
|
-
}).catch(function (err) {
|
|
1998
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1999
|
-
res.end(JSON.stringify({ error: "Failed to send email: " + (err.message || "unknown error") }));
|
|
2000
|
-
});
|
|
2001
|
-
} catch (e) {
|
|
2002
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2003
|
-
res.end('{"error":"Invalid request"}');
|
|
2004
|
-
}
|
|
2005
|
-
});
|
|
2006
|
-
return;
|
|
2007
|
-
}
|
|
2008
|
-
|
|
2009
|
-
// Get SMTP config (admin only)
|
|
2010
|
-
if (req.method === "GET" && fullUrl === "/api/admin/smtp") {
|
|
2011
|
-
if (!users.isMultiUser()) {
|
|
2012
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2013
|
-
res.end('{"error":"Not found"}');
|
|
2014
|
-
return;
|
|
2015
|
-
}
|
|
2016
|
-
var mu = getMultiUserFromReq(req);
|
|
2017
|
-
if (!mu || mu.role !== "admin") {
|
|
2018
|
-
res.writeHead(403, { "Content-Type": "application/json" });
|
|
2019
|
-
res.end('{"error":"Admin access required"}');
|
|
2020
|
-
return;
|
|
2021
|
-
}
|
|
2022
|
-
var cfg = smtp.getSmtpConfig();
|
|
2023
|
-
if (cfg) {
|
|
2024
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2025
|
-
res.end(JSON.stringify({ smtp: { host: cfg.host, port: cfg.port, secure: cfg.secure, user: cfg.user, pass: "••••••••", from: cfg.from, emailLoginEnabled: !!cfg.emailLoginEnabled } }));
|
|
2026
|
-
} else {
|
|
2027
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2028
|
-
res.end('{"smtp":null}');
|
|
2029
|
-
}
|
|
2030
|
-
return;
|
|
2031
|
-
}
|
|
2032
|
-
|
|
2033
|
-
// Save SMTP config (admin only)
|
|
2034
|
-
if (req.method === "POST" && fullUrl === "/api/admin/smtp") {
|
|
2035
|
-
if (!users.isMultiUser()) {
|
|
2036
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2037
|
-
res.end('{"error":"Not found"}');
|
|
2038
|
-
return;
|
|
2039
|
-
}
|
|
2040
|
-
var mu = getMultiUserFromReq(req);
|
|
2041
|
-
if (!mu || mu.role !== "admin") {
|
|
2042
|
-
res.writeHead(403, { "Content-Type": "application/json" });
|
|
2043
|
-
res.end('{"error":"Admin access required"}');
|
|
2044
|
-
return;
|
|
2045
|
-
}
|
|
2046
|
-
var body = "";
|
|
2047
|
-
req.on("data", function (chunk) { body += chunk; });
|
|
2048
|
-
req.on("end", function () {
|
|
2049
|
-
try {
|
|
2050
|
-
var data = JSON.parse(body);
|
|
2051
|
-
// Allow clearing SMTP config by sending empty fields
|
|
2052
|
-
if (!data.host && !data.user && !data.pass && !data.from) {
|
|
2053
|
-
smtp.saveSmtpConfig(null);
|
|
2054
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2055
|
-
res.end('{"ok":true}');
|
|
2056
|
-
return;
|
|
2057
|
-
}
|
|
2058
|
-
if (!data.host || !data.user || !data.pass || !data.from) {
|
|
2059
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2060
|
-
res.end('{"error":"Host, user, password, and from address are required"}');
|
|
2061
|
-
return;
|
|
2062
|
-
}
|
|
2063
|
-
// If password is masked, keep existing
|
|
2064
|
-
var existingCfg = smtp.getSmtpConfig();
|
|
2065
|
-
var pass = data.pass;
|
|
2066
|
-
if (pass === "••••••••" && existingCfg) {
|
|
2067
|
-
pass = existingCfg.pass;
|
|
2068
|
-
}
|
|
2069
|
-
smtp.saveSmtpConfig({
|
|
2070
|
-
host: data.host,
|
|
2071
|
-
port: parseInt(data.port, 10) || 587,
|
|
2072
|
-
secure: !!data.secure,
|
|
2073
|
-
user: data.user,
|
|
2074
|
-
pass: pass,
|
|
2075
|
-
from: data.from,
|
|
2076
|
-
emailLoginEnabled: !!data.emailLoginEnabled,
|
|
2077
|
-
});
|
|
2078
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2079
|
-
res.end('{"ok":true}');
|
|
2080
|
-
} catch (e) {
|
|
2081
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2082
|
-
res.end('{"error":"Invalid request"}');
|
|
2083
|
-
}
|
|
2084
|
-
});
|
|
2085
|
-
return;
|
|
2086
|
-
}
|
|
2087
|
-
|
|
2088
|
-
// Test SMTP connection (admin only)
|
|
2089
|
-
if (req.method === "POST" && fullUrl === "/api/admin/smtp/test") {
|
|
2090
|
-
if (!users.isMultiUser()) {
|
|
2091
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2092
|
-
res.end('{"error":"Not found"}');
|
|
2093
|
-
return;
|
|
2094
|
-
}
|
|
2095
|
-
var mu = getMultiUserFromReq(req);
|
|
2096
|
-
if (!mu || mu.role !== "admin") {
|
|
2097
|
-
res.writeHead(403, { "Content-Type": "application/json" });
|
|
2098
|
-
res.end('{"error":"Admin access required"}');
|
|
2099
|
-
return;
|
|
2100
|
-
}
|
|
2101
|
-
var body = "";
|
|
2102
|
-
req.on("data", function (chunk) { body += chunk; });
|
|
2103
|
-
req.on("end", function () {
|
|
2104
|
-
try {
|
|
2105
|
-
var data = JSON.parse(body);
|
|
2106
|
-
// Use provided config or fall back to saved
|
|
2107
|
-
var existingCfg = smtp.getSmtpConfig();
|
|
2108
|
-
var pass = data.pass;
|
|
2109
|
-
if (pass === "••••••••" && existingCfg) {
|
|
2110
|
-
pass = existingCfg.pass;
|
|
2111
|
-
}
|
|
2112
|
-
var cfg = {
|
|
2113
|
-
host: data.host || (existingCfg && existingCfg.host),
|
|
2114
|
-
port: parseInt(data.port, 10) || (existingCfg && existingCfg.port) || 587,
|
|
2115
|
-
secure: data.secure !== undefined ? !!data.secure : (existingCfg && !!existingCfg.secure),
|
|
2116
|
-
user: data.user || (existingCfg && existingCfg.user),
|
|
2117
|
-
pass: pass || (existingCfg && existingCfg.pass),
|
|
2118
|
-
from: data.from || (existingCfg && existingCfg.from),
|
|
2119
|
-
};
|
|
2120
|
-
if (!cfg.host || !cfg.user || !cfg.pass || !cfg.from) {
|
|
2121
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2122
|
-
res.end('{"error":"SMTP configuration is incomplete"}');
|
|
2123
|
-
return;
|
|
2124
|
-
}
|
|
2125
|
-
var testTo = mu.email || cfg.from;
|
|
2126
|
-
smtp.sendTestEmail(cfg, testTo).then(function (result) {
|
|
2127
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2128
|
-
res.end(JSON.stringify({ ok: true, message: "Test email sent to " + testTo }));
|
|
2129
|
-
}).catch(function (err) {
|
|
2130
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
2131
|
-
res.end(JSON.stringify({ ok: false, error: err.message || "Connection failed" }));
|
|
2132
|
-
});
|
|
2133
|
-
} catch (e) {
|
|
2134
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2135
|
-
res.end('{"error":"Invalid request"}');
|
|
2136
|
-
}
|
|
2137
|
-
});
|
|
2138
|
-
return;
|
|
2139
|
-
}
|
|
2140
|
-
|
|
2141
|
-
// --- Project access control (admin only, multi-user) ---
|
|
2142
|
-
|
|
2143
|
-
// Set project visibility (admin only)
|
|
2144
|
-
if (req.method === "PUT" && /^\/api\/admin\/projects\/[a-z0-9_-]+\/visibility$/.test(fullUrl)) {
|
|
2145
|
-
if (!users.isMultiUser()) {
|
|
2146
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2147
|
-
res.end('{"error":"Not found"}');
|
|
2148
|
-
return;
|
|
2149
|
-
}
|
|
2150
|
-
var mu = getMultiUserFromReq(req);
|
|
2151
|
-
var _visSlug = fullUrl.split("/")[4];
|
|
2152
|
-
var _visAccess = onGetProjectAccess ? onGetProjectAccess(_visSlug) : null;
|
|
2153
|
-
var _isOwner = mu && _visAccess && _visAccess.ownerId && mu.id === _visAccess.ownerId;
|
|
2154
|
-
if (!mu || (mu.role !== "admin" && !_isOwner)) {
|
|
2155
|
-
res.writeHead(403, { "Content-Type": "application/json" });
|
|
2156
|
-
res.end('{"error":"Admin or project owner access required"}');
|
|
2157
|
-
return;
|
|
2158
|
-
}
|
|
2159
|
-
var projSlug = fullUrl.split("/")[4];
|
|
2160
|
-
if (projSlug.indexOf("--") !== -1) {
|
|
2161
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2162
|
-
res.end('{"error":"Worktree projects inherit parent visibility"}');
|
|
2163
|
-
return;
|
|
2164
|
-
}
|
|
2165
|
-
var body = "";
|
|
2166
|
-
req.on("data", function (chunk) { body += chunk; });
|
|
2167
|
-
req.on("end", function () {
|
|
2168
|
-
try {
|
|
2169
|
-
var data = JSON.parse(body);
|
|
2170
|
-
if (data.visibility !== "public" && data.visibility !== "private") {
|
|
2171
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2172
|
-
res.end('{"error":"Visibility must be public or private"}');
|
|
2173
|
-
return;
|
|
2174
|
-
}
|
|
2175
|
-
if (!onSetProjectVisibility) {
|
|
2176
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
2177
|
-
res.end('{"error":"Visibility handler not configured"}');
|
|
2178
|
-
return;
|
|
2179
|
-
}
|
|
2180
|
-
var result = onSetProjectVisibility(projSlug, data.visibility);
|
|
2181
|
-
if (result && result.error) {
|
|
2182
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2183
|
-
res.end(JSON.stringify({ error: result.error }));
|
|
2184
|
-
return;
|
|
2185
|
-
}
|
|
2186
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2187
|
-
res.end('{"ok":true}');
|
|
2188
|
-
} catch (e) {
|
|
2189
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2190
|
-
res.end('{"error":"Invalid request"}');
|
|
2191
|
-
}
|
|
2192
|
-
});
|
|
2193
|
-
return;
|
|
2194
|
-
}
|
|
2195
|
-
|
|
2196
|
-
// Set project owner (admin only)
|
|
2197
|
-
if (req.method === "PUT" && /^\/api\/admin\/projects\/[a-z0-9_-]+\/owner$/.test(fullUrl)) {
|
|
2198
|
-
if (!users.isMultiUser()) {
|
|
2199
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2200
|
-
res.end('{"error":"Not found"}');
|
|
2201
|
-
return;
|
|
2202
|
-
}
|
|
2203
|
-
var mu = getMultiUserFromReq(req);
|
|
2204
|
-
if (!mu || mu.role !== "admin") {
|
|
2205
|
-
res.writeHead(403, { "Content-Type": "application/json" });
|
|
2206
|
-
res.end('{"error":"Admin access required"}');
|
|
2207
|
-
return;
|
|
2208
|
-
}
|
|
2209
|
-
var projSlug = fullUrl.split("/")[4];
|
|
2210
|
-
if (projSlug.indexOf("--") !== -1) {
|
|
2211
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2212
|
-
res.end('{"error":"Worktree projects inherit parent settings"}');
|
|
2213
|
-
return;
|
|
2214
|
-
}
|
|
2215
|
-
var body = "";
|
|
2216
|
-
req.on("data", function (chunk) { body += chunk; });
|
|
2217
|
-
req.on("end", function () {
|
|
2218
|
-
try {
|
|
2219
|
-
var data = JSON.parse(body);
|
|
2220
|
-
var targetCtx = projects.get(projSlug);
|
|
2221
|
-
if (!targetCtx) {
|
|
2222
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2223
|
-
res.end('{"error":"Project not found"}');
|
|
2224
|
-
return;
|
|
2225
|
-
}
|
|
2226
|
-
var ownerId = data.userId || null;
|
|
2227
|
-
targetCtx.setProjectOwner(ownerId);
|
|
2228
|
-
if (onProjectOwnerChanged) {
|
|
2229
|
-
onProjectOwnerChanged(projSlug, ownerId);
|
|
2230
|
-
}
|
|
2231
|
-
// Broadcast to project clients
|
|
2232
|
-
var ownerName = null;
|
|
2233
|
-
if (ownerId) {
|
|
2234
|
-
var ownerUser = users.findUserById(ownerId);
|
|
2235
|
-
ownerName = ownerUser ? (ownerUser.displayName || ownerUser.username) : ownerId;
|
|
2236
|
-
}
|
|
2237
|
-
targetCtx.send({ type: "project_owner_changed", ownerId: ownerId, ownerName: ownerName });
|
|
2238
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2239
|
-
res.end('{"ok":true}');
|
|
2240
|
-
} catch (e) {
|
|
2241
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2242
|
-
res.end('{"error":"Invalid request"}');
|
|
2243
|
-
}
|
|
2244
|
-
});
|
|
2245
|
-
return;
|
|
2246
|
-
}
|
|
2247
|
-
|
|
2248
|
-
// Set project allowed users (admin only)
|
|
2249
|
-
if (req.method === "PUT" && /^\/api\/admin\/projects\/[a-z0-9_-]+\/users$/.test(fullUrl)) {
|
|
2250
|
-
if (!users.isMultiUser()) {
|
|
2251
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2252
|
-
res.end('{"error":"Not found"}');
|
|
2253
|
-
return;
|
|
2254
|
-
}
|
|
2255
|
-
var mu = getMultiUserFromReq(req);
|
|
2256
|
-
var _usrSlug = fullUrl.split("/")[4];
|
|
2257
|
-
var _usrAccess = onGetProjectAccess ? onGetProjectAccess(_usrSlug) : null;
|
|
2258
|
-
var _isOwnerU = mu && _usrAccess && _usrAccess.ownerId && mu.id === _usrAccess.ownerId;
|
|
2259
|
-
if (!mu || (mu.role !== "admin" && !_isOwnerU)) {
|
|
2260
|
-
res.writeHead(403, { "Content-Type": "application/json" });
|
|
2261
|
-
res.end('{"error":"Admin or project owner access required"}');
|
|
2262
|
-
return;
|
|
2263
|
-
}
|
|
2264
|
-
var projSlug = fullUrl.split("/")[4];
|
|
2265
|
-
if (projSlug.indexOf("--") !== -1) {
|
|
2266
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2267
|
-
res.end('{"error":"Worktree projects inherit parent settings"}');
|
|
2268
|
-
return;
|
|
2269
|
-
}
|
|
2270
|
-
var body = "";
|
|
2271
|
-
req.on("data", function (chunk) { body += chunk; });
|
|
2272
|
-
req.on("end", function () {
|
|
2273
|
-
try {
|
|
2274
|
-
var data = JSON.parse(body);
|
|
2275
|
-
if (!Array.isArray(data.allowedUsers)) {
|
|
2276
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2277
|
-
res.end('{"error":"allowedUsers must be an array"}');
|
|
2278
|
-
return;
|
|
2279
|
-
}
|
|
2280
|
-
if (!onSetProjectAllowedUsers) {
|
|
2281
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
2282
|
-
res.end('{"error":"AllowedUsers handler not configured"}');
|
|
2283
|
-
return;
|
|
2284
|
-
}
|
|
2285
|
-
var result = onSetProjectAllowedUsers(projSlug, data.allowedUsers);
|
|
2286
|
-
if (result && result.error) {
|
|
2287
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2288
|
-
res.end(JSON.stringify({ error: result.error }));
|
|
2289
|
-
return;
|
|
2290
|
-
}
|
|
2291
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2292
|
-
res.end('{"ok":true}');
|
|
2293
|
-
} catch (e) {
|
|
2294
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2295
|
-
res.end('{"error":"Invalid request"}');
|
|
2296
|
-
}
|
|
2297
|
-
});
|
|
2298
|
-
return;
|
|
2299
|
-
}
|
|
2300
|
-
|
|
2301
|
-
// Get project access info (admin or project owner)
|
|
2302
|
-
if (req.method === "GET" && /^\/api\/admin\/projects\/[a-z0-9_-]+\/access$/.test(fullUrl)) {
|
|
2303
|
-
if (!users.isMultiUser()) {
|
|
2304
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2305
|
-
res.end('{"error":"Not found"}');
|
|
2306
|
-
return;
|
|
2307
|
-
}
|
|
2308
|
-
var mu = getMultiUserFromReq(req);
|
|
2309
|
-
var _accSlug = fullUrl.split("/")[4];
|
|
2310
|
-
var _accAccess = onGetProjectAccess ? onGetProjectAccess(_accSlug) : null;
|
|
2311
|
-
var _isOwnerA = mu && _accAccess && _accAccess.ownerId && mu.id === _accAccess.ownerId;
|
|
2312
|
-
if (!mu || (mu.role !== "admin" && !_isOwnerA)) {
|
|
2313
|
-
res.writeHead(403, { "Content-Type": "application/json" });
|
|
2314
|
-
res.end('{"error":"Admin or project owner access required"}');
|
|
2315
|
-
return;
|
|
2316
|
-
}
|
|
2317
|
-
var projSlug = fullUrl.split("/")[4];
|
|
2318
|
-
if (!onGetProjectAccess) {
|
|
2319
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
2320
|
-
res.end('{"error":"Access handler not configured"}');
|
|
2321
|
-
return;
|
|
2322
|
-
}
|
|
2323
|
-
var access = onGetProjectAccess(projSlug);
|
|
2324
|
-
if (access && access.error) {
|
|
2325
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2326
|
-
res.end(JSON.stringify({ error: access.error }));
|
|
2327
|
-
return;
|
|
2328
|
-
}
|
|
2329
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2330
|
-
res.end(JSON.stringify(access));
|
|
356
|
+
} catch (e) {
|
|
357
|
+
res.writeHead(400);
|
|
358
|
+
res.end("Bad request");
|
|
359
|
+
}
|
|
360
|
+
});
|
|
2331
361
|
return;
|
|
2332
362
|
}
|
|
2333
363
|
|
|
2334
|
-
//
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
if (!pQuery) {
|
|
2350
|
-
// Recent mode: return all sessions sorted by lastActivity
|
|
2351
|
-
projects.forEach(function (pCtx, pSlug) {
|
|
2352
|
-
var status = pCtx.getStatus();
|
|
2353
|
-
if (status.isWorktree) return;
|
|
2354
|
-
if (paletteUser && onGetProjectAccess) {
|
|
2355
|
-
var pAccess = onGetProjectAccess(pSlug);
|
|
2356
|
-
if (pAccess && !pAccess.error && !users.canAccessProject(paletteUser.id, pAccess)) return;
|
|
2357
|
-
}
|
|
2358
|
-
pCtx.sm.sessions.forEach(function (session) {
|
|
2359
|
-
if (session.hidden) return;
|
|
2360
|
-
if (paletteUser) {
|
|
2361
|
-
if (users.isMultiUser()) {
|
|
2362
|
-
var sAccess = onGetProjectAccess ? onGetProjectAccess(pSlug) : null;
|
|
2363
|
-
if (!users.canAccessSession(paletteUser.id, session, sAccess)) return;
|
|
2364
|
-
}
|
|
2365
|
-
} else {
|
|
2366
|
-
if (session.ownerId) return;
|
|
2367
|
-
}
|
|
2368
|
-
var pItem = {
|
|
2369
|
-
projectSlug: pSlug,
|
|
2370
|
-
projectTitle: status.title || status.project,
|
|
2371
|
-
projectIcon: status.icon || null,
|
|
2372
|
-
sessionId: session.localId,
|
|
2373
|
-
sessionTitle: session.title || "New Session",
|
|
2374
|
-
lastActivity: session.lastActivity || session.createdAt || 0,
|
|
2375
|
-
matchType: null,
|
|
2376
|
-
snippet: null
|
|
2377
|
-
};
|
|
2378
|
-
if (status.isMate) {
|
|
2379
|
-
pItem.isMate = true;
|
|
2380
|
-
pItem.mateId = status.mateId || null;
|
|
2381
|
-
}
|
|
2382
|
-
pResults.push(pItem);
|
|
2383
|
-
});
|
|
2384
|
-
});
|
|
2385
|
-
pResults.sort(function (a, b) { return b.lastActivity - a.lastActivity; });
|
|
2386
|
-
if (pResults.length > 30) pResults = pResults.slice(0, 30);
|
|
2387
|
-
} else {
|
|
2388
|
-
// Search mode: BM25 ranked search across all sessions
|
|
2389
|
-
var projectSessions = [];
|
|
2390
|
-
projects.forEach(function (pCtx, pSlug) {
|
|
2391
|
-
var status = pCtx.getStatus();
|
|
2392
|
-
if (status.isWorktree) return;
|
|
2393
|
-
if (paletteUser && onGetProjectAccess) {
|
|
2394
|
-
var pAccess = onGetProjectAccess(pSlug);
|
|
2395
|
-
if (pAccess && !pAccess.error && !users.canAccessProject(paletteUser.id, pAccess)) return;
|
|
2396
|
-
}
|
|
2397
|
-
var accessibleSessions = [];
|
|
2398
|
-
pCtx.sm.sessions.forEach(function (session) {
|
|
2399
|
-
if (session.hidden) return;
|
|
2400
|
-
if (paletteUser) {
|
|
2401
|
-
if (users.isMultiUser()) {
|
|
2402
|
-
var sAccess = onGetProjectAccess ? onGetProjectAccess(pSlug) : null;
|
|
2403
|
-
if (!users.canAccessSession(paletteUser.id, session, sAccess)) return;
|
|
2404
|
-
}
|
|
2405
|
-
} else {
|
|
2406
|
-
if (session.ownerId) return;
|
|
2407
|
-
}
|
|
2408
|
-
accessibleSessions.push(session);
|
|
2409
|
-
});
|
|
2410
|
-
if (accessibleSessions.length > 0) {
|
|
2411
|
-
projectSessions.push({
|
|
2412
|
-
projectSlug: pSlug,
|
|
2413
|
-
projectTitle: status.title || status.project,
|
|
2414
|
-
projectIcon: status.icon || null,
|
|
2415
|
-
isMate: status.isMate || false,
|
|
2416
|
-
mateId: status.mateId || null,
|
|
2417
|
-
sessions: accessibleSessions
|
|
2418
|
-
});
|
|
364
|
+
// Health check endpoint
|
|
365
|
+
// Unauthenticated: minimal liveness info only
|
|
366
|
+
// Authenticated: full system details (memory, pid, version, sessions)
|
|
367
|
+
if (req.method === "GET" && fullUrl === "/api/health") {
|
|
368
|
+
var health = {
|
|
369
|
+
status: "ok",
|
|
370
|
+
timestamp: new Date().toISOString(),
|
|
371
|
+
};
|
|
372
|
+
if (isRequestAuthed(req)) {
|
|
373
|
+
var mem = process.memoryUsage();
|
|
374
|
+
var activeSessions = 0;
|
|
375
|
+
projects.forEach(function (ctx) {
|
|
376
|
+
if (ctx && ctx.clients) {
|
|
377
|
+
activeSessions += ctx.clients.size || 0;
|
|
2419
378
|
}
|
|
2420
379
|
});
|
|
2421
|
-
|
|
380
|
+
health.uptime = process.uptime();
|
|
381
|
+
health.version = pkg.version;
|
|
382
|
+
health.node = process.version;
|
|
383
|
+
health.sessions = activeSessions;
|
|
384
|
+
health.projects = projects.size;
|
|
385
|
+
health.memory = {
|
|
386
|
+
rss: mem.rss,
|
|
387
|
+
heapUsed: mem.heapUsed,
|
|
388
|
+
heapTotal: mem.heapTotal,
|
|
389
|
+
};
|
|
390
|
+
health.pid = process.pid;
|
|
2422
391
|
}
|
|
392
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
393
|
+
res.end(JSON.stringify(health));
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
2423
396
|
|
|
397
|
+
// Theme list: bundled (lib/themes/) + user (~/.clay/themes/)
|
|
398
|
+
if (req.method === "GET" && fullUrl === "/api/themes") {
|
|
399
|
+
var bundled = {};
|
|
400
|
+
var custom = {};
|
|
401
|
+
// Read bundled themes
|
|
402
|
+
try {
|
|
403
|
+
var bFiles = fs.readdirSync(bundledThemesDir);
|
|
404
|
+
for (var i = 0; i < bFiles.length; i++) {
|
|
405
|
+
if (!bFiles[i].endsWith(".json")) continue;
|
|
406
|
+
try {
|
|
407
|
+
var raw = fs.readFileSync(path.join(bundledThemesDir, bFiles[i]), "utf8");
|
|
408
|
+
var id = bFiles[i].replace(/\.json$/, "");
|
|
409
|
+
bundled[id] = JSON.parse(raw);
|
|
410
|
+
} catch (e) {}
|
|
411
|
+
}
|
|
412
|
+
} catch (e) {}
|
|
413
|
+
// Read user themes (override bundled if same id)
|
|
414
|
+
try {
|
|
415
|
+
var uFiles = fs.readdirSync(userThemesDir);
|
|
416
|
+
for (var j = 0; j < uFiles.length; j++) {
|
|
417
|
+
if (!uFiles[j].endsWith(".json")) continue;
|
|
418
|
+
try {
|
|
419
|
+
var uRaw = fs.readFileSync(path.join(userThemesDir, uFiles[j]), "utf8");
|
|
420
|
+
var uid = uFiles[j].replace(/\.json$/, "");
|
|
421
|
+
custom[uid] = JSON.parse(uRaw);
|
|
422
|
+
} catch (e) {}
|
|
423
|
+
}
|
|
424
|
+
} catch (e) {}
|
|
2424
425
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2425
|
-
res.end(JSON.stringify({
|
|
426
|
+
res.end(JSON.stringify({ bundled: bundled, custom: custom }));
|
|
2426
427
|
return;
|
|
2427
428
|
}
|
|
2428
429
|
|
|
430
|
+
if (settings.handleRequest(req, res, fullUrl)) return;
|
|
431
|
+
|
|
432
|
+
// --- Admin API endpoints (multi-user mode only) ---
|
|
433
|
+
if (admin.handleRequest(req, res, fullUrl)) return;
|
|
434
|
+
|
|
435
|
+
// --- Palette search (delegated to server-palette) ---
|
|
436
|
+
if (palette.handleRequest(req, res, fullUrl)) return;
|
|
437
|
+
|
|
2429
438
|
// Multi-user info endpoint (who am I?)
|
|
2430
439
|
if (req.method === "GET" && fullUrl === "/api/me") {
|
|
2431
440
|
if (!users.isMultiUser()) {
|
|
@@ -2447,121 +456,14 @@ function createServer(opts) {
|
|
|
2447
456
|
return;
|
|
2448
457
|
}
|
|
2449
458
|
|
|
2450
|
-
// Skills
|
|
2451
|
-
if (
|
|
2452
|
-
if (users.isMultiUser()) {
|
|
2453
|
-
var skMu = getMultiUserFromReq(req);
|
|
2454
|
-
if (skMu) {
|
|
2455
|
-
var skPerms = users.getEffectivePermissions(skMu, osUsers);
|
|
2456
|
-
if (!skPerms.skills) {
|
|
2457
|
-
res.writeHead(403, { "Content-Type": "application/json" });
|
|
2458
|
-
res.end('{"error":"Skills access is not permitted"}');
|
|
2459
|
-
return;
|
|
2460
|
-
}
|
|
2461
|
-
}
|
|
2462
|
-
}
|
|
2463
|
-
}
|
|
2464
|
-
|
|
2465
|
-
// Skills proxy: leaderboard list
|
|
2466
|
-
if (req.method === "GET" && fullUrl === "/api/skills") {
|
|
2467
|
-
var qs = req.url.indexOf("?") >= 0 ? req.url.substring(req.url.indexOf("?")) : "";
|
|
2468
|
-
var tabParam = new URLSearchParams(qs).get("tab") || "all";
|
|
2469
|
-
var tabPath = tabParam === "trending" ? "/trending" : tabParam === "hot" ? "/hot" : "/";
|
|
2470
|
-
var cacheKey = "skills_" + tabParam;
|
|
2471
|
-
var cached = skillsCache[cacheKey];
|
|
2472
|
-
if (cached && Date.now() - cached.ts < 300000) {
|
|
2473
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2474
|
-
res.end(cached.data);
|
|
2475
|
-
return;
|
|
2476
|
-
}
|
|
2477
|
-
fetchSkillsPage("https://skills.sh" + tabPath).then(function (data) {
|
|
2478
|
-
var json = JSON.stringify(data);
|
|
2479
|
-
skillsCache[cacheKey] = { ts: Date.now(), data: json };
|
|
2480
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2481
|
-
res.end(json);
|
|
2482
|
-
}).catch(function (err) {
|
|
2483
|
-
res.writeHead(502, { "Content-Type": "application/json" });
|
|
2484
|
-
res.end(JSON.stringify({ error: "Failed to fetch skills: " + (err.message || err) }));
|
|
2485
|
-
});
|
|
2486
|
-
return;
|
|
2487
|
-
}
|
|
2488
|
-
|
|
2489
|
-
// Skills proxy: search
|
|
2490
|
-
if (req.method === "GET" && fullUrl.startsWith("/api/skills/search")) {
|
|
2491
|
-
var sqsRaw = req.url.indexOf("?") >= 0 ? req.url.substring(req.url.indexOf("?")) : "";
|
|
2492
|
-
var searchQ = new URLSearchParams(sqsRaw).get("q") || "";
|
|
2493
|
-
if (!searchQ) {
|
|
2494
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2495
|
-
res.end('{"error":"missing q param"}');
|
|
2496
|
-
return;
|
|
2497
|
-
}
|
|
2498
|
-
var searchCacheKey = "search_" + searchQ.toLowerCase();
|
|
2499
|
-
var searchCached = skillsCache[searchCacheKey];
|
|
2500
|
-
if (searchCached && Date.now() - searchCached.ts < 300000) {
|
|
2501
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2502
|
-
res.end(searchCached.data);
|
|
2503
|
-
return;
|
|
2504
|
-
}
|
|
2505
|
-
// Reuse cached "all" tab data if available, otherwise fetch
|
|
2506
|
-
var allCached = skillsCache["skills_all"];
|
|
2507
|
-
var allPromise = (allCached && Date.now() - allCached.ts < 300000)
|
|
2508
|
-
? Promise.resolve(JSON.parse(allCached.data))
|
|
2509
|
-
: fetchSkillsPage("https://skills.sh/");
|
|
2510
|
-
allPromise.then(function (data) {
|
|
2511
|
-
var q = searchQ.toLowerCase();
|
|
2512
|
-
var filtered = (data.skills || []).filter(function (s) {
|
|
2513
|
-
var name = (s.name || "").toLowerCase();
|
|
2514
|
-
var source = (s.source || "").toLowerCase();
|
|
2515
|
-
var skillId = (s.skillId || "").toLowerCase();
|
|
2516
|
-
return name.indexOf(q) >= 0 || source.indexOf(q) >= 0 || skillId.indexOf(q) >= 0;
|
|
2517
|
-
});
|
|
2518
|
-
var json = JSON.stringify({ skills: filtered });
|
|
2519
|
-
skillsCache[searchCacheKey] = { ts: Date.now(), data: json };
|
|
2520
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2521
|
-
res.end(json);
|
|
2522
|
-
}).catch(function (err) {
|
|
2523
|
-
res.writeHead(502, { "Content-Type": "application/json" });
|
|
2524
|
-
res.end(JSON.stringify({ error: "Failed to search skills: " + (err.message || err) }));
|
|
2525
|
-
});
|
|
2526
|
-
return;
|
|
2527
|
-
}
|
|
2528
|
-
|
|
2529
|
-
// Skills proxy: skill detail
|
|
2530
|
-
if (req.method === "GET" && fullUrl.startsWith("/api/skills/detail")) {
|
|
2531
|
-
var qs2 = req.url.indexOf("?") >= 0 ? req.url.substring(req.url.indexOf("?")) : "";
|
|
2532
|
-
var params2 = new URLSearchParams(qs2);
|
|
2533
|
-
var detailSource = params2.get("source");
|
|
2534
|
-
var detailSkill = params2.get("skill");
|
|
2535
|
-
if (!detailSource || !detailSkill) {
|
|
2536
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2537
|
-
res.end('{"error":"missing source or skill param"}');
|
|
2538
|
-
return;
|
|
2539
|
-
}
|
|
2540
|
-
var detailCacheKey = "detail_" + detailSource + "_" + detailSkill;
|
|
2541
|
-
var detailCached = skillsCache[detailCacheKey];
|
|
2542
|
-
if (detailCached && Date.now() - detailCached.ts < 300000) {
|
|
2543
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2544
|
-
res.end(detailCached.data);
|
|
2545
|
-
return;
|
|
2546
|
-
}
|
|
2547
|
-
var detailUrl = "https://skills.sh/" + encodeURIComponent(detailSource).replace(/%2F/g, "/") + "/" + encodeURIComponent(detailSkill);
|
|
2548
|
-
fetchSkillDetail(detailUrl).then(function (data) {
|
|
2549
|
-
var json = JSON.stringify(data);
|
|
2550
|
-
skillsCache[detailCacheKey] = { ts: Date.now(), data: json };
|
|
2551
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2552
|
-
res.end(json);
|
|
2553
|
-
}).catch(function (err) {
|
|
2554
|
-
res.writeHead(502, { "Content-Type": "application/json" });
|
|
2555
|
-
res.end(JSON.stringify({ error: "Failed to fetch skill detail: " + (err.message || err) }));
|
|
2556
|
-
});
|
|
2557
|
-
return;
|
|
2558
|
-
}
|
|
459
|
+
// --- Skills routes (delegated to server-skills) ---
|
|
460
|
+
if (skills.handleRequest(req, res, fullUrl)) return;
|
|
2559
461
|
|
|
2560
462
|
// Root path — redirect to first accessible project
|
|
2561
463
|
if (fullUrl === "/" && req.method === "GET") {
|
|
2562
464
|
if (!isRequestAuthed(req)) {
|
|
2563
465
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
2564
|
-
res.end(getAuthPage());
|
|
466
|
+
res.end(auth.getAuthPage());
|
|
2565
467
|
return;
|
|
2566
468
|
}
|
|
2567
469
|
if (projects.size > 0) {
|
|
@@ -2602,7 +504,7 @@ function createServer(opts) {
|
|
|
2602
504
|
// No accessible projects — show info page
|
|
2603
505
|
if (users.isMultiUser()) {
|
|
2604
506
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
2605
|
-
res.end(noProjectsPageHtml());
|
|
507
|
+
res.end(pages.noProjectsPageHtml());
|
|
2606
508
|
return;
|
|
2607
509
|
}
|
|
2608
510
|
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
@@ -2660,7 +562,7 @@ function createServer(opts) {
|
|
|
2660
562
|
// Auth check for project routes
|
|
2661
563
|
if (!isRequestAuthed(req)) {
|
|
2662
564
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
2663
|
-
res.end(getAuthPage());
|
|
565
|
+
res.end(auth.getAuthPage());
|
|
2664
566
|
return;
|
|
2665
567
|
}
|
|
2666
568
|
|
|
@@ -2739,7 +641,7 @@ function createServer(opts) {
|
|
|
2739
641
|
var httpSetupUrl = "http://" + hostname + ":" + (portNum + 1);
|
|
2740
642
|
var lanMode = /[?&]mode=lan/.test(req.url);
|
|
2741
643
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
2742
|
-
res.end(setupPageHtml(httpsSetupUrl, httpSetupUrl, !!caContent, lanMode));
|
|
644
|
+
res.end(pages.setupPageHtml(httpsSetupUrl, httpSetupUrl, !!caContent, lanMode));
|
|
2743
645
|
return;
|
|
2744
646
|
}
|
|
2745
647
|
|
|
@@ -3091,6 +993,9 @@ function createServer(opts) {
|
|
|
3091
993
|
onShutdown: onShutdown,
|
|
3092
994
|
onRestart: onRestart,
|
|
3093
995
|
onDmMessage: handleDmMessage,
|
|
996
|
+
broadcastAll: broadcastAll,
|
|
997
|
+
notificationsModule: _globalNotifications,
|
|
998
|
+
getProject: function (s) { return projects.get(s) || null; },
|
|
3094
999
|
});
|
|
3095
1000
|
projects.set(slug, ctx);
|
|
3096
1001
|
ctx.warmup();
|
|
@@ -3099,446 +1004,26 @@ function createServer(opts) {
|
|
|
3099
1004
|
return true;
|
|
3100
1005
|
}
|
|
3101
1006
|
|
|
3102
|
-
// --- DM message handler (server-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
var otherUser = users.findUserById(dmList[i].otherUserId);
|
|
3112
|
-
if (otherUser) {
|
|
3113
|
-
var p = otherUser.profile || {};
|
|
3114
|
-
dmList[i].otherUser = {
|
|
3115
|
-
id: otherUser.id,
|
|
3116
|
-
displayName: p.name || otherUser.displayName || otherUser.username,
|
|
3117
|
-
username: otherUser.username,
|
|
3118
|
-
avatarStyle: p.avatarStyle || "thumbs",
|
|
3119
|
-
avatarSeed: p.avatarSeed || otherUser.username,
|
|
3120
|
-
avatarColor: p.avatarColor || "#7c3aed",
|
|
3121
|
-
avatarCustom: p.avatarCustom || "",
|
|
3122
|
-
};
|
|
3123
|
-
}
|
|
3124
|
-
}
|
|
3125
|
-
// Include mates in the list
|
|
3126
|
-
var mateCtx = mates.buildMateCtx(userId);
|
|
3127
|
-
var mateList = mates.getAllMates(mateCtx);
|
|
3128
|
-
ws.send(JSON.stringify({ type: "dm_list", dms: dmList, mates: mateList }));
|
|
3129
|
-
return;
|
|
3130
|
-
}
|
|
3131
|
-
|
|
3132
|
-
if (msg.type === "dm_open") {
|
|
3133
|
-
if (!msg.targetUserId) return;
|
|
3134
|
-
|
|
3135
|
-
// Check if target is a mate
|
|
3136
|
-
var mateCtx2 = mates.buildMateCtx(userId);
|
|
3137
|
-
if (mates.isMate(mateCtx2, msg.targetUserId)) {
|
|
3138
|
-
var mate = mates.getMate(mateCtx2, msg.targetUserId);
|
|
3139
|
-
if (!mate) return;
|
|
3140
|
-
// Ensure mate project is registered (survives server restarts)
|
|
3141
|
-
var mateSlug2 = "mate-" + mate.id;
|
|
3142
|
-
if (!projects.has(mateSlug2)) {
|
|
3143
|
-
var mateDir2 = mates.getMateDir(mateCtx2, mate.id);
|
|
3144
|
-
fs.mkdirSync(mateDir2, { recursive: true });
|
|
3145
|
-
var mateName2 = (mate.profile && mate.profile.displayName) || mate.name || "New Mate";
|
|
3146
|
-
addProject(mateDir2, mateSlug2, mateName2, null, mate.createdBy || userId, null, { isMate: true, mateDisplayName: mateName2 });
|
|
3147
|
-
}
|
|
3148
|
-
var mp = mate.profile || {};
|
|
3149
|
-
ws.send(JSON.stringify({
|
|
3150
|
-
type: "dm_history",
|
|
3151
|
-
dmKey: "mate:" + mate.id,
|
|
3152
|
-
messages: dm.loadHistory("mate:" + mate.id),
|
|
3153
|
-
isMate: true,
|
|
3154
|
-
projectSlug: mateSlug2,
|
|
3155
|
-
targetUser: {
|
|
3156
|
-
id: mate.id,
|
|
3157
|
-
displayName: mp.displayName || mate.name || "New Mate",
|
|
3158
|
-
username: mate.id,
|
|
3159
|
-
avatarStyle: mp.avatarStyle || "bottts",
|
|
3160
|
-
avatarSeed: mp.avatarSeed || mate.id,
|
|
3161
|
-
avatarColor: mp.avatarColor || "#6c5ce7",
|
|
3162
|
-
avatarCustom: mp.avatarCustom || "",
|
|
3163
|
-
isMate: true,
|
|
3164
|
-
primary: !!mate.primary,
|
|
3165
|
-
mateStatus: mate.status,
|
|
3166
|
-
seedData: mate.seedData || {},
|
|
3167
|
-
},
|
|
3168
|
-
}));
|
|
3169
|
-
return;
|
|
3170
|
-
}
|
|
3171
|
-
|
|
3172
|
-
var result = dm.openDm(userId, msg.targetUserId);
|
|
3173
|
-
var targetUser = users.findUserById(msg.targetUserId);
|
|
3174
|
-
var tp = targetUser ? (targetUser.profile || {}) : {};
|
|
3175
|
-
ws.send(JSON.stringify({
|
|
3176
|
-
type: "dm_history",
|
|
3177
|
-
dmKey: result.dmKey,
|
|
3178
|
-
messages: result.messages,
|
|
3179
|
-
targetUser: targetUser ? {
|
|
3180
|
-
id: targetUser.id,
|
|
3181
|
-
displayName: tp.name || targetUser.displayName || targetUser.username,
|
|
3182
|
-
username: targetUser.username,
|
|
3183
|
-
avatarStyle: tp.avatarStyle || "thumbs",
|
|
3184
|
-
avatarSeed: tp.avatarSeed || targetUser.username,
|
|
3185
|
-
avatarColor: tp.avatarColor || "#7c3aed",
|
|
3186
|
-
avatarCustom: tp.avatarCustom || "",
|
|
3187
|
-
} : null,
|
|
3188
|
-
}));
|
|
3189
|
-
return;
|
|
3190
|
-
}
|
|
3191
|
-
|
|
3192
|
-
if (msg.type === "dm_typing") {
|
|
3193
|
-
// Relay typing indicator to DM partner
|
|
3194
|
-
var dmKey = msg.dmKey;
|
|
3195
|
-
if (!dmKey) return;
|
|
3196
|
-
var parts = dmKey.split(":");
|
|
3197
|
-
if (parts.indexOf(userId) === -1) return;
|
|
3198
|
-
var targetId = parts[0] === userId ? parts[1] : parts[0];
|
|
3199
|
-
projects.forEach(function (ctx) {
|
|
3200
|
-
ctx.forEachClient(function (otherWs) {
|
|
3201
|
-
if (otherWs === ws) return;
|
|
3202
|
-
if (!otherWs._clayUser || otherWs._clayUser.id !== targetId) return;
|
|
3203
|
-
if (otherWs.readyState !== 1) return;
|
|
3204
|
-
otherWs.send(JSON.stringify({ type: "dm_typing", dmKey: dmKey, userId: userId, typing: !!msg.typing }));
|
|
3205
|
-
});
|
|
3206
|
-
});
|
|
3207
|
-
return;
|
|
3208
|
-
}
|
|
3209
|
-
|
|
3210
|
-
if (msg.type === "dm_send") {
|
|
3211
|
-
if (!msg.dmKey || !msg.text) return;
|
|
3212
|
-
var parts = msg.dmKey.split(":");
|
|
3213
|
-
|
|
3214
|
-
// Handle mate DM: dmKey is "mate:mate_xxx"
|
|
3215
|
-
var mateCtx3 = mates.buildMateCtx(userId);
|
|
3216
|
-
if (parts[0] === "mate" && mates.isMate(mateCtx3, parts[1])) {
|
|
3217
|
-
var mate = mates.getMate(mateCtx3, parts[1]);
|
|
3218
|
-
if (!mate) return;
|
|
3219
|
-
// Verify sender is the mate's creator
|
|
3220
|
-
if (mate.createdBy !== userId) return;
|
|
3221
|
-
var message = dm.sendMessage(msg.dmKey, userId, msg.text);
|
|
3222
|
-
ws.send(JSON.stringify({ type: "dm_message", dmKey: msg.dmKey, message: message }));
|
|
3223
|
-
return;
|
|
3224
|
-
}
|
|
3225
|
-
|
|
3226
|
-
// Regular DM: verify sender is a participant
|
|
3227
|
-
if (parts.indexOf(userId) === -1) return;
|
|
3228
|
-
var message = dm.sendMessage(msg.dmKey, userId, msg.text);
|
|
3229
|
-
// Send confirmation to sender
|
|
3230
|
-
ws.send(JSON.stringify({ type: "dm_message", dmKey: msg.dmKey, message: message }));
|
|
3231
|
-
// Broadcast to target user's connections across all projects
|
|
3232
|
-
var targetId = parts[0] === userId ? parts[1] : parts[0];
|
|
3233
|
-
projects.forEach(function (ctx) {
|
|
3234
|
-
ctx.forEachClient(function (otherWs) {
|
|
3235
|
-
if (otherWs === ws) return;
|
|
3236
|
-
if (!otherWs._clayUser || otherWs._clayUser.id !== targetId) return;
|
|
3237
|
-
if (otherWs.readyState !== 1) return;
|
|
3238
|
-
otherWs.send(JSON.stringify({ type: "dm_message", dmKey: msg.dmKey, message: message }));
|
|
3239
|
-
});
|
|
3240
|
-
});
|
|
3241
|
-
// Send push notification to target user
|
|
3242
|
-
if (pushModule && pushModule.sendPushToUser) {
|
|
3243
|
-
var senderName = ws._clayUser ? (ws._clayUser.displayName || ws._clayUser.username || "Someone") : "Someone";
|
|
3244
|
-
var preview = (msg.text || "").substring(0, 140);
|
|
3245
|
-
pushModule.sendPushToUser(targetId, {
|
|
3246
|
-
type: "dm",
|
|
3247
|
-
title: senderName,
|
|
3248
|
-
body: preview,
|
|
3249
|
-
tag: "dm-" + msg.dmKey,
|
|
3250
|
-
dmKey: msg.dmKey,
|
|
3251
|
-
});
|
|
3252
|
-
}
|
|
3253
|
-
return;
|
|
3254
|
-
}
|
|
3255
|
-
|
|
3256
|
-
if (msg.type === "dm_add_favorite") {
|
|
3257
|
-
if (!msg.targetUserId) return;
|
|
3258
|
-
users.removeDmHidden(userId, msg.targetUserId);
|
|
3259
|
-
var updatedFavorites = users.addDmFavorite(userId, msg.targetUserId);
|
|
3260
|
-
var allUsersList = users.getAllUsers().map(function (u) {
|
|
3261
|
-
var p = u.profile || {};
|
|
3262
|
-
return {
|
|
3263
|
-
id: u.id,
|
|
3264
|
-
displayName: p.name || u.displayName || u.username,
|
|
3265
|
-
username: u.username,
|
|
3266
|
-
role: u.role,
|
|
3267
|
-
avatarStyle: p.avatarStyle || "thumbs",
|
|
3268
|
-
avatarSeed: p.avatarSeed || u.username,
|
|
3269
|
-
avatarColor: p.avatarColor || "#7c3aed",
|
|
3270
|
-
avatarCustom: p.avatarCustom || "",
|
|
3271
|
-
};
|
|
3272
|
-
});
|
|
3273
|
-
ws.send(JSON.stringify({
|
|
3274
|
-
type: "dm_favorites_updated",
|
|
3275
|
-
dmFavorites: updatedFavorites,
|
|
3276
|
-
allUsers: allUsersList,
|
|
3277
|
-
}));
|
|
3278
|
-
return;
|
|
3279
|
-
}
|
|
3280
|
-
|
|
3281
|
-
if (msg.type === "dm_remove_favorite") {
|
|
3282
|
-
if (!msg.targetUserId) return;
|
|
3283
|
-
users.addDmHidden(userId, msg.targetUserId);
|
|
3284
|
-
var updatedFavorites = users.removeDmFavorite(userId, msg.targetUserId);
|
|
3285
|
-
ws.send(JSON.stringify({
|
|
3286
|
-
type: "dm_favorites_updated",
|
|
3287
|
-
dmFavorites: updatedFavorites,
|
|
3288
|
-
}));
|
|
3289
|
-
return;
|
|
3290
|
-
}
|
|
3291
|
-
|
|
3292
|
-
// --- Mate handlers ---
|
|
3293
|
-
|
|
3294
|
-
if (msg.type === "mate_create") {
|
|
3295
|
-
if (!msg.seedData) return;
|
|
3296
|
-
try {
|
|
3297
|
-
var mateCtx4 = mates.buildMateCtx(userId);
|
|
3298
|
-
var mate = mates.createMate(mateCtx4, msg.seedData);
|
|
3299
|
-
// Register mate as a project
|
|
3300
|
-
var mateDir = mates.getMateDir(mateCtx4, mate.id);
|
|
3301
|
-
var mateSlug = "mate-" + mate.id;
|
|
3302
|
-
var mateName = (mate.profile && mate.profile.displayName) || mate.name || "New Mate";
|
|
3303
|
-
addProject(mateDir, mateSlug, mateName, null, mate.createdBy, null, { isMate: true, mateDisplayName: mateName });
|
|
3304
|
-
// Auto-add to favorites so it shows in sidebar
|
|
3305
|
-
users.addDmFavorite(userId, mate.id);
|
|
3306
|
-
ws.send(JSON.stringify({ type: "mate_created", mate: mate, projectSlug: mateSlug }));
|
|
3307
|
-
} catch (e) {
|
|
3308
|
-
ws.send(JSON.stringify({ type: "mate_error", error: "Failed to create mate: " + e.message }));
|
|
3309
|
-
}
|
|
3310
|
-
return;
|
|
3311
|
-
}
|
|
3312
|
-
|
|
3313
|
-
if (msg.type === "mate_list") {
|
|
3314
|
-
var mateCtx5 = mates.buildMateCtx(userId);
|
|
3315
|
-
// Backfill built-in mates for existing users
|
|
3316
|
-
try {
|
|
3317
|
-
var deletedKeys = users.getDeletedBuiltinKeys(userId);
|
|
3318
|
-
var newBuiltins = mates.ensureBuiltinMates(mateCtx5, deletedKeys);
|
|
3319
|
-
for (var bi = 0; bi < newBuiltins.length; bi++) {
|
|
3320
|
-
var nb = newBuiltins[bi];
|
|
3321
|
-
var nbSlug = "mate-" + nb.id;
|
|
3322
|
-
var nbDir = mates.getMateDir(mateCtx5, nb.id);
|
|
3323
|
-
var nbName = (nb.profile && nb.profile.displayName) || nb.name || "New Mate";
|
|
3324
|
-
addProject(nbDir, nbSlug, nbName, null, nb.createdBy || userId, null, { isMate: true, mateDisplayName: nbName });
|
|
3325
|
-
users.addDmFavorite(userId, nb.id);
|
|
3326
|
-
}
|
|
3327
|
-
} catch (e) {
|
|
3328
|
-
console.error("[server] Failed to ensure built-in mates:", e.message);
|
|
3329
|
-
}
|
|
3330
|
-
// Auto-sync primary mates (Ally) with latest definition
|
|
3331
|
-
try { mates.syncPrimaryMates(mateCtx5); } catch (e) {}
|
|
3332
|
-
// Ensure core built-in mates are in favorites (unless user explicitly removed them)
|
|
3333
|
-
// Only auto-favorite the core 3: Ally (chief of staff), Arch (architect), Buzz (marketer)
|
|
3334
|
-
var coreMateKeys = ["ally", "arch", "buzz"];
|
|
3335
|
-
var mateList = mates.getAllMates(mateCtx5);
|
|
3336
|
-
var currentFavs = users.getDmFavorites(userId);
|
|
3337
|
-
var hiddenIds = users.getDmHidden(userId);
|
|
3338
|
-
for (var bfi = 0; bfi < mateList.length; bfi++) {
|
|
3339
|
-
if (mateList[bfi].builtinKey && coreMateKeys.indexOf(mateList[bfi].builtinKey) !== -1 && currentFavs.indexOf(mateList[bfi].id) === -1 && hiddenIds.indexOf(mateList[bfi].id) === -1) {
|
|
3340
|
-
users.addDmFavorite(userId, mateList[bfi].id);
|
|
3341
|
-
}
|
|
3342
|
-
}
|
|
3343
|
-
// Ensure all mate projects are registered (survives server restarts)
|
|
3344
|
-
for (var mi = 0; mi < mateList.length; mi++) {
|
|
3345
|
-
var m = mateList[mi];
|
|
3346
|
-
var mSlug = "mate-" + m.id;
|
|
3347
|
-
if (!projects.has(mSlug)) {
|
|
3348
|
-
var mDir = mates.getMateDir(mateCtx5, m.id);
|
|
3349
|
-
fs.mkdirSync(mDir, { recursive: true });
|
|
3350
|
-
var mName = (m.profile && m.profile.displayName) || m.name || "New Mate";
|
|
3351
|
-
addProject(mDir, mSlug, mName, null, m.createdBy || userId, null, { isMate: true, mateDisplayName: mName });
|
|
3352
|
-
}
|
|
3353
|
-
}
|
|
3354
|
-
// Include deleted built-in mates for re-add UI
|
|
3355
|
-
var builtinDefs2 = require("./builtin-mates");
|
|
3356
|
-
var missingKeys2 = mates.getMissingBuiltinKeys(mateCtx5);
|
|
3357
|
-
var availableBuiltins2 = [];
|
|
3358
|
-
for (var abk2 = 0; abk2 < missingKeys2.length; abk2++) {
|
|
3359
|
-
var bDef2 = builtinDefs2.getBuiltinByKey(missingKeys2[abk2]);
|
|
3360
|
-
if (bDef2) {
|
|
3361
|
-
availableBuiltins2.push({
|
|
3362
|
-
key: bDef2.key,
|
|
3363
|
-
displayName: bDef2.displayName,
|
|
3364
|
-
bio: bDef2.bio,
|
|
3365
|
-
avatarCustom: bDef2.avatarCustom || "",
|
|
3366
|
-
avatarStyle: bDef2.avatarStyle || "bottts",
|
|
3367
|
-
avatarColor: bDef2.avatarColor || "",
|
|
3368
|
-
});
|
|
3369
|
-
}
|
|
3370
|
-
}
|
|
3371
|
-
ws.send(JSON.stringify({ type: "mate_list", mates: mateList, availableBuiltins: availableBuiltins2 }));
|
|
3372
|
-
return;
|
|
3373
|
-
}
|
|
3374
|
-
|
|
3375
|
-
if (msg.type === "mate_delete") {
|
|
3376
|
-
if (!msg.mateId) return;
|
|
3377
|
-
var mateCtx6 = mates.buildMateCtx(userId);
|
|
3378
|
-
// Track deleted built-in mate key so it doesn't auto-recreate
|
|
3379
|
-
var mateToDelete = mates.getMate(mateCtx6, msg.mateId);
|
|
3380
|
-
if (mateToDelete && mateToDelete.builtinKey) {
|
|
3381
|
-
users.addDeletedBuiltinKey(userId, mateToDelete.builtinKey);
|
|
3382
|
-
}
|
|
3383
|
-
var result = mates.deleteMate(mateCtx6, msg.mateId);
|
|
3384
|
-
if (result.error) {
|
|
3385
|
-
ws.send(JSON.stringify({ type: "mate_error", error: result.error }));
|
|
3386
|
-
} else {
|
|
3387
|
-
removeProject("mate-" + msg.mateId);
|
|
3388
|
-
// Build updated available builtins list
|
|
3389
|
-
var builtinDefs3 = require("./builtin-mates");
|
|
3390
|
-
var missingKeys3 = mates.getMissingBuiltinKeys(mateCtx6);
|
|
3391
|
-
var availableBuiltins3 = [];
|
|
3392
|
-
for (var abk3 = 0; abk3 < missingKeys3.length; abk3++) {
|
|
3393
|
-
var bDef3 = builtinDefs3.getBuiltinByKey(missingKeys3[abk3]);
|
|
3394
|
-
if (bDef3) {
|
|
3395
|
-
availableBuiltins3.push({
|
|
3396
|
-
key: bDef3.key,
|
|
3397
|
-
displayName: bDef3.displayName,
|
|
3398
|
-
bio: bDef3.bio,
|
|
3399
|
-
avatarCustom: bDef3.avatarCustom || "",
|
|
3400
|
-
avatarStyle: bDef3.avatarStyle || "bottts",
|
|
3401
|
-
avatarColor: bDef3.avatarColor || "",
|
|
3402
|
-
});
|
|
3403
|
-
}
|
|
3404
|
-
}
|
|
3405
|
-
ws.send(JSON.stringify({ type: "mate_deleted", mateId: msg.mateId, availableBuiltins: availableBuiltins3 }));
|
|
3406
|
-
// Broadcast to all clients so strips update
|
|
3407
|
-
projects.forEach(function (ctx) {
|
|
3408
|
-
ctx.forEachClient(function (otherWs) {
|
|
3409
|
-
if (otherWs === ws) return;
|
|
3410
|
-
if (otherWs.readyState !== 1) return;
|
|
3411
|
-
otherWs.send(JSON.stringify({ type: "mate_deleted", mateId: msg.mateId, availableBuiltins: availableBuiltins3 }));
|
|
3412
|
-
});
|
|
3413
|
-
});
|
|
3414
|
-
}
|
|
3415
|
-
return;
|
|
3416
|
-
}
|
|
3417
|
-
|
|
3418
|
-
if (msg.type === "mate_readd_builtin") {
|
|
3419
|
-
if (!msg.builtinKey) return;
|
|
3420
|
-
try {
|
|
3421
|
-
var mateCtxR = mates.buildMateCtx(userId);
|
|
3422
|
-
var missingKeys = mates.getMissingBuiltinKeys(mateCtxR);
|
|
3423
|
-
if (missingKeys.indexOf(msg.builtinKey) === -1) {
|
|
3424
|
-
ws.send(JSON.stringify({ type: "mate_error", error: "This built-in mate already exists" }));
|
|
3425
|
-
return;
|
|
3426
|
-
}
|
|
3427
|
-
var newMate = mates.createBuiltinMate(mateCtxR, msg.builtinKey);
|
|
3428
|
-
users.removeDeletedBuiltinKey(userId, msg.builtinKey);
|
|
3429
|
-
var updatedFavsR = users.addDmFavorite(userId, newMate.id);
|
|
3430
|
-
var readdSlug = "mate-" + newMate.id;
|
|
3431
|
-
var readdDir = mates.getMateDir(mateCtxR, newMate.id);
|
|
3432
|
-
var readdName = (newMate.profile && newMate.profile.displayName) || newMate.name || "New Mate";
|
|
3433
|
-
addProject(readdDir, readdSlug, readdName, null, newMate.createdBy || userId, null, { isMate: true, mateDisplayName: readdName });
|
|
3434
|
-
// Build updated available builtins
|
|
3435
|
-
var builtinDefsR = require("./builtin-mates");
|
|
3436
|
-
var missingKeysR = mates.getMissingBuiltinKeys(mateCtxR);
|
|
3437
|
-
var availableBuiltinsR = [];
|
|
3438
|
-
for (var abkR = 0; abkR < missingKeysR.length; abkR++) {
|
|
3439
|
-
var bDefR = builtinDefsR.getBuiltinByKey(missingKeysR[abkR]);
|
|
3440
|
-
if (bDefR) {
|
|
3441
|
-
availableBuiltinsR.push({ key: bDefR.key, displayName: bDefR.displayName, bio: bDefR.bio, avatarCustom: bDefR.avatarCustom || "", avatarStyle: bDefR.avatarStyle || "bottts", avatarColor: bDefR.avatarColor || "" });
|
|
3442
|
-
}
|
|
3443
|
-
}
|
|
3444
|
-
ws.send(JSON.stringify({ type: "mate_created", mate: newMate, projectSlug: readdSlug, availableBuiltins: availableBuiltinsR, dmFavorites: updatedFavsR }));
|
|
3445
|
-
} catch (e) {
|
|
3446
|
-
ws.send(JSON.stringify({ type: "mate_error", error: "Failed to re-add built-in mate: " + e.message }));
|
|
3447
|
-
}
|
|
3448
|
-
return;
|
|
3449
|
-
}
|
|
3450
|
-
|
|
3451
|
-
if (msg.type === "mate_list_available_builtins") {
|
|
3452
|
-
var mateCtxAB = mates.buildMateCtx(userId);
|
|
3453
|
-
var missingBuiltinKeys = mates.getMissingBuiltinKeys(mateCtxAB);
|
|
3454
|
-
var builtinDefs = require("./builtin-mates");
|
|
3455
|
-
var availableBuiltins = [];
|
|
3456
|
-
for (var abk = 0; abk < missingBuiltinKeys.length; abk++) {
|
|
3457
|
-
var bDef = builtinDefs.getBuiltinByKey(missingBuiltinKeys[abk]);
|
|
3458
|
-
if (bDef) {
|
|
3459
|
-
availableBuiltins.push({
|
|
3460
|
-
key: bDef.key,
|
|
3461
|
-
displayName: bDef.displayName,
|
|
3462
|
-
bio: bDef.bio,
|
|
3463
|
-
avatarColor: bDef.avatarColor,
|
|
3464
|
-
avatarStyle: bDef.avatarStyle,
|
|
3465
|
-
avatarCustom: bDef.avatarCustom || "",
|
|
3466
|
-
});
|
|
3467
|
-
}
|
|
3468
|
-
}
|
|
3469
|
-
ws.send(JSON.stringify({ type: "mate_available_builtins", builtins: availableBuiltins }));
|
|
3470
|
-
return;
|
|
3471
|
-
}
|
|
3472
|
-
|
|
3473
|
-
if (msg.type === "mate_update") {
|
|
3474
|
-
if (!msg.mateId || !msg.updates) return;
|
|
3475
|
-
var mateCtx7 = mates.buildMateCtx(userId);
|
|
3476
|
-
var updated = mates.updateMate(mateCtx7, msg.mateId, msg.updates);
|
|
3477
|
-
if (updated) {
|
|
3478
|
-
ws.send(JSON.stringify({ type: "mate_updated", mate: updated }));
|
|
3479
|
-
// Broadcast update
|
|
3480
|
-
projects.forEach(function (ctx) {
|
|
3481
|
-
ctx.forEachClient(function (otherWs) {
|
|
3482
|
-
if (otherWs === ws) return;
|
|
3483
|
-
if (otherWs.readyState !== 1) return;
|
|
3484
|
-
otherWs.send(JSON.stringify({ type: "mate_updated", mate: updated }));
|
|
3485
|
-
});
|
|
3486
|
-
});
|
|
3487
|
-
// Re-enforce team sections across all mate projects so roster stays current
|
|
3488
|
-
refreshTeamSections(mateCtx7);
|
|
3489
|
-
} else {
|
|
3490
|
-
ws.send(JSON.stringify({ type: "mate_error", error: "Mate not found" }));
|
|
3491
|
-
}
|
|
3492
|
-
return;
|
|
3493
|
-
}
|
|
3494
|
-
}
|
|
1007
|
+
// --- DM message handler (delegated to server-dm + server-mates inline) ---
|
|
1008
|
+
var dmHandler = serverDm.attachDm({
|
|
1009
|
+
users: users,
|
|
1010
|
+
dm: dm,
|
|
1011
|
+
mates: mates,
|
|
1012
|
+
projects: projects,
|
|
1013
|
+
pushModule: pushModule,
|
|
1014
|
+
addProject: addProject,
|
|
1015
|
+
});
|
|
3495
1016
|
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
try {
|
|
3502
|
-
var allMates = mates.getAllMates(mateCtx);
|
|
3503
|
-
// Collect non-mate projects for registry injection
|
|
3504
|
-
var projList = [];
|
|
3505
|
-
projects.forEach(function (pCtx) {
|
|
3506
|
-
var st = pCtx.getStatus();
|
|
3507
|
-
if (!st.isMate && !st.isWorktree) projList.push(st);
|
|
3508
|
-
});
|
|
3509
|
-
for (var ri = 0; ri < allMates.length; ri++) {
|
|
3510
|
-
var mDir = mates.getMateDir(mateCtx, allMates[ri].id);
|
|
3511
|
-
var claudePath = path.join(mDir, "CLAUDE.md");
|
|
3512
|
-
try {
|
|
3513
|
-
mates.enforceAllSections(claudePath, { ctx: mateCtx, mateId: allMates[ri].id, projects: projList });
|
|
3514
|
-
} catch (e) {}
|
|
3515
|
-
}
|
|
3516
|
-
} catch (e) {
|
|
3517
|
-
console.error("[mates] refreshTeamSections failed:", e.message);
|
|
3518
|
-
}
|
|
1017
|
+
// --- Mate handler ---
|
|
1018
|
+
// Forward reference: mateHandler is set up after removeProject is defined
|
|
1019
|
+
var mateHandler = null;
|
|
1020
|
+
function scheduleRegistryRefresh() {
|
|
1021
|
+
if (mateHandler) mateHandler.scheduleRegistryRefresh();
|
|
3519
1022
|
}
|
|
3520
1023
|
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
if (_registryRefreshTimer) clearTimeout(_registryRefreshTimer);
|
|
3525
|
-
_registryRefreshTimer = setTimeout(function () {
|
|
3526
|
-
_registryRefreshTimer = null;
|
|
3527
|
-
// Refresh for all known user contexts
|
|
3528
|
-
try {
|
|
3529
|
-
var allCtxs = {};
|
|
3530
|
-
projects.forEach(function (pCtx) {
|
|
3531
|
-
var st = pCtx.getStatus();
|
|
3532
|
-
if (st.projectOwnerId && !allCtxs[st.projectOwnerId]) {
|
|
3533
|
-
allCtxs[st.projectOwnerId] = mates.buildMateCtx(st.projectOwnerId);
|
|
3534
|
-
}
|
|
3535
|
-
});
|
|
3536
|
-
var ctxKeys = Object.keys(allCtxs);
|
|
3537
|
-
for (var ci = 0; ci < ctxKeys.length; ci++) {
|
|
3538
|
-
refreshTeamSections(allCtxs[ctxKeys[ci]]);
|
|
3539
|
-
}
|
|
3540
|
-
} catch (e) {}
|
|
3541
|
-
}, 2000);
|
|
1024
|
+
function handleDmMessage(ws, msg) {
|
|
1025
|
+
if (dmHandler.handleMessage(ws, msg)) return;
|
|
1026
|
+
if (mateHandler && mateHandler.handleMessage(ws, msg)) return;
|
|
3542
1027
|
}
|
|
3543
1028
|
|
|
3544
1029
|
function removeProject(slug) {
|
|
@@ -3551,6 +1036,16 @@ function createServer(opts) {
|
|
|
3551
1036
|
return true;
|
|
3552
1037
|
}
|
|
3553
1038
|
|
|
1039
|
+
// Now that addProject and removeProject are defined, initialize mateHandler
|
|
1040
|
+
mateHandler = serverMates.attachMates({
|
|
1041
|
+
users: users,
|
|
1042
|
+
mates: mates,
|
|
1043
|
+
projects: projects,
|
|
1044
|
+
addProject: addProject,
|
|
1045
|
+
removeProject: removeProject,
|
|
1046
|
+
onGetProjectAccess: onGetProjectAccess,
|
|
1047
|
+
});
|
|
1048
|
+
|
|
3554
1049
|
function getProjects() {
|
|
3555
1050
|
var list = [];
|
|
3556
1051
|
projects.forEach(function (ctx) {
|
|
@@ -3589,10 +1084,6 @@ function createServer(opts) {
|
|
|
3589
1084
|
return true;
|
|
3590
1085
|
}
|
|
3591
1086
|
|
|
3592
|
-
function setAuthToken(hash) {
|
|
3593
|
-
authToken = hash;
|
|
3594
|
-
}
|
|
3595
|
-
|
|
3596
1087
|
// Collect all unique users across all projects (for topbar server-wide presence)
|
|
3597
1088
|
function getServerUsers() {
|
|
3598
1089
|
var seen = {};
|
|
@@ -3767,9 +1258,9 @@ function createServer(opts) {
|
|
|
3767
1258
|
reorderProjects: reorderProjects,
|
|
3768
1259
|
setProjectTitle: setProjectTitle,
|
|
3769
1260
|
setProjectIcon: setProjectIcon,
|
|
3770
|
-
setAuthToken: setAuthToken,
|
|
3771
|
-
setRecovery: setRecovery,
|
|
3772
|
-
clearRecovery: clearRecovery,
|
|
1261
|
+
setAuthToken: auth.setAuthToken,
|
|
1262
|
+
setRecovery: auth.setRecovery,
|
|
1263
|
+
clearRecovery: auth.clearRecovery,
|
|
3773
1264
|
broadcastAll: broadcastAll,
|
|
3774
1265
|
destroyAll: destroyAll,
|
|
3775
1266
|
};
|