clay-server 2.8.2 → 2.9.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 +2 -0
- package/bin/cli.js +122 -2
- package/lib/config.js +20 -1
- package/lib/daemon.js +40 -0
- package/lib/pages.js +670 -27
- package/lib/project.js +267 -16
- package/lib/public/app.js +74 -14
- package/lib/public/css/admin.css +576 -0
- package/lib/public/css/icon-strip.css +1 -0
- package/lib/public/css/menus.css +16 -11
- package/lib/public/css/overlays.css +2 -4
- package/lib/public/css/sidebar.css +49 -0
- package/lib/public/css/title-bar.css +45 -1
- package/lib/public/index.html +38 -8
- package/lib/public/modules/admin.js +631 -0
- package/lib/public/modules/markdown.js +9 -5
- package/lib/public/modules/profile.js +21 -0
- package/lib/public/modules/project-settings.js +4 -1
- package/lib/public/modules/server-settings.js +13 -0
- package/lib/public/modules/sidebar.js +111 -5
- package/lib/public/style.css +1 -0
- package/lib/push.js +6 -0
- package/lib/server.js +1075 -27
- package/lib/sessions.js +127 -41
- package/lib/smtp.js +221 -0
- package/lib/users.js +459 -0
- package/package.json +2 -1
package/lib/server.js
CHANGED
|
@@ -3,8 +3,10 @@ var crypto = require("crypto");
|
|
|
3
3
|
var fs = require("fs");
|
|
4
4
|
var path = require("path");
|
|
5
5
|
var { WebSocketServer } = require("ws");
|
|
6
|
-
var { pinPageHtml, setupPageHtml } = require("./pages");
|
|
6
|
+
var { pinPageHtml, setupPageHtml, adminSetupPageHtml, multiUserLoginPageHtml, smtpLoginPageHtml, invitePageHtml, smtpInvitePageHtml, noProjectsPageHtml } = require("./pages");
|
|
7
|
+
var smtp = require("./smtp");
|
|
7
8
|
var { createProjectContext } = require("./project");
|
|
9
|
+
var users = require("./users");
|
|
8
10
|
|
|
9
11
|
var { CONFIG_DIR } = require("./config");
|
|
10
12
|
|
|
@@ -178,7 +180,25 @@ var MIME_TYPES = {
|
|
|
178
180
|
};
|
|
179
181
|
|
|
180
182
|
function generateAuthToken(pin) {
|
|
181
|
-
|
|
183
|
+
var salt = crypto.randomBytes(16).toString("hex");
|
|
184
|
+
var hash = crypto.scryptSync("clay:" + pin, salt, 64).toString("hex");
|
|
185
|
+
return salt + ":" + hash;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function verifyPin(pin, storedHash) {
|
|
189
|
+
if (!storedHash) return false;
|
|
190
|
+
// New scrypt format: salt_hex:hash_hex (contains colon)
|
|
191
|
+
if (storedHash.indexOf(":") !== -1) {
|
|
192
|
+
var parts = storedHash.split(":");
|
|
193
|
+
var salt = parts[0];
|
|
194
|
+
var hash = parts[1];
|
|
195
|
+
var derived = crypto.scryptSync("clay:" + pin, salt, 64).toString("hex");
|
|
196
|
+
return crypto.timingSafeEqual(Buffer.from(derived, "hex"), Buffer.from(hash, "hex"));
|
|
197
|
+
}
|
|
198
|
+
// Legacy SHA256 format (no colon)
|
|
199
|
+
var legacyHash = crypto.createHash("sha256").update("clay:" + pin).digest("hex");
|
|
200
|
+
var match = crypto.timingSafeEqual(Buffer.from(legacyHash, "hex"), Buffer.from(storedHash, "hex"));
|
|
201
|
+
return match;
|
|
182
202
|
}
|
|
183
203
|
|
|
184
204
|
function parseCookies(req) {
|
|
@@ -197,6 +217,31 @@ function isAuthed(req, authToken) {
|
|
|
197
217
|
return cookies["relay_auth"] === authToken;
|
|
198
218
|
}
|
|
199
219
|
|
|
220
|
+
// --- Multi-user auth helpers ---
|
|
221
|
+
// Multi-user auth tokens: userId:randomToken stored in memory
|
|
222
|
+
var multiUserTokens = {}; // token → userId
|
|
223
|
+
|
|
224
|
+
function createMultiUserSession(userId, tlsOptions) {
|
|
225
|
+
var token = users.generateUserAuthToken(userId);
|
|
226
|
+
multiUserTokens[token] = userId;
|
|
227
|
+
var cookie = "relay_auth_user=" + token + "; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000" + (tlsOptions ? "; Secure" : "");
|
|
228
|
+
return { token: token, cookie: cookie };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function getMultiUserFromReq(req) {
|
|
232
|
+
var cookies = parseCookies(req);
|
|
233
|
+
var token = cookies["relay_auth_user"];
|
|
234
|
+
if (!token) return null;
|
|
235
|
+
var userId = multiUserTokens[token];
|
|
236
|
+
if (!userId) return null;
|
|
237
|
+
var user = users.findUserById(userId);
|
|
238
|
+
return user || null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function isMultiUserAuthed(req) {
|
|
242
|
+
return !!getMultiUserFromReq(req);
|
|
243
|
+
}
|
|
244
|
+
|
|
200
245
|
// --- PIN rate limiting ---
|
|
201
246
|
var pinAttempts = {}; // ip → { count, lastAttempt }
|
|
202
247
|
var PIN_MAX_ATTEMPTS = 5;
|
|
@@ -302,6 +347,10 @@ function createServer(opts) {
|
|
|
302
347
|
var onSetPin = opts.onSetPin || null;
|
|
303
348
|
var onSetKeepAwake = opts.onSetKeepAwake || null;
|
|
304
349
|
var onShutdown = opts.onShutdown || null;
|
|
350
|
+
var onUpgradePin = opts.onUpgradePin || null;
|
|
351
|
+
var onSetProjectVisibility = opts.onSetProjectVisibility || null;
|
|
352
|
+
var onSetProjectAllowedUsers = opts.onSetProjectAllowedUsers || null;
|
|
353
|
+
var onGetProjectAccess = opts.onGetProjectAccess || null;
|
|
305
354
|
|
|
306
355
|
var authToken = pinHash || null;
|
|
307
356
|
var realVersion = require("../package.json").version;
|
|
@@ -309,6 +358,23 @@ function createServer(opts) {
|
|
|
309
358
|
|
|
310
359
|
var caContent = caPath ? (function () { try { return fs.readFileSync(caPath); } catch (e) { return null; } })() : null;
|
|
311
360
|
var pinPage = pinPageHtml();
|
|
361
|
+
var adminSetupPage = adminSetupPageHtml();
|
|
362
|
+
var loginPage = multiUserLoginPageHtml();
|
|
363
|
+
var smtpLoginPage = smtpLoginPageHtml();
|
|
364
|
+
|
|
365
|
+
// Multi-user auth: determine which page to show for unauthenticated requests
|
|
366
|
+
function getAuthPage() {
|
|
367
|
+
if (!users.isMultiUser()) return pinPage;
|
|
368
|
+
if (!users.hasAdmin()) return adminSetupPage;
|
|
369
|
+
if (smtp.isEmailLoginEnabled()) return smtpLoginPage;
|
|
370
|
+
return loginPage;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Unified auth check: works in both single-user and multi-user mode
|
|
374
|
+
function isRequestAuthed(req) {
|
|
375
|
+
if (users.isMultiUser()) return isMultiUserAuthed(req);
|
|
376
|
+
return isAuthed(req, authToken);
|
|
377
|
+
}
|
|
312
378
|
|
|
313
379
|
// --- Project registry ---
|
|
314
380
|
var projects = new Map(); // slug → projectContext
|
|
@@ -320,8 +386,26 @@ function createServer(opts) {
|
|
|
320
386
|
pushModule = initPush();
|
|
321
387
|
} catch (e) {}
|
|
322
388
|
|
|
389
|
+
// --- Security headers ---
|
|
390
|
+
var securityHeaders = {
|
|
391
|
+
"X-Content-Type-Options": "nosniff",
|
|
392
|
+
"X-Frame-Options": "DENY",
|
|
393
|
+
"Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://esm.sh; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; img-src 'self' data: blob: https://cdn.jsdelivr.net https://api.dicebear.com; connect-src 'self' ws: wss: https://cdn.jsdelivr.net https://esm.sh; font-src 'self' data: https://fonts.gstatic.com https://cdn.jsdelivr.net;",
|
|
394
|
+
};
|
|
395
|
+
if (tlsOptions) {
|
|
396
|
+
securityHeaders["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains";
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function setSecurityHeaders(res) {
|
|
400
|
+
var keys = Object.keys(securityHeaders);
|
|
401
|
+
for (var i = 0; i < keys.length; i++) {
|
|
402
|
+
res.setHeader(keys[i], securityHeaders[keys[i]]);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
323
406
|
// --- HTTP handler ---
|
|
324
407
|
var appHandler = function (req, res) {
|
|
408
|
+
setSecurityHeaders(res);
|
|
325
409
|
var fullUrl = req.url.split("?")[0];
|
|
326
410
|
|
|
327
411
|
// Global auth endpoint
|
|
@@ -338,8 +422,16 @@ function createServer(opts) {
|
|
|
338
422
|
req.on("end", function () {
|
|
339
423
|
try {
|
|
340
424
|
var data = JSON.parse(body);
|
|
341
|
-
if (authToken &&
|
|
425
|
+
if (authToken && verifyPin(data.pin, authToken)) {
|
|
342
426
|
clearPinFailures(ip);
|
|
427
|
+
// Auto-upgrade legacy SHA256 hash to scrypt
|
|
428
|
+
if (authToken.indexOf(":") === -1) {
|
|
429
|
+
var upgraded = generateAuthToken(data.pin);
|
|
430
|
+
authToken = upgraded;
|
|
431
|
+
if (typeof onUpgradePin === "function") {
|
|
432
|
+
onUpgradePin(upgraded);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
343
435
|
res.writeHead(200, {
|
|
344
436
|
"Set-Cookie": "relay_auth=" + authToken + "; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000" + (tlsOptions ? "; Secure" : ""),
|
|
345
437
|
"Content-Type": "application/json",
|
|
@@ -359,6 +451,318 @@ function createServer(opts) {
|
|
|
359
451
|
return;
|
|
360
452
|
}
|
|
361
453
|
|
|
454
|
+
// --- Multi-user auth endpoints ---
|
|
455
|
+
|
|
456
|
+
// Admin setup (first-time multi-user setup)
|
|
457
|
+
if (req.method === "POST" && fullUrl === "/auth/setup") {
|
|
458
|
+
if (!users.isMultiUser()) {
|
|
459
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
460
|
+
res.end('{"error":"Multi-user mode is not enabled"}');
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (users.hasAdmin()) {
|
|
464
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
465
|
+
res.end('{"error":"Admin already exists"}');
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
var body = "";
|
|
469
|
+
req.on("data", function (chunk) { body += chunk; });
|
|
470
|
+
req.on("end", function () {
|
|
471
|
+
try {
|
|
472
|
+
var data = JSON.parse(body);
|
|
473
|
+
if (!users.validateSetupCode(data.setupCode)) {
|
|
474
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
475
|
+
res.end('{"error":"Invalid setup code"}');
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (!data.username || data.username.trim().length < 1 || data.username.length > 100) {
|
|
479
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
480
|
+
res.end('{"error":"Username is required"}');
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (!data.pin || !/^\d{6}$/.test(data.pin)) {
|
|
484
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
485
|
+
res.end('{"error":"PIN must be exactly 6 digits"}');
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
// Migrate existing profile.json to admin profile
|
|
489
|
+
var adminProfile = undefined;
|
|
490
|
+
try {
|
|
491
|
+
var existingProfile = JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, "profile.json"), "utf8"));
|
|
492
|
+
adminProfile = {
|
|
493
|
+
name: data.displayName || data.username,
|
|
494
|
+
lang: existingProfile.lang || "en-US",
|
|
495
|
+
avatarColor: existingProfile.avatarColor || "#7c3aed",
|
|
496
|
+
avatarStyle: existingProfile.avatarStyle || "thumbs",
|
|
497
|
+
avatarSeed: existingProfile.avatarSeed || crypto.randomBytes(4).toString("hex"),
|
|
498
|
+
};
|
|
499
|
+
} catch (e) {}
|
|
500
|
+
var result = users.createAdmin({
|
|
501
|
+
username: data.username.trim(),
|
|
502
|
+
displayName: data.displayName || data.username.trim(),
|
|
503
|
+
pin: data.pin,
|
|
504
|
+
profile: adminProfile,
|
|
505
|
+
});
|
|
506
|
+
if (result.error) {
|
|
507
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
508
|
+
res.end(JSON.stringify({ error: result.error }));
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
users.clearSetupCode();
|
|
512
|
+
var session = createMultiUserSession(result.user.id, tlsOptions);
|
|
513
|
+
res.writeHead(200, {
|
|
514
|
+
"Set-Cookie": session.cookie,
|
|
515
|
+
"Content-Type": "application/json",
|
|
516
|
+
});
|
|
517
|
+
res.end(JSON.stringify({ ok: true, user: { id: result.user.id, username: result.user.username, role: result.user.role } }));
|
|
518
|
+
} catch (e) {
|
|
519
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
520
|
+
res.end('{"error":"Invalid request"}');
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Multi-user login
|
|
527
|
+
if (req.method === "POST" && fullUrl === "/auth/login") {
|
|
528
|
+
if (!users.isMultiUser()) {
|
|
529
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
530
|
+
res.end('{"error":"Multi-user mode is not enabled"}');
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
var ip = req.socket.remoteAddress || "";
|
|
534
|
+
var remaining = checkPinRateLimit(ip);
|
|
535
|
+
if (remaining !== null) {
|
|
536
|
+
res.writeHead(429, { "Content-Type": "application/json" });
|
|
537
|
+
res.end(JSON.stringify({ ok: false, locked: true, retryAfter: remaining }));
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
var body = "";
|
|
541
|
+
req.on("data", function (chunk) { body += chunk; });
|
|
542
|
+
req.on("end", function () {
|
|
543
|
+
try {
|
|
544
|
+
var data = JSON.parse(body);
|
|
545
|
+
var user = users.authenticateUser(data.username, data.pin);
|
|
546
|
+
if (!user) {
|
|
547
|
+
recordPinFailure(ip);
|
|
548
|
+
var attemptsLeft = PIN_MAX_ATTEMPTS - (pinAttempts[ip] ? pinAttempts[ip].count : 0);
|
|
549
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
550
|
+
res.end(JSON.stringify({ ok: false, error: "Invalid username or PIN", attemptsLeft: Math.max(attemptsLeft, 0) }));
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
clearPinFailures(ip);
|
|
554
|
+
var session = createMultiUserSession(user.id, tlsOptions);
|
|
555
|
+
res.writeHead(200, {
|
|
556
|
+
"Set-Cookie": session.cookie,
|
|
557
|
+
"Content-Type": "application/json",
|
|
558
|
+
});
|
|
559
|
+
res.end(JSON.stringify({ ok: true, user: { id: user.id, username: user.username, role: user.role } }));
|
|
560
|
+
} catch (e) {
|
|
561
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
562
|
+
res.end('{"error":"Invalid request"}');
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Request OTP code (SMTP login)
|
|
569
|
+
if (req.method === "POST" && fullUrl === "/auth/request-otp") {
|
|
570
|
+
if (!users.isMultiUser() || !smtp.isEmailLoginEnabled()) {
|
|
571
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
572
|
+
res.end('{"error":"OTP login not available"}');
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
var ip = req.socket.remoteAddress || "";
|
|
576
|
+
var remaining = checkPinRateLimit(ip);
|
|
577
|
+
if (remaining !== null) {
|
|
578
|
+
res.writeHead(429, { "Content-Type": "application/json" });
|
|
579
|
+
res.end(JSON.stringify({ ok: false, locked: true, retryAfter: remaining }));
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
var body = "";
|
|
583
|
+
req.on("data", function (chunk) { body += chunk; });
|
|
584
|
+
req.on("end", function () {
|
|
585
|
+
try {
|
|
586
|
+
var data = JSON.parse(body);
|
|
587
|
+
if (!data.email) {
|
|
588
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
589
|
+
res.end('{"error":"Email is required"}');
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
var user = users.findUserByEmail(data.email);
|
|
593
|
+
if (!user) {
|
|
594
|
+
// Don't reveal whether user exists — still say ok
|
|
595
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
596
|
+
res.end('{"ok":true}');
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
var result = smtp.requestOtp(data.email);
|
|
600
|
+
if (result.error) {
|
|
601
|
+
res.writeHead(429, { "Content-Type": "application/json" });
|
|
602
|
+
res.end(JSON.stringify({ error: result.error }));
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
smtp.sendOtpEmail(data.email, result.code).then(function () {
|
|
606
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
607
|
+
res.end('{"ok":true}');
|
|
608
|
+
}).catch(function () {
|
|
609
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
610
|
+
res.end('{"error":"Failed to send email"}');
|
|
611
|
+
});
|
|
612
|
+
} catch (e) {
|
|
613
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
614
|
+
res.end('{"error":"Invalid request"}');
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Verify OTP code (SMTP login)
|
|
621
|
+
if (req.method === "POST" && fullUrl === "/auth/verify-otp") {
|
|
622
|
+
if (!users.isMultiUser() || !smtp.isEmailLoginEnabled()) {
|
|
623
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
624
|
+
res.end('{"error":"OTP login not available"}');
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
var ip = req.socket.remoteAddress || "";
|
|
628
|
+
var remaining = checkPinRateLimit(ip);
|
|
629
|
+
if (remaining !== null) {
|
|
630
|
+
res.writeHead(429, { "Content-Type": "application/json" });
|
|
631
|
+
res.end(JSON.stringify({ ok: false, locked: true, retryAfter: remaining }));
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
var body = "";
|
|
635
|
+
req.on("data", function (chunk) { body += chunk; });
|
|
636
|
+
req.on("end", function () {
|
|
637
|
+
try {
|
|
638
|
+
var data = JSON.parse(body);
|
|
639
|
+
if (!data.email || !data.code) {
|
|
640
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
641
|
+
res.end('{"error":"Email and code are required"}');
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
var otpResult = smtp.verifyOtp(data.email, data.code);
|
|
645
|
+
if (!otpResult.valid) {
|
|
646
|
+
recordPinFailure(ip);
|
|
647
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
648
|
+
res.end(JSON.stringify({ ok: false, error: otpResult.error, attemptsLeft: otpResult.attemptsLeft }));
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
var user = users.findUserByEmail(data.email);
|
|
652
|
+
if (!user) {
|
|
653
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
654
|
+
res.end('{"ok":false,"error":"Account not found"}');
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
clearPinFailures(ip);
|
|
658
|
+
var session = createMultiUserSession(user.id, tlsOptions);
|
|
659
|
+
res.writeHead(200, {
|
|
660
|
+
"Set-Cookie": session.cookie,
|
|
661
|
+
"Content-Type": "application/json",
|
|
662
|
+
});
|
|
663
|
+
res.end(JSON.stringify({ ok: true, user: { id: user.id, username: user.username, role: user.role } }));
|
|
664
|
+
} catch (e) {
|
|
665
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
666
|
+
res.end('{"error":"Invalid request"}');
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Invite registration
|
|
673
|
+
if (req.method === "POST" && fullUrl === "/auth/register") {
|
|
674
|
+
if (!users.isMultiUser()) {
|
|
675
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
676
|
+
res.end('{"error":"Multi-user mode is not enabled"}');
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
var body = "";
|
|
680
|
+
req.on("data", function (chunk) { body += chunk; });
|
|
681
|
+
req.on("end", function () {
|
|
682
|
+
try {
|
|
683
|
+
var data = JSON.parse(body);
|
|
684
|
+
var validation = users.validateInvite(data.inviteCode);
|
|
685
|
+
if (!validation.valid) {
|
|
686
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
687
|
+
res.end(JSON.stringify({ error: validation.error }));
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
if (!data.username || data.username.trim().length < 1 || data.username.length > 100) {
|
|
691
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
692
|
+
res.end('{"error":"Username is required"}');
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
var result;
|
|
696
|
+
if (smtp.isEmailLoginEnabled() && !data.pin) {
|
|
697
|
+
// SMTP mode: username + email required, no PIN
|
|
698
|
+
if (!data.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
|
699
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
700
|
+
res.end('{"error":"A valid email address is required"}');
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
result = users.createUserWithoutPin({
|
|
704
|
+
username: data.username.trim(),
|
|
705
|
+
email: data.email,
|
|
706
|
+
displayName: data.displayName || data.username.trim(),
|
|
707
|
+
role: "user",
|
|
708
|
+
});
|
|
709
|
+
} else {
|
|
710
|
+
// PIN mode: username + PIN, no email required
|
|
711
|
+
if (!data.pin || !/^\d{6}$/.test(data.pin)) {
|
|
712
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
713
|
+
res.end('{"error":"PIN must be exactly 6 digits"}');
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
result = users.createUser({
|
|
717
|
+
username: data.username.trim(),
|
|
718
|
+
email: data.email || null,
|
|
719
|
+
displayName: data.displayName || data.username.trim(),
|
|
720
|
+
pin: data.pin,
|
|
721
|
+
role: "user",
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
if (result.error) {
|
|
725
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
726
|
+
res.end(JSON.stringify({ error: result.error }));
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
users.markInviteUsed(data.inviteCode);
|
|
730
|
+
var session = createMultiUserSession(result.user.id, tlsOptions);
|
|
731
|
+
res.writeHead(200, {
|
|
732
|
+
"Set-Cookie": session.cookie,
|
|
733
|
+
"Content-Type": "application/json",
|
|
734
|
+
});
|
|
735
|
+
res.end(JSON.stringify({ ok: true, user: { id: result.user.id, username: result.user.username, role: result.user.role } }));
|
|
736
|
+
} catch (e) {
|
|
737
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
738
|
+
res.end('{"error":"Invalid request"}');
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Invite page (magic link)
|
|
745
|
+
if (req.method === "GET" && fullUrl.indexOf("/invite/") === 0) {
|
|
746
|
+
var inviteCode = fullUrl.substring("/invite/".length);
|
|
747
|
+
if (!users.isMultiUser()) {
|
|
748
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
749
|
+
res.end("Not found");
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
var validation = users.validateInvite(inviteCode);
|
|
753
|
+
if (!validation.valid) {
|
|
754
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
755
|
+
res.end('<!DOCTYPE html><html><head><title>Clay</title>' +
|
|
756
|
+
'<style>body{background:#2F2E2B;color:#E8E5DE;font-family:system-ui;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}' +
|
|
757
|
+
'.c{text-align:center;max-width:360px;padding:20px}h1{color:#DA7756;margin-bottom:16px}p{color:#908B81}</style></head>' +
|
|
758
|
+
'<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>');
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
762
|
+
res.end(smtp.isEmailLoginEnabled() ? smtpInvitePageHtml(inviteCode) : invitePageHtml(inviteCode));
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
|
|
362
766
|
// CA certificate download
|
|
363
767
|
if (req.url === "/ca/download" && req.method === "GET" && caContent) {
|
|
364
768
|
res.writeHead(200, {
|
|
@@ -454,10 +858,25 @@ function createServer(opts) {
|
|
|
454
858
|
return;
|
|
455
859
|
}
|
|
456
860
|
|
|
457
|
-
// User profile
|
|
861
|
+
// User profile — user-scoped in multi-user mode, global in single-user mode
|
|
458
862
|
var profilePath = path.join(CONFIG_DIR, "profile.json");
|
|
459
863
|
|
|
460
864
|
if (req.method === "GET" && fullUrl === "/api/profile") {
|
|
865
|
+
if (users.isMultiUser()) {
|
|
866
|
+
var mu = getMultiUserFromReq(req);
|
|
867
|
+
if (!mu) {
|
|
868
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
869
|
+
res.end('{"error":"unauthorized"}');
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
var profile = mu.profile || { name: "", lang: "en-US", avatarColor: "#7c3aed", avatarStyle: "thumbs", avatarSeed: "" };
|
|
873
|
+
profile.username = mu.username;
|
|
874
|
+
profile.userId = mu.id;
|
|
875
|
+
profile.role = mu.role;
|
|
876
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
877
|
+
res.end(JSON.stringify(profile));
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
461
880
|
var profile = { name: "", lang: "en-US", avatarColor: "#7c3aed", avatarStyle: "thumbs", avatarSeed: "" };
|
|
462
881
|
try {
|
|
463
882
|
var raw = fs.readFileSync(profilePath, "utf8");
|
|
@@ -487,7 +906,24 @@ function createServer(opts) {
|
|
|
487
906
|
}
|
|
488
907
|
if (typeof data.avatarStyle === "string") profile.avatarStyle = data.avatarStyle.substring(0, 30);
|
|
489
908
|
if (typeof data.avatarSeed === "string") profile.avatarSeed = data.avatarSeed.substring(0, 30);
|
|
490
|
-
|
|
909
|
+
if (users.isMultiUser()) {
|
|
910
|
+
var mu = getMultiUserFromReq(req);
|
|
911
|
+
if (!mu) {
|
|
912
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
913
|
+
res.end('{"error":"unauthorized"}');
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
users.updateUserProfile(mu.id, profile);
|
|
917
|
+
// Broadcast updated avatar/presence to all projects
|
|
918
|
+
projects.forEach(function (pCtx) {
|
|
919
|
+
pCtx.refreshUserProfile(mu.id);
|
|
920
|
+
});
|
|
921
|
+
} else {
|
|
922
|
+
fs.writeFileSync(profilePath, JSON.stringify(profile, null, 2), "utf8");
|
|
923
|
+
if (process.platform !== "win32") {
|
|
924
|
+
try { fs.chmodSync(profilePath, 0o600); } catch (chmodErr) {}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
491
927
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
492
928
|
res.end(JSON.stringify(profile));
|
|
493
929
|
} catch (e) {
|
|
@@ -498,6 +934,440 @@ function createServer(opts) {
|
|
|
498
934
|
return;
|
|
499
935
|
}
|
|
500
936
|
|
|
937
|
+
// --- Admin API endpoints (multi-user mode only) ---
|
|
938
|
+
|
|
939
|
+
// List all users (admin only)
|
|
940
|
+
if (req.method === "GET" && fullUrl === "/api/admin/users") {
|
|
941
|
+
if (!users.isMultiUser()) {
|
|
942
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
943
|
+
res.end('{"error":"Not found"}');
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
var mu = getMultiUserFromReq(req);
|
|
947
|
+
if (!mu || mu.role !== "admin") {
|
|
948
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
949
|
+
res.end('{"error":"Admin access required"}');
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
953
|
+
res.end(JSON.stringify({ users: users.getAllUsers() }));
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// Remove user (admin only)
|
|
958
|
+
if (req.method === "DELETE" && fullUrl.indexOf("/api/admin/users/") === 0) {
|
|
959
|
+
if (!users.isMultiUser()) {
|
|
960
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
961
|
+
res.end('{"error":"Not found"}');
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
var mu = getMultiUserFromReq(req);
|
|
965
|
+
if (!mu || mu.role !== "admin") {
|
|
966
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
967
|
+
res.end('{"error":"Admin access required"}');
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
var targetUserId = fullUrl.substring("/api/admin/users/".length);
|
|
971
|
+
if (targetUserId === mu.id) {
|
|
972
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
973
|
+
res.end('{"error":"Cannot remove yourself"}');
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
var result = users.removeUser(targetUserId);
|
|
977
|
+
if (result.error) {
|
|
978
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
979
|
+
res.end(JSON.stringify({ error: result.error }));
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
983
|
+
res.end('{"ok":true}');
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// Create invite (admin only)
|
|
988
|
+
if (req.method === "POST" && fullUrl === "/api/admin/invites") {
|
|
989
|
+
if (!users.isMultiUser()) {
|
|
990
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
991
|
+
res.end('{"error":"Not found"}');
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
var mu = getMultiUserFromReq(req);
|
|
995
|
+
if (!mu || mu.role !== "admin") {
|
|
996
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
997
|
+
res.end('{"error":"Admin access required"}');
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
var invite = users.createInvite(mu.id);
|
|
1001
|
+
var proto = tlsOptions ? "https" : "http";
|
|
1002
|
+
var host = req.headers.host || ("localhost:" + portNum);
|
|
1003
|
+
var inviteUrl = proto + "://" + host + "/invite/" + invite.code;
|
|
1004
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1005
|
+
res.end(JSON.stringify({ ok: true, invite: invite, url: inviteUrl }));
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// List invites (admin only)
|
|
1010
|
+
if (req.method === "GET" && fullUrl === "/api/admin/invites") {
|
|
1011
|
+
if (!users.isMultiUser()) {
|
|
1012
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1013
|
+
res.end('{"error":"Not found"}');
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
var mu = getMultiUserFromReq(req);
|
|
1017
|
+
if (!mu || mu.role !== "admin") {
|
|
1018
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1019
|
+
res.end('{"error":"Admin access required"}');
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1023
|
+
res.end(JSON.stringify({ invites: users.getInvites() }));
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// Revoke invite (admin only)
|
|
1028
|
+
if (req.method === "DELETE" && fullUrl.indexOf("/api/admin/invites/") === 0) {
|
|
1029
|
+
if (!users.isMultiUser()) {
|
|
1030
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1031
|
+
res.end('{"error":"Not found"}');
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
var mu = getMultiUserFromReq(req);
|
|
1035
|
+
if (!mu || mu.role !== "admin") {
|
|
1036
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1037
|
+
res.end('{"error":"Admin access required"}');
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
var inviteCode = decodeURIComponent(fullUrl.replace("/api/admin/invites/", ""));
|
|
1041
|
+
if (!inviteCode) {
|
|
1042
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1043
|
+
res.end('{"error":"Invite code is required"}');
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
var result = users.revokeInvite(inviteCode);
|
|
1047
|
+
if (result.error) {
|
|
1048
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1049
|
+
res.end(JSON.stringify({ error: result.error }));
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1053
|
+
res.end('{"ok":true}');
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// Send invite via email (admin only)
|
|
1058
|
+
if (req.method === "POST" && fullUrl === "/api/admin/invites/email") {
|
|
1059
|
+
if (!users.isMultiUser() || !smtp.isSmtpConfigured()) {
|
|
1060
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1061
|
+
res.end('{"error":"SMTP not configured"}');
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
var mu = getMultiUserFromReq(req);
|
|
1065
|
+
if (!mu || mu.role !== "admin") {
|
|
1066
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1067
|
+
res.end('{"error":"Admin access required"}');
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
var body = "";
|
|
1071
|
+
req.on("data", function (chunk) { body += chunk; });
|
|
1072
|
+
req.on("end", function () {
|
|
1073
|
+
try {
|
|
1074
|
+
var data = JSON.parse(body);
|
|
1075
|
+
if (!data.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
|
1076
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1077
|
+
res.end('{"error":"Valid email is required"}');
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
var invite = users.createInvite(mu.id, data.email);
|
|
1081
|
+
var proto = tlsOptions ? "https" : "http";
|
|
1082
|
+
var host = req.headers.host || ("localhost:" + portNum);
|
|
1083
|
+
var inviteUrl = proto + "://" + host + "/invite/" + invite.code;
|
|
1084
|
+
smtp.sendInviteEmail(data.email, inviteUrl, mu.displayName || mu.username).then(function () {
|
|
1085
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1086
|
+
res.end(JSON.stringify({ ok: true, invite: invite, url: inviteUrl }));
|
|
1087
|
+
}).catch(function (err) {
|
|
1088
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1089
|
+
res.end(JSON.stringify({ error: "Failed to send email: " + (err.message || "unknown error") }));
|
|
1090
|
+
});
|
|
1091
|
+
} catch (e) {
|
|
1092
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1093
|
+
res.end('{"error":"Invalid request"}');
|
|
1094
|
+
}
|
|
1095
|
+
});
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// Get SMTP config (admin only)
|
|
1100
|
+
if (req.method === "GET" && fullUrl === "/api/admin/smtp") {
|
|
1101
|
+
if (!users.isMultiUser()) {
|
|
1102
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1103
|
+
res.end('{"error":"Not found"}');
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
var mu = getMultiUserFromReq(req);
|
|
1107
|
+
if (!mu || mu.role !== "admin") {
|
|
1108
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1109
|
+
res.end('{"error":"Admin access required"}');
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
var cfg = smtp.getSmtpConfig();
|
|
1113
|
+
if (cfg) {
|
|
1114
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1115
|
+
res.end(JSON.stringify({ smtp: { host: cfg.host, port: cfg.port, secure: cfg.secure, user: cfg.user, pass: "••••••••", from: cfg.from, emailLoginEnabled: !!cfg.emailLoginEnabled } }));
|
|
1116
|
+
} else {
|
|
1117
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1118
|
+
res.end('{"smtp":null}');
|
|
1119
|
+
}
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// Save SMTP config (admin only)
|
|
1124
|
+
if (req.method === "POST" && fullUrl === "/api/admin/smtp") {
|
|
1125
|
+
if (!users.isMultiUser()) {
|
|
1126
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1127
|
+
res.end('{"error":"Not found"}');
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
var mu = getMultiUserFromReq(req);
|
|
1131
|
+
if (!mu || mu.role !== "admin") {
|
|
1132
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1133
|
+
res.end('{"error":"Admin access required"}');
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
var body = "";
|
|
1137
|
+
req.on("data", function (chunk) { body += chunk; });
|
|
1138
|
+
req.on("end", function () {
|
|
1139
|
+
try {
|
|
1140
|
+
var data = JSON.parse(body);
|
|
1141
|
+
// Allow clearing SMTP config by sending empty fields
|
|
1142
|
+
if (!data.host && !data.user && !data.pass && !data.from) {
|
|
1143
|
+
smtp.saveSmtpConfig(null);
|
|
1144
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1145
|
+
res.end('{"ok":true}');
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
if (!data.host || !data.user || !data.pass || !data.from) {
|
|
1149
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1150
|
+
res.end('{"error":"Host, user, password, and from address are required"}');
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
// If password is masked, keep existing
|
|
1154
|
+
var existingCfg = smtp.getSmtpConfig();
|
|
1155
|
+
var pass = data.pass;
|
|
1156
|
+
if (pass === "••••••••" && existingCfg) {
|
|
1157
|
+
pass = existingCfg.pass;
|
|
1158
|
+
}
|
|
1159
|
+
smtp.saveSmtpConfig({
|
|
1160
|
+
host: data.host,
|
|
1161
|
+
port: parseInt(data.port, 10) || 587,
|
|
1162
|
+
secure: !!data.secure,
|
|
1163
|
+
user: data.user,
|
|
1164
|
+
pass: pass,
|
|
1165
|
+
from: data.from,
|
|
1166
|
+
emailLoginEnabled: !!data.emailLoginEnabled,
|
|
1167
|
+
});
|
|
1168
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1169
|
+
res.end('{"ok":true}');
|
|
1170
|
+
} catch (e) {
|
|
1171
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1172
|
+
res.end('{"error":"Invalid request"}');
|
|
1173
|
+
}
|
|
1174
|
+
});
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// Test SMTP connection (admin only)
|
|
1179
|
+
if (req.method === "POST" && fullUrl === "/api/admin/smtp/test") {
|
|
1180
|
+
if (!users.isMultiUser()) {
|
|
1181
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1182
|
+
res.end('{"error":"Not found"}');
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
var mu = getMultiUserFromReq(req);
|
|
1186
|
+
if (!mu || mu.role !== "admin") {
|
|
1187
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1188
|
+
res.end('{"error":"Admin access required"}');
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
var body = "";
|
|
1192
|
+
req.on("data", function (chunk) { body += chunk; });
|
|
1193
|
+
req.on("end", function () {
|
|
1194
|
+
try {
|
|
1195
|
+
var data = JSON.parse(body);
|
|
1196
|
+
// Use provided config or fall back to saved
|
|
1197
|
+
var existingCfg = smtp.getSmtpConfig();
|
|
1198
|
+
var pass = data.pass;
|
|
1199
|
+
if (pass === "••••••••" && existingCfg) {
|
|
1200
|
+
pass = existingCfg.pass;
|
|
1201
|
+
}
|
|
1202
|
+
var cfg = {
|
|
1203
|
+
host: data.host || (existingCfg && existingCfg.host),
|
|
1204
|
+
port: parseInt(data.port, 10) || (existingCfg && existingCfg.port) || 587,
|
|
1205
|
+
secure: data.secure !== undefined ? !!data.secure : (existingCfg && !!existingCfg.secure),
|
|
1206
|
+
user: data.user || (existingCfg && existingCfg.user),
|
|
1207
|
+
pass: pass || (existingCfg && existingCfg.pass),
|
|
1208
|
+
from: data.from || (existingCfg && existingCfg.from),
|
|
1209
|
+
};
|
|
1210
|
+
if (!cfg.host || !cfg.user || !cfg.pass || !cfg.from) {
|
|
1211
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1212
|
+
res.end('{"error":"SMTP configuration is incomplete"}');
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
var testTo = mu.email || cfg.from;
|
|
1216
|
+
smtp.sendTestEmail(cfg, testTo).then(function (result) {
|
|
1217
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1218
|
+
res.end(JSON.stringify({ ok: true, message: "Test email sent to " + testTo }));
|
|
1219
|
+
}).catch(function (err) {
|
|
1220
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1221
|
+
res.end(JSON.stringify({ ok: false, error: err.message || "Connection failed" }));
|
|
1222
|
+
});
|
|
1223
|
+
} catch (e) {
|
|
1224
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1225
|
+
res.end('{"error":"Invalid request"}');
|
|
1226
|
+
}
|
|
1227
|
+
});
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// --- Project access control (admin only, multi-user) ---
|
|
1232
|
+
|
|
1233
|
+
// Set project visibility (admin only)
|
|
1234
|
+
if (req.method === "PUT" && /^\/api\/admin\/projects\/[a-z0-9_-]+\/visibility$/.test(fullUrl)) {
|
|
1235
|
+
if (!users.isMultiUser()) {
|
|
1236
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1237
|
+
res.end('{"error":"Not found"}');
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
var mu = getMultiUserFromReq(req);
|
|
1241
|
+
if (!mu || mu.role !== "admin") {
|
|
1242
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1243
|
+
res.end('{"error":"Admin access required"}');
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
var projSlug = fullUrl.split("/")[4];
|
|
1247
|
+
var body = "";
|
|
1248
|
+
req.on("data", function (chunk) { body += chunk; });
|
|
1249
|
+
req.on("end", function () {
|
|
1250
|
+
try {
|
|
1251
|
+
var data = JSON.parse(body);
|
|
1252
|
+
if (data.visibility !== "public" && data.visibility !== "private") {
|
|
1253
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1254
|
+
res.end('{"error":"Visibility must be public or private"}');
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
if (!onSetProjectVisibility) {
|
|
1258
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1259
|
+
res.end('{"error":"Visibility handler not configured"}');
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
var result = onSetProjectVisibility(projSlug, data.visibility);
|
|
1263
|
+
if (result && result.error) {
|
|
1264
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1265
|
+
res.end(JSON.stringify({ error: result.error }));
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1269
|
+
res.end('{"ok":true}');
|
|
1270
|
+
} catch (e) {
|
|
1271
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1272
|
+
res.end('{"error":"Invalid request"}');
|
|
1273
|
+
}
|
|
1274
|
+
});
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// Set project allowed users (admin only)
|
|
1279
|
+
if (req.method === "PUT" && /^\/api\/admin\/projects\/[a-z0-9_-]+\/users$/.test(fullUrl)) {
|
|
1280
|
+
if (!users.isMultiUser()) {
|
|
1281
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1282
|
+
res.end('{"error":"Not found"}');
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
var mu = getMultiUserFromReq(req);
|
|
1286
|
+
if (!mu || mu.role !== "admin") {
|
|
1287
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1288
|
+
res.end('{"error":"Admin access required"}');
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
var projSlug = fullUrl.split("/")[4];
|
|
1292
|
+
var body = "";
|
|
1293
|
+
req.on("data", function (chunk) { body += chunk; });
|
|
1294
|
+
req.on("end", function () {
|
|
1295
|
+
try {
|
|
1296
|
+
var data = JSON.parse(body);
|
|
1297
|
+
if (!Array.isArray(data.allowedUsers)) {
|
|
1298
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1299
|
+
res.end('{"error":"allowedUsers must be an array"}');
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
if (!onSetProjectAllowedUsers) {
|
|
1303
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1304
|
+
res.end('{"error":"AllowedUsers handler not configured"}');
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
var result = onSetProjectAllowedUsers(projSlug, data.allowedUsers);
|
|
1308
|
+
if (result && result.error) {
|
|
1309
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1310
|
+
res.end(JSON.stringify({ error: result.error }));
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1314
|
+
res.end('{"ok":true}');
|
|
1315
|
+
} catch (e) {
|
|
1316
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1317
|
+
res.end('{"error":"Invalid request"}');
|
|
1318
|
+
}
|
|
1319
|
+
});
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// Get project access info (admin only)
|
|
1324
|
+
if (req.method === "GET" && /^\/api\/admin\/projects\/[a-z0-9_-]+\/access$/.test(fullUrl)) {
|
|
1325
|
+
if (!users.isMultiUser()) {
|
|
1326
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1327
|
+
res.end('{"error":"Not found"}');
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
var mu = getMultiUserFromReq(req);
|
|
1331
|
+
if (!mu || mu.role !== "admin") {
|
|
1332
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1333
|
+
res.end('{"error":"Admin access required"}');
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
var projSlug = fullUrl.split("/")[4];
|
|
1337
|
+
if (!onGetProjectAccess) {
|
|
1338
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1339
|
+
res.end('{"error":"Access handler not configured"}');
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
var access = onGetProjectAccess(projSlug);
|
|
1343
|
+
if (access && access.error) {
|
|
1344
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1345
|
+
res.end(JSON.stringify({ error: access.error }));
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1349
|
+
res.end(JSON.stringify(access));
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// Multi-user info endpoint (who am I?)
|
|
1354
|
+
if (req.method === "GET" && fullUrl === "/api/me") {
|
|
1355
|
+
if (!users.isMultiUser()) {
|
|
1356
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1357
|
+
res.end('{"multiUser":false}');
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
var mu = getMultiUserFromReq(req);
|
|
1361
|
+
if (!mu) {
|
|
1362
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1363
|
+
res.end('{"error":"unauthorized"}');
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1367
|
+
res.end(JSON.stringify({ multiUser: true, smtpEnabled: smtp.isSmtpConfigured(), emailLoginEnabled: smtp.isEmailLoginEnabled(), user: { id: mu.id, username: mu.username, email: mu.email || null, displayName: mu.displayName, role: mu.role } }));
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
501
1371
|
// Skills proxy: leaderboard list
|
|
502
1372
|
if (req.method === "GET" && fullUrl === "/api/skills") {
|
|
503
1373
|
var qs = req.url.indexOf("?") >= 0 ? req.url.substring(req.url.indexOf("?")) : "";
|
|
@@ -593,17 +1463,37 @@ function createServer(opts) {
|
|
|
593
1463
|
return;
|
|
594
1464
|
}
|
|
595
1465
|
|
|
596
|
-
// Root path — redirect to first project
|
|
1466
|
+
// Root path — redirect to first accessible project
|
|
597
1467
|
if (fullUrl === "/" && req.method === "GET") {
|
|
598
|
-
if (!
|
|
1468
|
+
if (!isRequestAuthed(req)) {
|
|
599
1469
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
600
|
-
res.end(
|
|
1470
|
+
res.end(getAuthPage());
|
|
601
1471
|
return;
|
|
602
1472
|
}
|
|
603
1473
|
if (projects.size > 0) {
|
|
604
|
-
var
|
|
605
|
-
|
|
606
|
-
|
|
1474
|
+
var targetSlug = null;
|
|
1475
|
+
var reqUser = users.isMultiUser() ? getMultiUserFromReq(req) : null;
|
|
1476
|
+
projects.forEach(function (ctx, s) {
|
|
1477
|
+
if (targetSlug) return;
|
|
1478
|
+
if (reqUser && onGetProjectAccess) {
|
|
1479
|
+
var access = onGetProjectAccess(s);
|
|
1480
|
+
if (access && !access.error && users.canAccessProject(reqUser.id, access)) {
|
|
1481
|
+
targetSlug = s;
|
|
1482
|
+
}
|
|
1483
|
+
} else {
|
|
1484
|
+
targetSlug = s;
|
|
1485
|
+
}
|
|
1486
|
+
});
|
|
1487
|
+
if (targetSlug) {
|
|
1488
|
+
res.writeHead(302, { "Location": "/p/" + targetSlug + "/" });
|
|
1489
|
+
res.end();
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
// No accessible projects — show info page
|
|
1494
|
+
if (users.isMultiUser()) {
|
|
1495
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
1496
|
+
res.end(noProjectsPageHtml());
|
|
607
1497
|
return;
|
|
608
1498
|
}
|
|
609
1499
|
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
@@ -611,21 +1501,22 @@ function createServer(opts) {
|
|
|
611
1501
|
return;
|
|
612
1502
|
}
|
|
613
1503
|
|
|
614
|
-
// Global info endpoint (
|
|
1504
|
+
// Global info endpoint (projects only for authenticated requests)
|
|
615
1505
|
if (req.method === "GET" && req.url === "/info") {
|
|
616
|
-
if (!
|
|
1506
|
+
if (!isRequestAuthed(req)) {
|
|
617
1507
|
res.writeHead(401, {
|
|
618
1508
|
"Content-Type": "application/json",
|
|
619
1509
|
"Access-Control-Allow-Origin": "*",
|
|
620
1510
|
});
|
|
621
|
-
res.end(
|
|
1511
|
+
res.end(JSON.stringify({ version: currentVersion, authenticated: false }));
|
|
622
1512
|
return;
|
|
623
1513
|
}
|
|
624
1514
|
var projectList = [];
|
|
625
1515
|
projects.forEach(function (ctx, slug) {
|
|
626
1516
|
projectList.push({ slug: slug, project: ctx.project });
|
|
627
1517
|
});
|
|
628
|
-
res.
|
|
1518
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1519
|
+
res.end(JSON.stringify({ projects: projectList, version: currentVersion, authenticated: true }));
|
|
629
1520
|
return;
|
|
630
1521
|
}
|
|
631
1522
|
|
|
@@ -658,12 +1549,25 @@ function createServer(opts) {
|
|
|
658
1549
|
}
|
|
659
1550
|
|
|
660
1551
|
// Auth check for project routes
|
|
661
|
-
if (!
|
|
1552
|
+
if (!isRequestAuthed(req)) {
|
|
662
1553
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
663
|
-
res.end(
|
|
1554
|
+
res.end(getAuthPage());
|
|
664
1555
|
return;
|
|
665
1556
|
}
|
|
666
1557
|
|
|
1558
|
+
// Multi-user: check project access for HTTP requests
|
|
1559
|
+
if (users.isMultiUser() && onGetProjectAccess) {
|
|
1560
|
+
var httpUser = getMultiUserFromReq(req);
|
|
1561
|
+
if (httpUser) {
|
|
1562
|
+
var httpAccess = onGetProjectAccess(slug);
|
|
1563
|
+
if (httpAccess && !httpAccess.error && !users.canAccessProject(httpUser.id, httpAccess)) {
|
|
1564
|
+
res.writeHead(302, { "Location": "/" });
|
|
1565
|
+
res.end();
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
667
1571
|
// Strip prefix for project-scoped handling
|
|
668
1572
|
var projectUrl = stripPrefix(req.url.split("?")[0], slug);
|
|
669
1573
|
// Re-attach query string for API routes
|
|
@@ -777,7 +1681,7 @@ function createServer(opts) {
|
|
|
777
1681
|
}
|
|
778
1682
|
}
|
|
779
1683
|
|
|
780
|
-
if (!
|
|
1684
|
+
if (!isRequestAuthed(req)) {
|
|
781
1685
|
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
782
1686
|
socket.destroy();
|
|
783
1687
|
return;
|
|
@@ -796,8 +1700,49 @@ function createServer(opts) {
|
|
|
796
1700
|
return;
|
|
797
1701
|
}
|
|
798
1702
|
|
|
1703
|
+
// Attach user info to the WS connection for multi-user filtering
|
|
1704
|
+
var wsUser = null;
|
|
1705
|
+
if (users.isMultiUser()) {
|
|
1706
|
+
wsUser = getMultiUserFromReq(req);
|
|
1707
|
+
// Check project access for multi-user mode
|
|
1708
|
+
if (wsUser && onGetProjectAccess) {
|
|
1709
|
+
var projectAccess = onGetProjectAccess(wsSlug);
|
|
1710
|
+
if (projectAccess && !projectAccess.error) {
|
|
1711
|
+
if (!users.canAccessProject(wsUser.id, projectAccess)) {
|
|
1712
|
+
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
1713
|
+
socket.destroy();
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
|
|
799
1720
|
wss.handleUpgrade(req, socket, head, function (ws) {
|
|
800
|
-
|
|
1721
|
+
// Apply rate limiting to WS messages
|
|
1722
|
+
var msgCount = 0;
|
|
1723
|
+
var msgWindowStart = Date.now();
|
|
1724
|
+
var WS_RATE_LIMIT = 60; // messages per second
|
|
1725
|
+
var origEmit = ws.emit;
|
|
1726
|
+
ws.emit = function (event) {
|
|
1727
|
+
if (event === "message") {
|
|
1728
|
+
var now = Date.now();
|
|
1729
|
+
if (now - msgWindowStart >= 1000) {
|
|
1730
|
+
msgCount = 0;
|
|
1731
|
+
msgWindowStart = now;
|
|
1732
|
+
}
|
|
1733
|
+
msgCount++;
|
|
1734
|
+
if (msgCount > WS_RATE_LIMIT) {
|
|
1735
|
+
try {
|
|
1736
|
+
ws.send(JSON.stringify({ type: "error", message: "Rate limit exceeded. Connection will be closed." }));
|
|
1737
|
+
ws.close(1008, "Rate limit exceeded");
|
|
1738
|
+
} catch (e) {}
|
|
1739
|
+
return false;
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
return origEmit.apply(ws, arguments);
|
|
1743
|
+
};
|
|
1744
|
+
ws._clayUser = wsUser; // attach user context
|
|
1745
|
+
ctx.handleConnection(ws, wsUser);
|
|
801
1746
|
});
|
|
802
1747
|
});
|
|
803
1748
|
|
|
@@ -807,11 +1752,34 @@ function createServer(opts) {
|
|
|
807
1752
|
if (processingUpdateTimer) clearTimeout(processingUpdateTimer);
|
|
808
1753
|
processingUpdateTimer = setTimeout(function () {
|
|
809
1754
|
processingUpdateTimer = null;
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
1755
|
+
if (users.isMultiUser() && onGetProjectAccess) {
|
|
1756
|
+
// Per-client filtered project list
|
|
1757
|
+
var allProjectsList = getProjects();
|
|
1758
|
+
projects.forEach(function (ctx) {
|
|
1759
|
+
ctx.forEachClient(function (ws) {
|
|
1760
|
+
var wsUser = ws._clayUser;
|
|
1761
|
+
var filtered = allProjectsList;
|
|
1762
|
+
if (wsUser) {
|
|
1763
|
+
filtered = allProjectsList.filter(function (p) {
|
|
1764
|
+
var access = onGetProjectAccess(p.slug);
|
|
1765
|
+
if (!access || access.error) return true;
|
|
1766
|
+
return users.canAccessProject(wsUser.id, access);
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
ws.send(JSON.stringify({
|
|
1770
|
+
type: "projects_updated",
|
|
1771
|
+
projects: filtered,
|
|
1772
|
+
projectCount: filtered.length,
|
|
1773
|
+
}));
|
|
1774
|
+
});
|
|
1775
|
+
});
|
|
1776
|
+
} else {
|
|
1777
|
+
broadcastAll({
|
|
1778
|
+
type: "projects_updated",
|
|
1779
|
+
projects: getProjects(),
|
|
1780
|
+
projectCount: projects.size,
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
815
1783
|
}, 200);
|
|
816
1784
|
}
|
|
817
1785
|
|
|
@@ -829,9 +1797,16 @@ function createServer(opts) {
|
|
|
829
1797
|
currentVersion: currentVersion,
|
|
830
1798
|
lanHost: lanHost,
|
|
831
1799
|
getProjectCount: function () { return projects.size; },
|
|
832
|
-
getProjectList: function () {
|
|
1800
|
+
getProjectList: function (userId) {
|
|
833
1801
|
var list = [];
|
|
834
|
-
projects.forEach(function (ctx) {
|
|
1802
|
+
projects.forEach(function (ctx, s) {
|
|
1803
|
+
var status = ctx.getStatus();
|
|
1804
|
+
if (userId && users.isMultiUser() && onGetProjectAccess) {
|
|
1805
|
+
var access = onGetProjectAccess(s);
|
|
1806
|
+
if (access && !access.error && !users.canAccessProject(userId, access)) return;
|
|
1807
|
+
}
|
|
1808
|
+
list.push(status);
|
|
1809
|
+
});
|
|
835
1810
|
return list;
|
|
836
1811
|
},
|
|
837
1812
|
getHubSchedules: function () {
|
|
@@ -896,6 +1871,7 @@ function createServer(opts) {
|
|
|
896
1871
|
if (!ctx) return 0;
|
|
897
1872
|
return ctx.getSchedules().length;
|
|
898
1873
|
},
|
|
1874
|
+
onPresenceChange: broadcastPresenceChange,
|
|
899
1875
|
onProcessingChanged: broadcastProcessingChange,
|
|
900
1876
|
onAddProject: onAddProject,
|
|
901
1877
|
onRemoveProject: onRemoveProject,
|
|
@@ -974,6 +1950,78 @@ function createServer(opts) {
|
|
|
974
1950
|
authToken = hash;
|
|
975
1951
|
}
|
|
976
1952
|
|
|
1953
|
+
// Collect all unique users across all projects (for topbar server-wide presence)
|
|
1954
|
+
function getServerUsers() {
|
|
1955
|
+
var seen = {};
|
|
1956
|
+
var list = [];
|
|
1957
|
+
projects.forEach(function (ctx) {
|
|
1958
|
+
ctx.forEachClient(function (ws) {
|
|
1959
|
+
if (!ws._clayUser) return;
|
|
1960
|
+
var u = ws._clayUser;
|
|
1961
|
+
if (seen[u.id]) return;
|
|
1962
|
+
seen[u.id] = true;
|
|
1963
|
+
var p = u.profile || {};
|
|
1964
|
+
list.push({
|
|
1965
|
+
id: u.id,
|
|
1966
|
+
displayName: p.name || u.displayName || u.username,
|
|
1967
|
+
username: u.username,
|
|
1968
|
+
avatarStyle: p.avatarStyle || "thumbs",
|
|
1969
|
+
avatarSeed: p.avatarSeed || u.username,
|
|
1970
|
+
});
|
|
1971
|
+
});
|
|
1972
|
+
});
|
|
1973
|
+
return list;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
// Debounced broadcast of projects_updated when presence changes
|
|
1977
|
+
// Sends per-user filtered project lists + server-wide user list
|
|
1978
|
+
var presenceTimer = null;
|
|
1979
|
+
function broadcastPresenceChange() {
|
|
1980
|
+
if (presenceTimer) return;
|
|
1981
|
+
presenceTimer = setTimeout(function () {
|
|
1982
|
+
presenceTimer = null;
|
|
1983
|
+
if (!users.isMultiUser()) {
|
|
1984
|
+
broadcastAll({
|
|
1985
|
+
type: "projects_updated",
|
|
1986
|
+
projects: getProjects(),
|
|
1987
|
+
projectCount: projects.size,
|
|
1988
|
+
});
|
|
1989
|
+
return;
|
|
1990
|
+
}
|
|
1991
|
+
var serverUsers = getServerUsers();
|
|
1992
|
+
// Build per-user filtered lists, send individually
|
|
1993
|
+
var sentUsers = {};
|
|
1994
|
+
projects.forEach(function (ctx) {
|
|
1995
|
+
ctx.forEachClient(function (ws) {
|
|
1996
|
+
var userId = ws._clayUser ? ws._clayUser.id : null;
|
|
1997
|
+
var key = userId || "__anon__";
|
|
1998
|
+
if (sentUsers[key]) {
|
|
1999
|
+
// Already computed for this user, just send the cached msg
|
|
2000
|
+
ws.send(sentUsers[key]);
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
var filteredProjects = [];
|
|
2004
|
+
projects.forEach(function (pCtx, s) {
|
|
2005
|
+
var status = pCtx.getStatus();
|
|
2006
|
+
if (userId && onGetProjectAccess) {
|
|
2007
|
+
var access = onGetProjectAccess(s);
|
|
2008
|
+
if (access && !access.error && !users.canAccessProject(userId, access)) return;
|
|
2009
|
+
}
|
|
2010
|
+
filteredProjects.push(status);
|
|
2011
|
+
});
|
|
2012
|
+
var msgStr = JSON.stringify({
|
|
2013
|
+
type: "projects_updated",
|
|
2014
|
+
projects: filteredProjects,
|
|
2015
|
+
projectCount: projects.size,
|
|
2016
|
+
serverUsers: serverUsers,
|
|
2017
|
+
});
|
|
2018
|
+
sentUsers[key] = msgStr;
|
|
2019
|
+
ws.send(msgStr);
|
|
2020
|
+
});
|
|
2021
|
+
});
|
|
2022
|
+
}, 300);
|
|
2023
|
+
}
|
|
2024
|
+
|
|
977
2025
|
function broadcastAll(msg) {
|
|
978
2026
|
projects.forEach(function (ctx) {
|
|
979
2027
|
ctx.send(msg);
|
|
@@ -1004,4 +2052,4 @@ function createServer(opts) {
|
|
|
1004
2052
|
};
|
|
1005
2053
|
}
|
|
1006
2054
|
|
|
1007
|
-
module.exports = { createServer: createServer, generateAuthToken: generateAuthToken };
|
|
2055
|
+
module.exports = { createServer: createServer, generateAuthToken: generateAuthToken, verifyPin: verifyPin };
|