ijihun-planner-studio 0.1.0 → 0.1.2
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/package.json +1 -1
- package/server.mjs +123 -13
package/package.json
CHANGED
package/server.mjs
CHANGED
|
@@ -45,7 +45,7 @@ const trustedOrigins = new Set([
|
|
|
45
45
|
.split(",")
|
|
46
46
|
.map((item) => item.trim())
|
|
47
47
|
.filter(Boolean)
|
|
48
|
-
]);
|
|
48
|
+
].map(normalizeOrigin).filter(Boolean));
|
|
49
49
|
|
|
50
50
|
const mimeTypes = {
|
|
51
51
|
".html": "text/html; charset=utf-8",
|
|
@@ -118,6 +118,34 @@ function sendJson(res, status, payload, headers = {}) {
|
|
|
118
118
|
res.end(JSON.stringify(payload, null, 2));
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
function corsHeaders(req) {
|
|
122
|
+
const origin = req.headers.origin;
|
|
123
|
+
if (!origin || !originAllowed(req)) return {};
|
|
124
|
+
const requestedHeaders = String(req.headers["access-control-request-headers"] || "").trim();
|
|
125
|
+
const requestedMethod = String(req.headers["access-control-request-method"] || "").trim();
|
|
126
|
+
return {
|
|
127
|
+
"access-control-allow-origin": origin,
|
|
128
|
+
"access-control-allow-credentials": "true",
|
|
129
|
+
"access-control-allow-methods": requestedMethod || "GET,HEAD,POST,OPTIONS",
|
|
130
|
+
"access-control-allow-headers": requestedHeaders || "content-type,x-csrf-token",
|
|
131
|
+
"vary": "Origin"
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function sendCorsPreflight(req, res) {
|
|
136
|
+
const headers = corsHeaders(req);
|
|
137
|
+
if (!Object.keys(headers).length) {
|
|
138
|
+
logOriginBlocked(req, "preflight");
|
|
139
|
+
sendJson(res, 403, { ok: false, error: "Origin not allowed" });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
res.writeHead(204, securityHeaders({
|
|
143
|
+
"cache-control": "no-store",
|
|
144
|
+
...headers
|
|
145
|
+
}));
|
|
146
|
+
res.end();
|
|
147
|
+
}
|
|
148
|
+
|
|
121
149
|
function redirect(res, location) {
|
|
122
150
|
res.writeHead(303, securityHeaders({
|
|
123
151
|
location,
|
|
@@ -126,6 +154,15 @@ function redirect(res, location) {
|
|
|
126
154
|
res.end();
|
|
127
155
|
}
|
|
128
156
|
|
|
157
|
+
function permanentRedirect(res, location) {
|
|
158
|
+
res.writeHead(301, securityHeaders({
|
|
159
|
+
location,
|
|
160
|
+
"content-type": "text/plain; charset=utf-8",
|
|
161
|
+
"cache-control": "no-store"
|
|
162
|
+
}));
|
|
163
|
+
res.end("Moved Permanently");
|
|
164
|
+
}
|
|
165
|
+
|
|
129
166
|
function runFile(command, commandArgs, options = {}) {
|
|
130
167
|
return new Promise((resolveRun, rejectRun) => {
|
|
131
168
|
const child = execFile(command, commandArgs, {
|
|
@@ -316,14 +353,70 @@ function acceptsJson(req) {
|
|
|
316
353
|
return String(req.headers.accept || "").includes("application/json");
|
|
317
354
|
}
|
|
318
355
|
|
|
356
|
+
function acceptsHtml(req) {
|
|
357
|
+
const accept = String(req.headers.accept || "");
|
|
358
|
+
return accept.includes("text/html") && !acceptsJson(req);
|
|
359
|
+
}
|
|
360
|
+
|
|
319
361
|
function isUnsafeMethod(method) {
|
|
320
362
|
return !["GET", "HEAD", "OPTIONS"].includes(method || "GET");
|
|
321
363
|
}
|
|
322
364
|
|
|
323
|
-
function originAllowed(req) {
|
|
365
|
+
function originAllowed(req, options = {}) {
|
|
324
366
|
const origin = req.headers.origin;
|
|
325
367
|
if (!origin) return true;
|
|
326
|
-
|
|
368
|
+
const normalized = normalizeOrigin(origin);
|
|
369
|
+
if (!normalized) {
|
|
370
|
+
return Boolean(
|
|
371
|
+
options.allowNullOrigin
|
|
372
|
+
&& String(origin).trim() === "null"
|
|
373
|
+
&& trustedOrigins.has(requestOrigin(req))
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
return trustedOrigins.has(normalized) || normalized === requestOrigin(req);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function logOriginBlocked(req, route) {
|
|
380
|
+
const origin = String(req.headers.origin || "");
|
|
381
|
+
console.warn(JSON.stringify({
|
|
382
|
+
event: "origin_blocked",
|
|
383
|
+
route,
|
|
384
|
+
method: req.method,
|
|
385
|
+
origin,
|
|
386
|
+
normalizedOrigin: normalizeOrigin(origin),
|
|
387
|
+
requestOrigin: requestOrigin(req),
|
|
388
|
+
host: req.headers.host || "",
|
|
389
|
+
forwardedHost: req.headers["x-forwarded-host"] || "",
|
|
390
|
+
forwardedProto: req.headers["x-forwarded-proto"] || ""
|
|
391
|
+
}));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function normalizeOrigin(value) {
|
|
395
|
+
try {
|
|
396
|
+
return new URL(String(value).trim()).origin;
|
|
397
|
+
} catch {
|
|
398
|
+
return "";
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function requestOrigin(req) {
|
|
403
|
+
const host = String(req.headers["x-forwarded-host"] || req.headers.host || "").split(",")[0].trim();
|
|
404
|
+
if (!host) return "";
|
|
405
|
+
const proto = String(req.headers["x-forwarded-proto"] || "").split(",")[0].trim() || "http";
|
|
406
|
+
return normalizeOrigin(`${proto}://${host}`);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function shouldRedirectToHttps(req) {
|
|
410
|
+
const host = String(req.headers["x-forwarded-host"] || req.headers.host || "").split(",")[0].trim().toLowerCase();
|
|
411
|
+
if (host !== "planner.ljihun.store") return false;
|
|
412
|
+
const proto = String(req.headers["x-forwarded-proto"] || "").split(",")[0].trim().toLowerCase();
|
|
413
|
+
if (proto === "http") return true;
|
|
414
|
+
return String(req.headers["cf-visitor"] || "").includes('"scheme":"http"');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function redirectToHttps(req, res) {
|
|
418
|
+
const host = String(req.headers["x-forwarded-host"] || req.headers.host || "planner.ljihun.store").split(",")[0].trim();
|
|
419
|
+
permanentRedirect(res, `https://${host}${req.url || "/"}`);
|
|
327
420
|
}
|
|
328
421
|
|
|
329
422
|
function csrfAllowed(req, session) {
|
|
@@ -420,8 +513,13 @@ function sendAuthCss(res) {
|
|
|
420
513
|
}
|
|
421
514
|
|
|
422
515
|
async function handleAuth(req, res, url) {
|
|
516
|
+
if ((req.method === "GET" || req.method === "HEAD") && url.pathname === "/api/auth/login" && acceptsHtml(req)) {
|
|
517
|
+
redirect(res, "/login");
|
|
518
|
+
return true;
|
|
519
|
+
}
|
|
520
|
+
|
|
423
521
|
if (req.method === "GET" && url.pathname === "/api/auth/session") {
|
|
424
|
-
sendJson(res, 200, sessionPayload(getSession(req)));
|
|
522
|
+
sendJson(res, 200, sessionPayload(getSession(req)), corsHeaders(req));
|
|
425
523
|
return true;
|
|
426
524
|
}
|
|
427
525
|
|
|
@@ -429,11 +527,13 @@ async function handleAuth(req, res, url) {
|
|
|
429
527
|
const formLogin = String(req.headers["content-type"] || "").startsWith("application/x-www-form-urlencoded");
|
|
430
528
|
if (!authConfigured) {
|
|
431
529
|
if (formLogin) redirect(res, "/login?error=1");
|
|
432
|
-
else sendJson(res, 503, { ok: false, error: "Owner authentication is not configured" });
|
|
530
|
+
else sendJson(res, 503, { ok: false, error: "Owner authentication is not configured" }, corsHeaders(req));
|
|
433
531
|
return true;
|
|
434
532
|
}
|
|
435
|
-
if (!originAllowed(req)) {
|
|
436
|
-
|
|
533
|
+
if (!originAllowed(req, { allowNullOrigin: formLogin })) {
|
|
534
|
+
logOriginBlocked(req, url.pathname);
|
|
535
|
+
if (formLogin) redirect(res, "/login?error=1");
|
|
536
|
+
else sendJson(res, 403, { ok: false, error: "Origin not allowed" }, corsHeaders(req));
|
|
437
537
|
return true;
|
|
438
538
|
}
|
|
439
539
|
|
|
@@ -442,7 +542,7 @@ async function handleAuth(req, res, url) {
|
|
|
442
542
|
const password = String(payload.password || "");
|
|
443
543
|
if (isLockedOut(req, email)) {
|
|
444
544
|
if (formLogin) redirect(res, "/login?locked=1");
|
|
445
|
-
else sendJson(res, 429, { ok: false, error: "Too many login attempts" });
|
|
545
|
+
else sendJson(res, 429, { ok: false, error: "Too many login attempts" }, corsHeaders(req));
|
|
446
546
|
return true;
|
|
447
547
|
}
|
|
448
548
|
|
|
@@ -450,25 +550,25 @@ async function handleAuth(req, res, url) {
|
|
|
450
550
|
if (!ok) {
|
|
451
551
|
recordLoginFailure(req, email || ownerEmail);
|
|
452
552
|
if (formLogin) redirect(res, "/login?error=1");
|
|
453
|
-
else sendJson(res, 401, { ok: false, error: "Invalid credentials" });
|
|
553
|
+
else sendJson(res, 401, { ok: false, error: "Invalid credentials" }, corsHeaders(req));
|
|
454
554
|
return true;
|
|
455
555
|
}
|
|
456
556
|
|
|
457
557
|
clearLoginFailures(req, email);
|
|
458
558
|
const session = createSession(res);
|
|
459
559
|
if (formLogin) redirect(res, "/");
|
|
460
|
-
else sendJson(res, 200, sessionPayload(session));
|
|
560
|
+
else sendJson(res, 200, sessionPayload(session), corsHeaders(req));
|
|
461
561
|
return true;
|
|
462
562
|
}
|
|
463
563
|
|
|
464
564
|
if (req.method === "POST" && url.pathname === "/api/auth/logout") {
|
|
465
565
|
const session = getSession(req);
|
|
466
566
|
if (session && !csrfAllowed(req, session)) {
|
|
467
|
-
sendJson(res, 403, { ok: false, error: "CSRF token required" });
|
|
567
|
+
sendJson(res, 403, { ok: false, error: "CSRF token required" }, corsHeaders(req));
|
|
468
568
|
return true;
|
|
469
569
|
}
|
|
470
570
|
clearSession(req, res);
|
|
471
|
-
sendJson(res, 200, { ok: true });
|
|
571
|
+
sendJson(res, 200, { ok: true }, corsHeaders(req));
|
|
472
572
|
return true;
|
|
473
573
|
}
|
|
474
574
|
|
|
@@ -485,7 +585,8 @@ async function handleApi(req, res, url) {
|
|
|
485
585
|
return;
|
|
486
586
|
}
|
|
487
587
|
if (!originAllowed(req)) {
|
|
488
|
-
|
|
588
|
+
logOriginBlocked(req, url.pathname);
|
|
589
|
+
sendJson(res, 403, { ok: false, error: "Origin not allowed" }, corsHeaders(req));
|
|
489
590
|
return;
|
|
490
591
|
}
|
|
491
592
|
if (!csrfAllowed(req, session)) {
|
|
@@ -617,6 +718,11 @@ const server = createServer(async (req, res) => {
|
|
|
617
718
|
|
|
618
719
|
const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
|
|
619
720
|
|
|
721
|
+
if (shouldRedirectToHttps(req)) {
|
|
722
|
+
redirectToHttps(req, res);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
|
|
620
726
|
if (req.method === "GET" && url.pathname === "/login") {
|
|
621
727
|
sendLoginPage(req, res, url);
|
|
622
728
|
return;
|
|
@@ -625,6 +731,10 @@ const server = createServer(async (req, res) => {
|
|
|
625
731
|
sendAuthCss(res);
|
|
626
732
|
return;
|
|
627
733
|
}
|
|
734
|
+
if (req.method === "OPTIONS" && url.pathname.startsWith("/api/")) {
|
|
735
|
+
sendCorsPreflight(req, res);
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
628
738
|
if (url.pathname.startsWith("/api/")) {
|
|
629
739
|
await handleApi(req, res, url);
|
|
630
740
|
return;
|