ijihun-planner-studio 0.1.0 → 0.1.1
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 +114 -11
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,6 +353,11 @@ 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
|
}
|
|
@@ -323,7 +365,52 @@ function isUnsafeMethod(method) {
|
|
|
323
365
|
function originAllowed(req) {
|
|
324
366
|
const origin = req.headers.origin;
|
|
325
367
|
if (!origin) return true;
|
|
326
|
-
|
|
368
|
+
const normalized = normalizeOrigin(origin);
|
|
369
|
+
if (!normalized) return false;
|
|
370
|
+
return trustedOrigins.has(normalized) || normalized === requestOrigin(req);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function logOriginBlocked(req, route) {
|
|
374
|
+
const origin = String(req.headers.origin || "");
|
|
375
|
+
console.warn(JSON.stringify({
|
|
376
|
+
event: "origin_blocked",
|
|
377
|
+
route,
|
|
378
|
+
method: req.method,
|
|
379
|
+
origin,
|
|
380
|
+
normalizedOrigin: normalizeOrigin(origin),
|
|
381
|
+
requestOrigin: requestOrigin(req),
|
|
382
|
+
host: req.headers.host || "",
|
|
383
|
+
forwardedHost: req.headers["x-forwarded-host"] || "",
|
|
384
|
+
forwardedProto: req.headers["x-forwarded-proto"] || ""
|
|
385
|
+
}));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function normalizeOrigin(value) {
|
|
389
|
+
try {
|
|
390
|
+
return new URL(String(value).trim()).origin;
|
|
391
|
+
} catch {
|
|
392
|
+
return "";
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function requestOrigin(req) {
|
|
397
|
+
const host = String(req.headers["x-forwarded-host"] || req.headers.host || "").split(",")[0].trim();
|
|
398
|
+
if (!host) return "";
|
|
399
|
+
const proto = String(req.headers["x-forwarded-proto"] || "").split(",")[0].trim() || "http";
|
|
400
|
+
return normalizeOrigin(`${proto}://${host}`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function shouldRedirectToHttps(req) {
|
|
404
|
+
const host = String(req.headers["x-forwarded-host"] || req.headers.host || "").split(",")[0].trim().toLowerCase();
|
|
405
|
+
if (host !== "planner.ljihun.store") return false;
|
|
406
|
+
const proto = String(req.headers["x-forwarded-proto"] || "").split(",")[0].trim().toLowerCase();
|
|
407
|
+
if (proto === "http") return true;
|
|
408
|
+
return String(req.headers["cf-visitor"] || "").includes('"scheme":"http"');
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function redirectToHttps(req, res) {
|
|
412
|
+
const host = String(req.headers["x-forwarded-host"] || req.headers.host || "planner.ljihun.store").split(",")[0].trim();
|
|
413
|
+
permanentRedirect(res, `https://${host}${req.url || "/"}`);
|
|
327
414
|
}
|
|
328
415
|
|
|
329
416
|
function csrfAllowed(req, session) {
|
|
@@ -420,8 +507,13 @@ function sendAuthCss(res) {
|
|
|
420
507
|
}
|
|
421
508
|
|
|
422
509
|
async function handleAuth(req, res, url) {
|
|
510
|
+
if ((req.method === "GET" || req.method === "HEAD") && url.pathname === "/api/auth/login" && acceptsHtml(req)) {
|
|
511
|
+
redirect(res, "/login");
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
|
|
423
515
|
if (req.method === "GET" && url.pathname === "/api/auth/session") {
|
|
424
|
-
sendJson(res, 200, sessionPayload(getSession(req)));
|
|
516
|
+
sendJson(res, 200, sessionPayload(getSession(req)), corsHeaders(req));
|
|
425
517
|
return true;
|
|
426
518
|
}
|
|
427
519
|
|
|
@@ -429,11 +521,12 @@ async function handleAuth(req, res, url) {
|
|
|
429
521
|
const formLogin = String(req.headers["content-type"] || "").startsWith("application/x-www-form-urlencoded");
|
|
430
522
|
if (!authConfigured) {
|
|
431
523
|
if (formLogin) redirect(res, "/login?error=1");
|
|
432
|
-
else sendJson(res, 503, { ok: false, error: "Owner authentication is not configured" });
|
|
524
|
+
else sendJson(res, 503, { ok: false, error: "Owner authentication is not configured" }, corsHeaders(req));
|
|
433
525
|
return true;
|
|
434
526
|
}
|
|
435
527
|
if (!originAllowed(req)) {
|
|
436
|
-
|
|
528
|
+
logOriginBlocked(req, url.pathname);
|
|
529
|
+
sendJson(res, 403, { ok: false, error: "Origin not allowed" }, corsHeaders(req));
|
|
437
530
|
return true;
|
|
438
531
|
}
|
|
439
532
|
|
|
@@ -442,7 +535,7 @@ async function handleAuth(req, res, url) {
|
|
|
442
535
|
const password = String(payload.password || "");
|
|
443
536
|
if (isLockedOut(req, email)) {
|
|
444
537
|
if (formLogin) redirect(res, "/login?locked=1");
|
|
445
|
-
else sendJson(res, 429, { ok: false, error: "Too many login attempts" });
|
|
538
|
+
else sendJson(res, 429, { ok: false, error: "Too many login attempts" }, corsHeaders(req));
|
|
446
539
|
return true;
|
|
447
540
|
}
|
|
448
541
|
|
|
@@ -450,25 +543,25 @@ async function handleAuth(req, res, url) {
|
|
|
450
543
|
if (!ok) {
|
|
451
544
|
recordLoginFailure(req, email || ownerEmail);
|
|
452
545
|
if (formLogin) redirect(res, "/login?error=1");
|
|
453
|
-
else sendJson(res, 401, { ok: false, error: "Invalid credentials" });
|
|
546
|
+
else sendJson(res, 401, { ok: false, error: "Invalid credentials" }, corsHeaders(req));
|
|
454
547
|
return true;
|
|
455
548
|
}
|
|
456
549
|
|
|
457
550
|
clearLoginFailures(req, email);
|
|
458
551
|
const session = createSession(res);
|
|
459
552
|
if (formLogin) redirect(res, "/");
|
|
460
|
-
else sendJson(res, 200, sessionPayload(session));
|
|
553
|
+
else sendJson(res, 200, sessionPayload(session), corsHeaders(req));
|
|
461
554
|
return true;
|
|
462
555
|
}
|
|
463
556
|
|
|
464
557
|
if (req.method === "POST" && url.pathname === "/api/auth/logout") {
|
|
465
558
|
const session = getSession(req);
|
|
466
559
|
if (session && !csrfAllowed(req, session)) {
|
|
467
|
-
sendJson(res, 403, { ok: false, error: "CSRF token required" });
|
|
560
|
+
sendJson(res, 403, { ok: false, error: "CSRF token required" }, corsHeaders(req));
|
|
468
561
|
return true;
|
|
469
562
|
}
|
|
470
563
|
clearSession(req, res);
|
|
471
|
-
sendJson(res, 200, { ok: true });
|
|
564
|
+
sendJson(res, 200, { ok: true }, corsHeaders(req));
|
|
472
565
|
return true;
|
|
473
566
|
}
|
|
474
567
|
|
|
@@ -485,7 +578,8 @@ async function handleApi(req, res, url) {
|
|
|
485
578
|
return;
|
|
486
579
|
}
|
|
487
580
|
if (!originAllowed(req)) {
|
|
488
|
-
|
|
581
|
+
logOriginBlocked(req, url.pathname);
|
|
582
|
+
sendJson(res, 403, { ok: false, error: "Origin not allowed" }, corsHeaders(req));
|
|
489
583
|
return;
|
|
490
584
|
}
|
|
491
585
|
if (!csrfAllowed(req, session)) {
|
|
@@ -617,6 +711,11 @@ const server = createServer(async (req, res) => {
|
|
|
617
711
|
|
|
618
712
|
const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
|
|
619
713
|
|
|
714
|
+
if (shouldRedirectToHttps(req)) {
|
|
715
|
+
redirectToHttps(req, res);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
|
|
620
719
|
if (req.method === "GET" && url.pathname === "/login") {
|
|
621
720
|
sendLoginPage(req, res, url);
|
|
622
721
|
return;
|
|
@@ -625,6 +724,10 @@ const server = createServer(async (req, res) => {
|
|
|
625
724
|
sendAuthCss(res);
|
|
626
725
|
return;
|
|
627
726
|
}
|
|
727
|
+
if (req.method === "OPTIONS" && url.pathname.startsWith("/api/")) {
|
|
728
|
+
sendCorsPreflight(req, res);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
628
731
|
if (url.pathname.startsWith("/api/")) {
|
|
629
732
|
await handleApi(req, res, url);
|
|
630
733
|
return;
|