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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/server.mjs +114 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ijihun-planner-studio",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A local owner-only timebox and Mandarart planner with Apple Reminders and Google Tasks bridge support.",
5
5
  "type": "module",
6
6
  "bin": {
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
- return trustedOrigins.has(origin);
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
- sendJson(res, 403, { ok: false, error: "Origin not allowed" });
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
- sendJson(res, 403, { ok: false, error: "Origin not allowed" });
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;