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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/server.mjs +123 -13
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.2",
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,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
- return trustedOrigins.has(origin);
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
- sendJson(res, 403, { ok: false, error: "Origin not allowed" });
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
- sendJson(res, 403, { ok: false, error: "Origin not allowed" });
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;