clay-server 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +281 -0
- package/bin/cli.js +2385 -0
- package/lib/cli-sessions.js +270 -0
- package/lib/config.js +237 -0
- package/lib/daemon.js +489 -0
- package/lib/ipc.js +112 -0
- package/lib/notes.js +120 -0
- package/lib/pages.js +664 -0
- package/lib/project.js +1433 -0
- package/lib/public/app.js +2795 -0
- package/lib/public/apple-touch-icon-dark.png +0 -0
- package/lib/public/apple-touch-icon.png +0 -0
- package/lib/public/css/base.css +264 -0
- package/lib/public/css/diff.css +128 -0
- package/lib/public/css/filebrowser.css +1114 -0
- package/lib/public/css/highlight.css +144 -0
- package/lib/public/css/icon-strip.css +296 -0
- package/lib/public/css/input.css +573 -0
- package/lib/public/css/menus.css +856 -0
- package/lib/public/css/messages.css +1445 -0
- package/lib/public/css/mobile-nav.css +354 -0
- package/lib/public/css/overlays.css +697 -0
- package/lib/public/css/rewind.css +505 -0
- package/lib/public/css/server-settings.css +761 -0
- package/lib/public/css/sidebar.css +936 -0
- package/lib/public/css/sticky-notes.css +358 -0
- package/lib/public/css/title-bar.css +314 -0
- package/lib/public/favicon-dark.svg +1 -0
- package/lib/public/favicon.svg +1 -0
- package/lib/public/icon-192-dark.png +0 -0
- package/lib/public/icon-192.png +0 -0
- package/lib/public/icon-512-dark.png +0 -0
- package/lib/public/icon-512.png +0 -0
- package/lib/public/icon-mono.svg +1 -0
- package/lib/public/index.html +762 -0
- package/lib/public/manifest.json +27 -0
- package/lib/public/modules/diff.js +398 -0
- package/lib/public/modules/events.js +21 -0
- package/lib/public/modules/filebrowser.js +1411 -0
- package/lib/public/modules/fileicons.js +172 -0
- package/lib/public/modules/icons.js +54 -0
- package/lib/public/modules/input.js +584 -0
- package/lib/public/modules/markdown.js +356 -0
- package/lib/public/modules/notifications.js +649 -0
- package/lib/public/modules/qrcode.js +70 -0
- package/lib/public/modules/rewind.js +345 -0
- package/lib/public/modules/server-settings.js +510 -0
- package/lib/public/modules/sidebar.js +1083 -0
- package/lib/public/modules/state.js +3 -0
- package/lib/public/modules/sticky-notes.js +688 -0
- package/lib/public/modules/terminal.js +697 -0
- package/lib/public/modules/theme.js +738 -0
- package/lib/public/modules/tools.js +1608 -0
- package/lib/public/modules/utils.js +56 -0
- package/lib/public/style.css +15 -0
- package/lib/public/sw.js +75 -0
- package/lib/push.js +124 -0
- package/lib/sdk-bridge.js +989 -0
- package/lib/server.js +582 -0
- package/lib/sessions.js +424 -0
- package/lib/terminal-manager.js +187 -0
- package/lib/terminal.js +24 -0
- package/lib/themes/ayu-light.json +9 -0
- package/lib/themes/catppuccin-latte.json +9 -0
- package/lib/themes/catppuccin-mocha.json +9 -0
- package/lib/themes/clay-light.json +10 -0
- package/lib/themes/clay.json +10 -0
- package/lib/themes/dracula.json +9 -0
- package/lib/themes/everforest-light.json +9 -0
- package/lib/themes/everforest.json +9 -0
- package/lib/themes/github-light.json +9 -0
- package/lib/themes/gruvbox-dark.json +9 -0
- package/lib/themes/gruvbox-light.json +9 -0
- package/lib/themes/monokai.json +9 -0
- package/lib/themes/nord-light.json +9 -0
- package/lib/themes/nord.json +9 -0
- package/lib/themes/one-dark.json +9 -0
- package/lib/themes/one-light.json +9 -0
- package/lib/themes/rose-pine-dawn.json +9 -0
- package/lib/themes/rose-pine.json +9 -0
- package/lib/themes/solarized-dark.json +9 -0
- package/lib/themes/solarized-light.json +9 -0
- package/lib/themes/tokyo-night-light.json +9 -0
- package/lib/themes/tokyo-night.json +9 -0
- package/lib/updater.js +97 -0
- package/package.json +47 -0
package/lib/server.js
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
var http = require("http");
|
|
2
|
+
var crypto = require("crypto");
|
|
3
|
+
var fs = require("fs");
|
|
4
|
+
var path = require("path");
|
|
5
|
+
var { WebSocketServer } = require("ws");
|
|
6
|
+
var { pinPageHtml, setupPageHtml } = require("./pages");
|
|
7
|
+
var { createProjectContext } = require("./project");
|
|
8
|
+
|
|
9
|
+
var { CONFIG_DIR } = require("./config");
|
|
10
|
+
|
|
11
|
+
var publicDir = path.join(__dirname, "public");
|
|
12
|
+
var bundledThemesDir = path.join(__dirname, "themes");
|
|
13
|
+
var userThemesDir = path.join(CONFIG_DIR, "themes");
|
|
14
|
+
|
|
15
|
+
var MIME_TYPES = {
|
|
16
|
+
".html": "text/html",
|
|
17
|
+
".css": "text/css",
|
|
18
|
+
".js": "application/javascript",
|
|
19
|
+
".json": "application/json",
|
|
20
|
+
".png": "image/png",
|
|
21
|
+
".jpg": "image/jpeg",
|
|
22
|
+
".jpeg": "image/jpeg",
|
|
23
|
+
".gif": "image/gif",
|
|
24
|
+
".webp": "image/webp",
|
|
25
|
+
".bmp": "image/bmp",
|
|
26
|
+
".svg": "image/svg+xml",
|
|
27
|
+
".ico": "image/x-icon",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function generateAuthToken(pin) {
|
|
31
|
+
return crypto.createHash("sha256").update("clay:" + pin).digest("hex");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseCookies(req) {
|
|
35
|
+
var cookies = {};
|
|
36
|
+
var header = req.headers.cookie || "";
|
|
37
|
+
header.split(";").forEach(function (part) {
|
|
38
|
+
var pair = part.trim().split("=");
|
|
39
|
+
if (pair.length === 2) cookies[pair[0]] = pair[1];
|
|
40
|
+
});
|
|
41
|
+
return cookies;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isAuthed(req, authToken) {
|
|
45
|
+
if (!authToken) return true;
|
|
46
|
+
var cookies = parseCookies(req);
|
|
47
|
+
return cookies["relay_auth"] === authToken;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- PIN rate limiting ---
|
|
51
|
+
var pinAttempts = {}; // ip → { count, lastAttempt }
|
|
52
|
+
var PIN_MAX_ATTEMPTS = 5;
|
|
53
|
+
var PIN_LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
|
|
54
|
+
|
|
55
|
+
function checkPinRateLimit(ip) {
|
|
56
|
+
var entry = pinAttempts[ip];
|
|
57
|
+
if (!entry) return null;
|
|
58
|
+
if (entry.count >= PIN_MAX_ATTEMPTS) {
|
|
59
|
+
var elapsed = Date.now() - entry.lastAttempt;
|
|
60
|
+
if (elapsed < PIN_LOCKOUT_MS) {
|
|
61
|
+
return Math.ceil((PIN_LOCKOUT_MS - elapsed) / 1000);
|
|
62
|
+
}
|
|
63
|
+
delete pinAttempts[ip];
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function recordPinFailure(ip) {
|
|
69
|
+
if (!pinAttempts[ip]) pinAttempts[ip] = { count: 0, lastAttempt: 0 };
|
|
70
|
+
pinAttempts[ip].count++;
|
|
71
|
+
pinAttempts[ip].lastAttempt = Date.now();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function clearPinFailures(ip) {
|
|
75
|
+
delete pinAttempts[ip];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function serveStatic(urlPath, res) {
|
|
79
|
+
if (urlPath === "/") urlPath = "/index.html";
|
|
80
|
+
|
|
81
|
+
var filePath = path.join(publicDir, urlPath);
|
|
82
|
+
|
|
83
|
+
if (!filePath.startsWith(publicDir)) {
|
|
84
|
+
res.writeHead(403);
|
|
85
|
+
res.end("Forbidden");
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
var content = fs.readFileSync(filePath);
|
|
91
|
+
var ext = path.extname(filePath);
|
|
92
|
+
var mime = MIME_TYPES[ext] || "application/octet-stream";
|
|
93
|
+
res.writeHead(200, { "Content-Type": mime + "; charset=utf-8", "Cache-Control": "no-cache" });
|
|
94
|
+
res.end(content);
|
|
95
|
+
return true;
|
|
96
|
+
} catch (e) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Extract slug from URL path: /p/{slug}/... → slug
|
|
103
|
+
* Returns null if path doesn't match /p/{slug}
|
|
104
|
+
*/
|
|
105
|
+
function extractSlug(urlPath) {
|
|
106
|
+
var match = urlPath.match(/^\/p\/([a-z0-9_-]+)(\/|$)/);
|
|
107
|
+
return match ? match[1] : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Strip the /p/{slug} prefix from URL path
|
|
112
|
+
*/
|
|
113
|
+
function stripPrefix(urlPath, slug) {
|
|
114
|
+
var prefix = "/p/" + slug;
|
|
115
|
+
var rest = urlPath.substring(prefix.length);
|
|
116
|
+
return rest || "/";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Create a multi-project server.
|
|
121
|
+
* opts: { tlsOptions, caPath, pinHash, port, debug, dangerouslySkipPermissions }
|
|
122
|
+
*/
|
|
123
|
+
function createServer(opts) {
|
|
124
|
+
var tlsOptions = opts.tlsOptions || null;
|
|
125
|
+
var caPath = opts.caPath || null;
|
|
126
|
+
var pinHash = opts.pinHash || null;
|
|
127
|
+
var portNum = opts.port || 2633;
|
|
128
|
+
var debug = opts.debug || false;
|
|
129
|
+
var dangerouslySkipPermissions = opts.dangerouslySkipPermissions || false;
|
|
130
|
+
var lanHost = opts.lanHost || null;
|
|
131
|
+
var onAddProject = opts.onAddProject || null;
|
|
132
|
+
var onRemoveProject = opts.onRemoveProject || null;
|
|
133
|
+
var onGetDaemonConfig = opts.onGetDaemonConfig || null;
|
|
134
|
+
var onSetPin = opts.onSetPin || null;
|
|
135
|
+
var onSetKeepAwake = opts.onSetKeepAwake || null;
|
|
136
|
+
var onShutdown = opts.onShutdown || null;
|
|
137
|
+
|
|
138
|
+
var authToken = pinHash || null;
|
|
139
|
+
var realVersion = require("../package.json").version;
|
|
140
|
+
var currentVersion = debug ? "0.0.9" : realVersion;
|
|
141
|
+
|
|
142
|
+
var caContent = caPath ? (function () { try { return fs.readFileSync(caPath); } catch (e) { return null; } })() : null;
|
|
143
|
+
var pinPage = pinPageHtml();
|
|
144
|
+
|
|
145
|
+
// --- Project registry ---
|
|
146
|
+
var projects = new Map(); // slug → projectContext
|
|
147
|
+
|
|
148
|
+
// --- Push module (global) ---
|
|
149
|
+
var pushModule = null;
|
|
150
|
+
try {
|
|
151
|
+
var { initPush } = require("./push");
|
|
152
|
+
pushModule = initPush();
|
|
153
|
+
} catch (e) {}
|
|
154
|
+
|
|
155
|
+
// --- HTTP handler ---
|
|
156
|
+
var appHandler = function (req, res) {
|
|
157
|
+
var fullUrl = req.url.split("?")[0];
|
|
158
|
+
|
|
159
|
+
// Global auth endpoint
|
|
160
|
+
if (req.method === "POST" && req.url === "/auth") {
|
|
161
|
+
var ip = req.socket.remoteAddress || "";
|
|
162
|
+
var remaining = checkPinRateLimit(ip);
|
|
163
|
+
if (remaining !== null) {
|
|
164
|
+
res.writeHead(429, { "Content-Type": "application/json" });
|
|
165
|
+
res.end(JSON.stringify({ ok: false, locked: true, retryAfter: remaining }));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
var body = "";
|
|
169
|
+
req.on("data", function (chunk) { body += chunk; });
|
|
170
|
+
req.on("end", function () {
|
|
171
|
+
try {
|
|
172
|
+
var data = JSON.parse(body);
|
|
173
|
+
if (authToken && generateAuthToken(data.pin) === authToken) {
|
|
174
|
+
clearPinFailures(ip);
|
|
175
|
+
res.writeHead(200, {
|
|
176
|
+
"Set-Cookie": "relay_auth=" + authToken + "; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000" + (tlsOptions ? "; Secure" : ""),
|
|
177
|
+
"Content-Type": "application/json",
|
|
178
|
+
});
|
|
179
|
+
res.end('{"ok":true}');
|
|
180
|
+
} else {
|
|
181
|
+
recordPinFailure(ip);
|
|
182
|
+
var attemptsLeft = PIN_MAX_ATTEMPTS - (pinAttempts[ip] ? pinAttempts[ip].count : 0);
|
|
183
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
184
|
+
res.end(JSON.stringify({ ok: false, attemptsLeft: Math.max(attemptsLeft, 0) }));
|
|
185
|
+
}
|
|
186
|
+
} catch (e) {
|
|
187
|
+
res.writeHead(400);
|
|
188
|
+
res.end("Bad request");
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// CA certificate download
|
|
195
|
+
if (req.url === "/ca/download" && req.method === "GET" && caContent) {
|
|
196
|
+
res.writeHead(200, {
|
|
197
|
+
"Content-Type": "application/x-pem-file",
|
|
198
|
+
"Content-Disposition": 'attachment; filename="clay-ca.pem"',
|
|
199
|
+
});
|
|
200
|
+
res.end(caContent);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// CORS preflight for cross-origin requests (HTTP onboarding → HTTPS)
|
|
205
|
+
if (req.method === "OPTIONS") {
|
|
206
|
+
res.writeHead(204, {
|
|
207
|
+
"Access-Control-Allow-Origin": "*",
|
|
208
|
+
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
209
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
210
|
+
"Access-Control-Max-Age": "86400",
|
|
211
|
+
});
|
|
212
|
+
res.end();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Setup page
|
|
217
|
+
if (fullUrl === "/setup" && req.method === "GET") {
|
|
218
|
+
var host = req.headers.host || "localhost";
|
|
219
|
+
var hostname = host.split(":")[0];
|
|
220
|
+
var protocol = tlsOptions ? "https" : "http";
|
|
221
|
+
var setupUrl = protocol + "://" + hostname + ":" + portNum;
|
|
222
|
+
var lanMode = /[?&]mode=lan/.test(req.url);
|
|
223
|
+
res.writeHead(200, {
|
|
224
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
225
|
+
"Access-Control-Allow-Origin": "*",
|
|
226
|
+
});
|
|
227
|
+
res.end(setupPageHtml(setupUrl, setupUrl, !!caContent, lanMode));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Global push endpoints (used by setup page)
|
|
232
|
+
if (req.method === "GET" && fullUrl === "/api/vapid-public-key" && pushModule) {
|
|
233
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
234
|
+
res.end(JSON.stringify({ publicKey: pushModule.publicKey }));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (req.method === "POST" && fullUrl === "/api/push-subscribe" && pushModule) {
|
|
239
|
+
var body = "";
|
|
240
|
+
req.on("data", function (chunk) { body += chunk; });
|
|
241
|
+
req.on("end", function () {
|
|
242
|
+
try {
|
|
243
|
+
var parsed = JSON.parse(body);
|
|
244
|
+
var sub = parsed.subscription || parsed;
|
|
245
|
+
pushModule.addSubscription(sub, parsed.replaceEndpoint);
|
|
246
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
247
|
+
res.end('{"ok":true}');
|
|
248
|
+
} catch (e) {
|
|
249
|
+
res.writeHead(400);
|
|
250
|
+
res.end("Bad request");
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Theme list: bundled (lib/themes/) + user (~/.clay/themes/)
|
|
257
|
+
if (req.method === "GET" && fullUrl === "/api/themes") {
|
|
258
|
+
var bundled = {};
|
|
259
|
+
var custom = {};
|
|
260
|
+
// Read bundled themes
|
|
261
|
+
try {
|
|
262
|
+
var bFiles = fs.readdirSync(bundledThemesDir);
|
|
263
|
+
for (var i = 0; i < bFiles.length; i++) {
|
|
264
|
+
if (!bFiles[i].endsWith(".json")) continue;
|
|
265
|
+
try {
|
|
266
|
+
var raw = fs.readFileSync(path.join(bundledThemesDir, bFiles[i]), "utf8");
|
|
267
|
+
var id = bFiles[i].replace(/\.json$/, "");
|
|
268
|
+
bundled[id] = JSON.parse(raw);
|
|
269
|
+
} catch (e) {}
|
|
270
|
+
}
|
|
271
|
+
} catch (e) {}
|
|
272
|
+
// Read user themes (override bundled if same id)
|
|
273
|
+
try {
|
|
274
|
+
var uFiles = fs.readdirSync(userThemesDir);
|
|
275
|
+
for (var j = 0; j < uFiles.length; j++) {
|
|
276
|
+
if (!uFiles[j].endsWith(".json")) continue;
|
|
277
|
+
try {
|
|
278
|
+
var uRaw = fs.readFileSync(path.join(userThemesDir, uFiles[j]), "utf8");
|
|
279
|
+
var uid = uFiles[j].replace(/\.json$/, "");
|
|
280
|
+
custom[uid] = JSON.parse(uRaw);
|
|
281
|
+
} catch (e) {}
|
|
282
|
+
}
|
|
283
|
+
} catch (e) {}
|
|
284
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
285
|
+
res.end(JSON.stringify({ bundled: bundled, custom: custom }));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Root path — redirect to first project
|
|
290
|
+
if (fullUrl === "/" && req.method === "GET") {
|
|
291
|
+
if (!isAuthed(req, authToken)) {
|
|
292
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
293
|
+
res.end(pinPage);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (projects.size > 0) {
|
|
297
|
+
var slug = projects.keys().next().value;
|
|
298
|
+
res.writeHead(302, { "Location": "/p/" + slug + "/" });
|
|
299
|
+
res.end();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
303
|
+
res.end("No projects registered.");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Global info endpoint (auth required)
|
|
308
|
+
if (req.method === "GET" && req.url === "/info") {
|
|
309
|
+
if (!isAuthed(req, authToken)) {
|
|
310
|
+
res.writeHead(401, {
|
|
311
|
+
"Content-Type": "application/json",
|
|
312
|
+
"Access-Control-Allow-Origin": "*",
|
|
313
|
+
});
|
|
314
|
+
res.end('{"error":"unauthorized"}');
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
var projectList = [];
|
|
318
|
+
projects.forEach(function (ctx, slug) {
|
|
319
|
+
projectList.push({ slug: slug, project: ctx.project });
|
|
320
|
+
});
|
|
321
|
+
res.end(JSON.stringify({ projects: projectList, version: currentVersion }));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Static files at root (favicon, manifest, icons, sw.js, etc.)
|
|
326
|
+
if (fullUrl.lastIndexOf("/") === 0 && !fullUrl.includes("..")) {
|
|
327
|
+
if (serveStatic(fullUrl, res)) return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Project-scoped routes: /p/{slug}/...
|
|
331
|
+
var slug = extractSlug(req.url.split("?")[0]);
|
|
332
|
+
if (!slug) {
|
|
333
|
+
// Not a project route and not handled above
|
|
334
|
+
res.writeHead(404);
|
|
335
|
+
res.end("Not found");
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
var ctx = projects.get(slug);
|
|
340
|
+
if (!ctx) {
|
|
341
|
+
res.writeHead(302, { "Location": "/" });
|
|
342
|
+
res.end();
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Redirect /p/{slug} → /p/{slug}/ (trailing slash required for relative paths)
|
|
347
|
+
if (fullUrl === "/p/" + slug) {
|
|
348
|
+
res.writeHead(301, { "Location": "/p/" + slug + "/" });
|
|
349
|
+
res.end();
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Auth check for project routes
|
|
354
|
+
if (!isAuthed(req, authToken)) {
|
|
355
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
356
|
+
res.end(pinPage);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Strip prefix for project-scoped handling
|
|
361
|
+
var projectUrl = stripPrefix(req.url.split("?")[0], slug);
|
|
362
|
+
// Re-attach query string for API routes
|
|
363
|
+
var qsIdx = req.url.indexOf("?");
|
|
364
|
+
var projectUrlWithQS = qsIdx >= 0 ? projectUrl + req.url.substring(qsIdx) : projectUrl;
|
|
365
|
+
|
|
366
|
+
// Try project HTTP handler first (APIs)
|
|
367
|
+
var origUrl = req.url;
|
|
368
|
+
req.url = projectUrlWithQS;
|
|
369
|
+
var handled = ctx.handleHTTP(req, res, projectUrlWithQS);
|
|
370
|
+
req.url = origUrl;
|
|
371
|
+
if (handled) return;
|
|
372
|
+
|
|
373
|
+
// Static files (same assets for all projects)
|
|
374
|
+
if (req.method === "GET") {
|
|
375
|
+
if (serveStatic(projectUrl, res)) return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
res.writeHead(404);
|
|
379
|
+
res.end("Not found");
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
// --- Server setup ---
|
|
383
|
+
var server;
|
|
384
|
+
if (tlsOptions) {
|
|
385
|
+
server = require("https").createServer(tlsOptions, appHandler);
|
|
386
|
+
} else {
|
|
387
|
+
server = http.createServer(appHandler);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// --- HTTP onboarding server (only when TLS is active) ---
|
|
391
|
+
var onboardingServer = null;
|
|
392
|
+
if (tlsOptions) {
|
|
393
|
+
onboardingServer = http.createServer(function (req, res) {
|
|
394
|
+
var url = req.url.split("?")[0];
|
|
395
|
+
|
|
396
|
+
// CA certificate download
|
|
397
|
+
if (url === "/ca/download" && req.method === "GET" && caContent) {
|
|
398
|
+
res.writeHead(200, {
|
|
399
|
+
"Content-Type": "application/x-pem-file",
|
|
400
|
+
"Content-Disposition": 'attachment; filename="clay-ca.pem"',
|
|
401
|
+
});
|
|
402
|
+
res.end(caContent);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Setup page
|
|
407
|
+
if (url === "/setup" && req.method === "GET") {
|
|
408
|
+
var host = req.headers.host || "localhost";
|
|
409
|
+
var hostname = host.split(":")[0];
|
|
410
|
+
var httpsSetupUrl = "https://" + hostname + ":" + portNum;
|
|
411
|
+
var httpSetupUrl = "http://" + hostname + ":" + (portNum + 1);
|
|
412
|
+
var lanMode = /[?&]mode=lan/.test(req.url);
|
|
413
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
414
|
+
res.end(setupPageHtml(httpsSetupUrl, httpSetupUrl, !!caContent, lanMode));
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// /info — CORS-enabled, used by setup page to verify HTTPS
|
|
419
|
+
if (url === "/info" && req.method === "GET") {
|
|
420
|
+
res.writeHead(200, {
|
|
421
|
+
"Content-Type": "application/json",
|
|
422
|
+
"Access-Control-Allow-Origin": "*",
|
|
423
|
+
});
|
|
424
|
+
res.end(JSON.stringify({ version: currentVersion }));
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Static files at root (favicon, manifest, icons, etc.)
|
|
429
|
+
if (url.lastIndexOf("/") === 0 && !url.includes("..")) {
|
|
430
|
+
if (serveStatic(url, res)) return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Everything else → redirect to HTTPS setup
|
|
434
|
+
var hostname = (req.headers.host || "localhost").split(":")[0];
|
|
435
|
+
res.writeHead(302, { "Location": "https://" + hostname + ":" + portNum + "/setup" });
|
|
436
|
+
res.end();
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// --- WebSocket ---
|
|
441
|
+
var wss = new WebSocketServer({ noServer: true });
|
|
442
|
+
|
|
443
|
+
server.on("upgrade", function (req, socket, head) {
|
|
444
|
+
// Origin validation (CSRF prevention)
|
|
445
|
+
var origin = req.headers.origin;
|
|
446
|
+
if (origin) {
|
|
447
|
+
try {
|
|
448
|
+
var originUrl = new URL(origin);
|
|
449
|
+
var originPort = String(originUrl.port || (originUrl.protocol === "https:" ? "443" : "80"));
|
|
450
|
+
// Extract port from Host header for reverse proxy support.
|
|
451
|
+
// Use URL parser to correctly handle IPv6 addresses (e.g. [::1])
|
|
452
|
+
// and infer default port from origin protocol (not backend tlsOptions)
|
|
453
|
+
// so TLS-terminating proxies on :443 with HTTP backends work.
|
|
454
|
+
var hostPort;
|
|
455
|
+
try {
|
|
456
|
+
var hostUrl = new URL(originUrl.protocol + "//" + (req.headers.host || ""));
|
|
457
|
+
hostPort = String(hostUrl.port || (originUrl.protocol === "https:" ? "443" : "80"));
|
|
458
|
+
} catch (e2) {
|
|
459
|
+
hostPort = String(portNum);
|
|
460
|
+
}
|
|
461
|
+
if (originPort !== String(portNum) && originPort !== hostPort) {
|
|
462
|
+
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
463
|
+
socket.destroy();
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
} catch (e) {
|
|
467
|
+
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
468
|
+
socket.destroy();
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (!isAuthed(req, authToken)) {
|
|
474
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
475
|
+
socket.destroy();
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Extract slug from WS URL: /p/{slug}/ws
|
|
480
|
+
var wsSlug = extractSlug(req.url);
|
|
481
|
+
if (!wsSlug) {
|
|
482
|
+
socket.destroy();
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
var ctx = projects.get(wsSlug);
|
|
487
|
+
if (!ctx) {
|
|
488
|
+
socket.destroy();
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
wss.handleUpgrade(req, socket, head, function (ws) {
|
|
493
|
+
ctx.handleConnection(ws);
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// --- Project management ---
|
|
498
|
+
function addProject(cwd, slug, title) {
|
|
499
|
+
if (projects.has(slug)) return false;
|
|
500
|
+
var ctx = createProjectContext({
|
|
501
|
+
cwd: cwd,
|
|
502
|
+
slug: slug,
|
|
503
|
+
title: title || null,
|
|
504
|
+
pushModule: pushModule,
|
|
505
|
+
debug: debug,
|
|
506
|
+
dangerouslySkipPermissions: dangerouslySkipPermissions,
|
|
507
|
+
currentVersion: currentVersion,
|
|
508
|
+
lanHost: lanHost,
|
|
509
|
+
getProjectCount: function () { return projects.size; },
|
|
510
|
+
getProjectList: function () {
|
|
511
|
+
var list = [];
|
|
512
|
+
projects.forEach(function (ctx) { list.push(ctx.getStatus()); });
|
|
513
|
+
return list;
|
|
514
|
+
},
|
|
515
|
+
onAddProject: onAddProject,
|
|
516
|
+
onRemoveProject: onRemoveProject,
|
|
517
|
+
onGetDaemonConfig: onGetDaemonConfig,
|
|
518
|
+
onSetPin: onSetPin,
|
|
519
|
+
onSetKeepAwake: onSetKeepAwake,
|
|
520
|
+
onShutdown: onShutdown,
|
|
521
|
+
});
|
|
522
|
+
projects.set(slug, ctx);
|
|
523
|
+
ctx.warmup();
|
|
524
|
+
return true;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function removeProject(slug) {
|
|
528
|
+
var ctx = projects.get(slug);
|
|
529
|
+
if (!ctx) return false;
|
|
530
|
+
ctx.destroy();
|
|
531
|
+
projects.delete(slug);
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function getProjects() {
|
|
536
|
+
var list = [];
|
|
537
|
+
projects.forEach(function (ctx) {
|
|
538
|
+
list.push(ctx.getStatus());
|
|
539
|
+
});
|
|
540
|
+
return list;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function setProjectTitle(slug, title) {
|
|
544
|
+
var ctx = projects.get(slug);
|
|
545
|
+
if (!ctx) return false;
|
|
546
|
+
ctx.setTitle(title);
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function setAuthToken(hash) {
|
|
551
|
+
authToken = hash;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function broadcastAll(msg) {
|
|
555
|
+
projects.forEach(function (ctx) {
|
|
556
|
+
ctx.send(msg);
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function destroyAll() {
|
|
561
|
+
projects.forEach(function (ctx, slug) {
|
|
562
|
+
console.log("[server] Destroying project:", slug);
|
|
563
|
+
ctx.destroy();
|
|
564
|
+
});
|
|
565
|
+
projects.clear();
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return {
|
|
569
|
+
server: server,
|
|
570
|
+
onboardingServer: onboardingServer,
|
|
571
|
+
isTLS: !!tlsOptions,
|
|
572
|
+
addProject: addProject,
|
|
573
|
+
removeProject: removeProject,
|
|
574
|
+
getProjects: getProjects,
|
|
575
|
+
setProjectTitle: setProjectTitle,
|
|
576
|
+
setAuthToken: setAuthToken,
|
|
577
|
+
broadcastAll: broadcastAll,
|
|
578
|
+
destroyAll: destroyAll,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
module.exports = { createServer: createServer, generateAuthToken: generateAuthToken };
|