ijihun-planner-studio 0.1.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/.env.example +5 -0
- package/README.md +40 -0
- package/bin/planner-studio.mjs +49 -0
- package/dist/assets/index-BXkpRJGR.css +1 -0
- package/dist/assets/index-D0WfhOqG.js +177 -0
- package/dist/index.html +15 -0
- package/lib/auth.mjs +64 -0
- package/package.json +52 -0
- package/scripts/create-owner-secret.mjs +71 -0
- package/server.mjs +645 -0
package/server.mjs
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { chmodSync, createReadStream, existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { readFile, stat } from "node:fs/promises";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { dirname, extname, join, resolve, sep } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { signValue, timingSafeStringEqual, verifyPassword } from "./lib/auth.mjs";
|
|
10
|
+
|
|
11
|
+
const root = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
loadLocalEnv([resolve(process.cwd(), ".env.local"), join(root, ".env.local")]);
|
|
13
|
+
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
const apiOnly = args.includes("--api-only");
|
|
16
|
+
const portIndex = args.indexOf("--port");
|
|
17
|
+
const hostIndex = args.indexOf("--host");
|
|
18
|
+
const port = portIndex >= 0 ? Number(args[portIndex + 1]) : Number(process.env.PORT || 4179);
|
|
19
|
+
const host = hostIndex >= 0 ? args[hostIndex + 1] : process.env.HOST || "127.0.0.1";
|
|
20
|
+
const dist = join(root, "dist");
|
|
21
|
+
const bridgeRoot = process.env.PLANNER_REMINDERS_BRIDGE_ROOT || "/Users/ijihun/apps/icloud-reminders-google-sync";
|
|
22
|
+
const exportSwift = join(bridgeRoot, "RemindersExport.swift");
|
|
23
|
+
const applySwift = join(bridgeRoot, "RemindersApply.swift");
|
|
24
|
+
const syncPy = join(bridgeRoot, "icloud_reminders_google_sync.py");
|
|
25
|
+
const configPath = join(homedir(), ".config/icloud-reminders-google-sync/config.json");
|
|
26
|
+
const statusPath = join(homedir(), ".config/icloud-reminders-google-sync/status.json");
|
|
27
|
+
const ownerEmail = (process.env.PLANNER_OWNER_EMAIL || "").trim().toLowerCase();
|
|
28
|
+
const passwordHash = (process.env.PLANNER_PASSWORD_HASH || "").trim();
|
|
29
|
+
const sessionSecret = (process.env.PLANNER_SESSION_SECRET || "").trim();
|
|
30
|
+
const authConfigured = Boolean(ownerEmail && passwordHash && sessionSecret);
|
|
31
|
+
const sessionTtlMs = Number(process.env.PLANNER_SESSION_TTL_MINUTES || 480) * 60 * 1000;
|
|
32
|
+
const secureCookies = process.env.PLANNER_COOKIE_SECURE === "1";
|
|
33
|
+
const allowedHosts = new Set(
|
|
34
|
+
(process.env.PLANNER_ALLOWED_HOSTS || "127.0.0.1,localhost,::1")
|
|
35
|
+
.split(",")
|
|
36
|
+
.map((item) => item.trim())
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
);
|
|
39
|
+
const trustedOrigins = new Set([
|
|
40
|
+
`http://127.0.0.1:${port}`,
|
|
41
|
+
`http://localhost:${port}`,
|
|
42
|
+
"http://127.0.0.1:5178",
|
|
43
|
+
"http://localhost:5178",
|
|
44
|
+
...(process.env.PLANNER_TRUSTED_ORIGINS || "")
|
|
45
|
+
.split(",")
|
|
46
|
+
.map((item) => item.trim())
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const mimeTypes = {
|
|
51
|
+
".html": "text/html; charset=utf-8",
|
|
52
|
+
".js": "text/javascript; charset=utf-8",
|
|
53
|
+
".css": "text/css; charset=utf-8",
|
|
54
|
+
".json": "application/json; charset=utf-8",
|
|
55
|
+
".svg": "image/svg+xml",
|
|
56
|
+
".png": "image/png",
|
|
57
|
+
".ico": "image/x-icon"
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const sessions = new Map();
|
|
61
|
+
const loginAttempts = new Map();
|
|
62
|
+
|
|
63
|
+
function loadLocalEnv(files) {
|
|
64
|
+
for (const filePath of files) {
|
|
65
|
+
if (!existsSync(filePath)) continue;
|
|
66
|
+
const text = readFileSync(filePath, "utf8");
|
|
67
|
+
for (const line of text.split(/\r?\n/)) {
|
|
68
|
+
const trimmed = line.trim();
|
|
69
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
70
|
+
const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(trimmed);
|
|
71
|
+
if (!match || process.env[match[1]] !== undefined) continue;
|
|
72
|
+
process.env[match[1]] = parseEnvValue(match[2]);
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
chmodSync(filePath, 0o600);
|
|
76
|
+
} catch {
|
|
77
|
+
// Best-effort hardening for local secret material.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseEnvValue(value) {
|
|
83
|
+
const trimmed = value.trim();
|
|
84
|
+
if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
85
|
+
return trimmed.slice(1, -1);
|
|
86
|
+
}
|
|
87
|
+
return trimmed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function securityHeaders(extra = {}) {
|
|
91
|
+
return {
|
|
92
|
+
"content-security-policy": [
|
|
93
|
+
"default-src 'self'",
|
|
94
|
+
"script-src 'self'",
|
|
95
|
+
"style-src 'self'",
|
|
96
|
+
"img-src 'self' data:",
|
|
97
|
+
"font-src 'self'",
|
|
98
|
+
"connect-src 'self'",
|
|
99
|
+
"object-src 'none'",
|
|
100
|
+
"base-uri 'none'",
|
|
101
|
+
"frame-ancestors 'none'",
|
|
102
|
+
"form-action 'self'"
|
|
103
|
+
].join("; "),
|
|
104
|
+
"x-content-type-options": "nosniff",
|
|
105
|
+
"x-frame-options": "DENY",
|
|
106
|
+
"referrer-policy": "no-referrer",
|
|
107
|
+
"permissions-policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=(), bluetooth=()",
|
|
108
|
+
...extra
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function sendJson(res, status, payload, headers = {}) {
|
|
113
|
+
res.writeHead(status, securityHeaders({
|
|
114
|
+
"content-type": "application/json; charset=utf-8",
|
|
115
|
+
"cache-control": "no-store",
|
|
116
|
+
...headers
|
|
117
|
+
}));
|
|
118
|
+
res.end(JSON.stringify(payload, null, 2));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function redirect(res, location) {
|
|
122
|
+
res.writeHead(303, securityHeaders({
|
|
123
|
+
location,
|
|
124
|
+
"cache-control": "no-store"
|
|
125
|
+
}));
|
|
126
|
+
res.end();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function runFile(command, commandArgs, options = {}) {
|
|
130
|
+
return new Promise((resolveRun, rejectRun) => {
|
|
131
|
+
const child = execFile(command, commandArgs, {
|
|
132
|
+
cwd: options.cwd || root,
|
|
133
|
+
timeout: options.timeout || 120000,
|
|
134
|
+
maxBuffer: 1024 * 1024 * 12
|
|
135
|
+
}, (error, stdout, stderr) => {
|
|
136
|
+
if (error) {
|
|
137
|
+
rejectRun(Object.assign(error, { stdout, stderr }));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
resolveRun({ stdout, stderr });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (options.stdin) {
|
|
144
|
+
child.stdin?.write(options.stdin);
|
|
145
|
+
child.stdin?.end();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function readRawBody(req, limit = 1024 * 1024) {
|
|
151
|
+
const chunks = [];
|
|
152
|
+
let size = 0;
|
|
153
|
+
for await (const chunk of req) {
|
|
154
|
+
size += chunk.length;
|
|
155
|
+
if (size > limit) throw new Error("Request body too large");
|
|
156
|
+
chunks.push(chunk);
|
|
157
|
+
}
|
|
158
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function readBody(req) {
|
|
162
|
+
const text = await readRawBody(req);
|
|
163
|
+
if (!text) return {};
|
|
164
|
+
const contentType = String(req.headers["content-type"] || "").split(";")[0].trim();
|
|
165
|
+
if (contentType === "application/x-www-form-urlencoded") {
|
|
166
|
+
return Object.fromEntries(new URLSearchParams(text));
|
|
167
|
+
}
|
|
168
|
+
if (!contentType || contentType === "application/json") {
|
|
169
|
+
return JSON.parse(text);
|
|
170
|
+
}
|
|
171
|
+
const error = new Error("Unsupported content type");
|
|
172
|
+
error.statusCode = 415;
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function safeMessage(error) {
|
|
177
|
+
return {
|
|
178
|
+
message: error.message || "Unknown error",
|
|
179
|
+
stderr: error.stderr || "",
|
|
180
|
+
stdout: error.stdout || ""
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function loadStatus() {
|
|
185
|
+
if (!existsSync(statusPath)) return null;
|
|
186
|
+
try {
|
|
187
|
+
return JSON.parse(await readFile(statusPath, "utf8"));
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function parseCookies(req) {
|
|
194
|
+
const cookieHeader = String(req.headers.cookie || "");
|
|
195
|
+
const cookies = new Map();
|
|
196
|
+
for (const part of cookieHeader.split(";")) {
|
|
197
|
+
const index = part.indexOf("=");
|
|
198
|
+
if (index < 0) continue;
|
|
199
|
+
const key = part.slice(0, index).trim();
|
|
200
|
+
const value = part.slice(index + 1).trim();
|
|
201
|
+
if (key) cookies.set(key, decodeURIComponent(value));
|
|
202
|
+
}
|
|
203
|
+
return cookies;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function serializeCookie(name, value, options = {}) {
|
|
207
|
+
const parts = [`${name}=${encodeURIComponent(value)}`];
|
|
208
|
+
parts.push(`Path=${options.path || "/"}`);
|
|
209
|
+
if (options.maxAge !== undefined) parts.push(`Max-Age=${options.maxAge}`);
|
|
210
|
+
if (options.httpOnly !== false) parts.push("HttpOnly");
|
|
211
|
+
parts.push(`SameSite=${options.sameSite || "Lax"}`);
|
|
212
|
+
if (options.secure) parts.push("Secure");
|
|
213
|
+
return parts.join("; ");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function signedSessionValue(sessionId) {
|
|
217
|
+
return `${sessionId}.${signValue(sessionId, sessionSecret)}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function getSession(req) {
|
|
221
|
+
if (!authConfigured) return null;
|
|
222
|
+
const raw = parseCookies(req).get("planner_session");
|
|
223
|
+
if (!raw) return null;
|
|
224
|
+
const [sessionId, signature] = raw.split(".");
|
|
225
|
+
if (!sessionId || !signature) return null;
|
|
226
|
+
if (!timingSafeStringEqual(signValue(sessionId, sessionSecret), signature)) return null;
|
|
227
|
+
const session = sessions.get(sessionId);
|
|
228
|
+
if (!session || session.expiresAt <= Date.now()) {
|
|
229
|
+
sessions.delete(sessionId);
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
session.lastSeenAt = Date.now();
|
|
233
|
+
return { ...session, id: sessionId };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function createSession(res) {
|
|
237
|
+
pruneSessions();
|
|
238
|
+
const sessionId = randomBytes(32).toString("base64url");
|
|
239
|
+
const session = {
|
|
240
|
+
email: ownerEmail,
|
|
241
|
+
csrfToken: randomBytes(32).toString("base64url"),
|
|
242
|
+
createdAt: Date.now(),
|
|
243
|
+
lastSeenAt: Date.now(),
|
|
244
|
+
expiresAt: Date.now() + sessionTtlMs
|
|
245
|
+
};
|
|
246
|
+
sessions.set(sessionId, session);
|
|
247
|
+
res.setHeader("set-cookie", serializeCookie("planner_session", signedSessionValue(sessionId), {
|
|
248
|
+
maxAge: Math.floor(sessionTtlMs / 1000),
|
|
249
|
+
secure: secureCookies
|
|
250
|
+
}));
|
|
251
|
+
return { ...session, id: sessionId };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function clearSession(req, res) {
|
|
255
|
+
const session = getSession(req);
|
|
256
|
+
if (session) sessions.delete(session.id);
|
|
257
|
+
res.setHeader("set-cookie", serializeCookie("planner_session", "", {
|
|
258
|
+
maxAge: 0,
|
|
259
|
+
secure: secureCookies
|
|
260
|
+
}));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function pruneSessions() {
|
|
264
|
+
const now = Date.now();
|
|
265
|
+
for (const [sessionId, session] of sessions) {
|
|
266
|
+
if (session.expiresAt <= now) sessions.delete(sessionId);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function getClientKey(req, email) {
|
|
271
|
+
return `${req.socket.remoteAddress || "unknown"}:${String(email || "").toLowerCase()}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function getAttemptState(req, email) {
|
|
275
|
+
const key = getClientKey(req, email);
|
|
276
|
+
const current = loginAttempts.get(key) || { count: 0, lockedUntil: 0, firstFailureAt: 0 };
|
|
277
|
+
const now = Date.now();
|
|
278
|
+
if (current.firstFailureAt && now - current.firstFailureAt > 15 * 60 * 1000) {
|
|
279
|
+
loginAttempts.delete(key);
|
|
280
|
+
return { key, state: { count: 0, lockedUntil: 0, firstFailureAt: 0 } };
|
|
281
|
+
}
|
|
282
|
+
return { key, state: current };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function recordLoginFailure(req, email) {
|
|
286
|
+
const { key, state } = getAttemptState(req, email);
|
|
287
|
+
const now = Date.now();
|
|
288
|
+
const count = state.count + 1;
|
|
289
|
+
loginAttempts.set(key, {
|
|
290
|
+
count,
|
|
291
|
+
firstFailureAt: state.firstFailureAt || now,
|
|
292
|
+
lockedUntil: count >= 5 ? now + 15 * 60 * 1000 : state.lockedUntil
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function clearLoginFailures(req, email) {
|
|
297
|
+
loginAttempts.delete(getClientKey(req, email));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function isLockedOut(req, email) {
|
|
301
|
+
const { state } = getAttemptState(req, email);
|
|
302
|
+
return state.lockedUntil > Date.now();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function sessionPayload(session) {
|
|
306
|
+
return {
|
|
307
|
+
configured: authConfigured,
|
|
308
|
+
authenticated: Boolean(session),
|
|
309
|
+
ownerEmail,
|
|
310
|
+
csrfToken: session?.csrfToken || null,
|
|
311
|
+
expiresAt: session ? new Date(session.expiresAt).toISOString() : null
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function acceptsJson(req) {
|
|
316
|
+
return String(req.headers.accept || "").includes("application/json");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function isUnsafeMethod(method) {
|
|
320
|
+
return !["GET", "HEAD", "OPTIONS"].includes(method || "GET");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function originAllowed(req) {
|
|
324
|
+
const origin = req.headers.origin;
|
|
325
|
+
if (!origin) return true;
|
|
326
|
+
return trustedOrigins.has(origin);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function csrfAllowed(req, session) {
|
|
330
|
+
if (!isUnsafeMethod(req.method)) return true;
|
|
331
|
+
const token = String(req.headers["x-csrf-token"] || "");
|
|
332
|
+
return Boolean(token && session?.csrfToken && timingSafeStringEqual(token, session.csrfToken));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function rejectUnauthenticated(req, res) {
|
|
336
|
+
if (req.url?.startsWith("/api/") || acceptsJson(req)) {
|
|
337
|
+
sendJson(res, 401, { ok: false, error: "Authentication required" });
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
redirect(res, "/login");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function publicPath(pathname) {
|
|
344
|
+
return pathname === "/login" || pathname === "/auth.css" || pathname.startsWith("/api/auth/");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function hostAllowed(req) {
|
|
348
|
+
const hostHeader = req.headers.host;
|
|
349
|
+
if (!hostHeader) return true;
|
|
350
|
+
try {
|
|
351
|
+
const hostname = new URL(`http://${hostHeader}`).hostname;
|
|
352
|
+
return allowedHosts.has(hostname);
|
|
353
|
+
} catch {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function htmlEscape(value) {
|
|
359
|
+
return String(value)
|
|
360
|
+
.replaceAll("&", "&")
|
|
361
|
+
.replaceAll("<", "<")
|
|
362
|
+
.replaceAll(">", ">")
|
|
363
|
+
.replaceAll("\"", """);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function sendLoginPage(req, res, url) {
|
|
367
|
+
const session = getSession(req);
|
|
368
|
+
if (session) {
|
|
369
|
+
redirect(res, "/");
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const error = url.searchParams.get("error") === "1";
|
|
373
|
+
const locked = url.searchParams.get("locked") === "1";
|
|
374
|
+
const setup = !authConfigured;
|
|
375
|
+
res.writeHead(200, securityHeaders({
|
|
376
|
+
"content-type": "text/html; charset=utf-8",
|
|
377
|
+
"cache-control": "no-store"
|
|
378
|
+
}));
|
|
379
|
+
res.end(`<!doctype html>
|
|
380
|
+
<html lang="ko">
|
|
381
|
+
<head>
|
|
382
|
+
<meta charset="UTF-8" />
|
|
383
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
384
|
+
<title>Planner Studio Login</title>
|
|
385
|
+
<link rel="stylesheet" href="/auth.css" />
|
|
386
|
+
</head>
|
|
387
|
+
<body>
|
|
388
|
+
<main class="login-shell">
|
|
389
|
+
<section class="login-card" aria-labelledby="login-title">
|
|
390
|
+
<div class="mark">PS</div>
|
|
391
|
+
<p class="eyebrow">Owner access</p>
|
|
392
|
+
<h1 id="login-title">Planner Studio</h1>
|
|
393
|
+
<p class="copy">등록된 소유자 계정으로만 플래너와 Reminders 연동을 사용할 수 있습니다.</p>
|
|
394
|
+
${setup ? `<p class="alert">서버 소유자 비밀번호가 아직 설정되지 않았습니다. <code>.env.local</code>을 먼저 생성하세요.</p>` : ""}
|
|
395
|
+
${error ? `<p class="alert">아이디 또는 비밀번호가 올바르지 않습니다.</p>` : ""}
|
|
396
|
+
${locked ? `<p class="alert">로그인 시도가 잠시 제한되었습니다. 15분 뒤 다시 시도하세요.</p>` : ""}
|
|
397
|
+
<form method="post" action="/api/auth/login">
|
|
398
|
+
<label>
|
|
399
|
+
<span>아이디</span>
|
|
400
|
+
<input name="email" type="email" autocomplete="username" value="${htmlEscape(ownerEmail)}" required />
|
|
401
|
+
</label>
|
|
402
|
+
<label>
|
|
403
|
+
<span>비밀번호</span>
|
|
404
|
+
<input name="password" type="password" autocomplete="current-password" required autofocus />
|
|
405
|
+
</label>
|
|
406
|
+
<button type="submit" ${setup ? "disabled" : ""}>로그인</button>
|
|
407
|
+
</form>
|
|
408
|
+
</section>
|
|
409
|
+
</main>
|
|
410
|
+
</body>
|
|
411
|
+
</html>`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function sendAuthCss(res) {
|
|
415
|
+
res.writeHead(200, securityHeaders({
|
|
416
|
+
"content-type": "text/css; charset=utf-8",
|
|
417
|
+
"cache-control": "no-store"
|
|
418
|
+
}));
|
|
419
|
+
res.end(`:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Apple SD Gothic Neo","Noto Sans KR","Segoe UI",sans-serif;color:#202124;background:#eef2f7}*{box-sizing:border-box}body{margin:0;min-height:100vh}.login-shell{min-height:100vh;display:grid;place-items:center;padding:24px}.login-card{width:min(420px,100%);background:#fff;border:1px solid #d7dbe3;border-radius:8px;padding:28px;box-shadow:0 20px 50px rgba(24,28,38,.14)}.mark{width:46px;height:46px;border:2px solid #202124;border-radius:8px;display:grid;place-items:center;font-weight:900;margin-bottom:18px}.eyebrow{margin:0 0 6px;color:#72737a;font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.04em}h1{margin:0;font-size:30px;line-height:1.08}.copy{color:#72737a;line-height:1.5;margin:12px 0 20px}.alert{border:1px solid #f0b8b8;background:#fff6f6;color:#9c2f2f;border-radius:8px;padding:10px 12px;font-size:13px;line-height:1.45}form{display:grid;gap:12px}label{display:grid;gap:6px;font-size:13px;font-weight:800;color:#4f5159}input,button{font:inherit;border-radius:8px;min-height:42px}input{border:1px solid #d7dbe3;padding:0 12px}button{border:1px solid #202124;background:#202124;color:#fff;font-weight:850;cursor:pointer}button:disabled{opacity:.5;cursor:not-allowed}code{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}`);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function handleAuth(req, res, url) {
|
|
423
|
+
if (req.method === "GET" && url.pathname === "/api/auth/session") {
|
|
424
|
+
sendJson(res, 200, sessionPayload(getSession(req)));
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (req.method === "POST" && url.pathname === "/api/auth/login") {
|
|
429
|
+
const formLogin = String(req.headers["content-type"] || "").startsWith("application/x-www-form-urlencoded");
|
|
430
|
+
if (!authConfigured) {
|
|
431
|
+
if (formLogin) redirect(res, "/login?error=1");
|
|
432
|
+
else sendJson(res, 503, { ok: false, error: "Owner authentication is not configured" });
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
if (!originAllowed(req)) {
|
|
436
|
+
sendJson(res, 403, { ok: false, error: "Origin not allowed" });
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const payload = await readBody(req);
|
|
441
|
+
const email = String(payload.email || "").trim().toLowerCase();
|
|
442
|
+
const password = String(payload.password || "");
|
|
443
|
+
if (isLockedOut(req, email)) {
|
|
444
|
+
if (formLogin) redirect(res, "/login?locked=1");
|
|
445
|
+
else sendJson(res, 429, { ok: false, error: "Too many login attempts" });
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const ok = email === ownerEmail && await verifyPassword(password, passwordHash);
|
|
450
|
+
if (!ok) {
|
|
451
|
+
recordLoginFailure(req, email || ownerEmail);
|
|
452
|
+
if (formLogin) redirect(res, "/login?error=1");
|
|
453
|
+
else sendJson(res, 401, { ok: false, error: "Invalid credentials" });
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
clearLoginFailures(req, email);
|
|
458
|
+
const session = createSession(res);
|
|
459
|
+
if (formLogin) redirect(res, "/");
|
|
460
|
+
else sendJson(res, 200, sessionPayload(session));
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (req.method === "POST" && url.pathname === "/api/auth/logout") {
|
|
465
|
+
const session = getSession(req);
|
|
466
|
+
if (session && !csrfAllowed(req, session)) {
|
|
467
|
+
sendJson(res, 403, { ok: false, error: "CSRF token required" });
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
clearSession(req, res);
|
|
471
|
+
sendJson(res, 200, { ok: true });
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function handleApi(req, res, url) {
|
|
479
|
+
try {
|
|
480
|
+
if (await handleAuth(req, res, url)) return;
|
|
481
|
+
|
|
482
|
+
const session = getSession(req);
|
|
483
|
+
if (!session) {
|
|
484
|
+
rejectUnauthenticated(req, res);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (!originAllowed(req)) {
|
|
488
|
+
sendJson(res, 403, { ok: false, error: "Origin not allowed" });
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (!csrfAllowed(req, session)) {
|
|
492
|
+
sendJson(res, 403, { ok: false, error: "CSRF token required" });
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (req.method === "GET" && url.pathname === "/api/health") {
|
|
497
|
+
const status = await loadStatus();
|
|
498
|
+
sendJson(res, 200, {
|
|
499
|
+
ok: true,
|
|
500
|
+
apiOnly,
|
|
501
|
+
bridgeRoot,
|
|
502
|
+
remindersBridge: existsSync(exportSwift) && existsSync(applySwift),
|
|
503
|
+
googleTasksBridge: existsSync(syncPy),
|
|
504
|
+
googleSyncStatus: status
|
|
505
|
+
});
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (req.method === "GET" && url.pathname === "/api/reminders/lists") {
|
|
510
|
+
const result = await runFile("/usr/bin/swift", [exportSwift, "--lists-only"], { cwd: bridgeRoot });
|
|
511
|
+
sendJson(res, 200, JSON.parse(result.stdout));
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (req.method === "GET" && url.pathname === "/api/reminders") {
|
|
516
|
+
const lookahead = Math.min(Math.max(Number(url.searchParams.get("lookaheadDays") || 14), 0), 3650);
|
|
517
|
+
const commandArgs = [exportSwift, "--lookahead-days", String(lookahead)];
|
|
518
|
+
if (url.searchParams.get("includeUndated") === "1") commandArgs.push("--include-undated");
|
|
519
|
+
const lists = url.searchParams.getAll("list").filter(Boolean);
|
|
520
|
+
for (const list of lists) commandArgs.push("--list", list);
|
|
521
|
+
const result = await runFile("/usr/bin/swift", commandArgs, { cwd: bridgeRoot });
|
|
522
|
+
sendJson(res, 200, JSON.parse(result.stdout));
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (req.method === "POST" && url.pathname === "/api/reminders/apply") {
|
|
527
|
+
const payload = await readBody(req);
|
|
528
|
+
const operations = Array.isArray(payload) ? payload : payload.operations;
|
|
529
|
+
if (!Array.isArray(operations) || operations.length > 100) {
|
|
530
|
+
sendJson(res, 400, { ok: false, error: "Expected operations array with at most 100 items." });
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const result = await runFile("/usr/bin/swift", [applySwift], {
|
|
534
|
+
cwd: bridgeRoot,
|
|
535
|
+
stdin: JSON.stringify(operations)
|
|
536
|
+
});
|
|
537
|
+
sendJson(res, 200, JSON.parse(result.stdout));
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (req.method === "GET" && url.pathname === "/api/google-sync/status") {
|
|
542
|
+
const status = await loadStatus();
|
|
543
|
+
sendJson(res, 200, {
|
|
544
|
+
configured: existsSync(configPath),
|
|
545
|
+
bridgeRoot,
|
|
546
|
+
status
|
|
547
|
+
});
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (req.method === "POST" && url.pathname === "/api/google-sync/run") {
|
|
552
|
+
const payload = await readBody(req);
|
|
553
|
+
const dryRun = payload.dryRun !== false;
|
|
554
|
+
const commandArgs = [syncPy, "--config", configPath, "sync"];
|
|
555
|
+
if (dryRun) commandArgs.push("--dry-run");
|
|
556
|
+
const result = await runFile("/opt/homebrew/bin/python3", commandArgs, {
|
|
557
|
+
cwd: bridgeRoot,
|
|
558
|
+
timeout: 180000
|
|
559
|
+
});
|
|
560
|
+
sendJson(res, 200, {
|
|
561
|
+
ok: true,
|
|
562
|
+
dryRun,
|
|
563
|
+
stdout: result.stdout,
|
|
564
|
+
stderr: result.stderr,
|
|
565
|
+
status: await loadStatus()
|
|
566
|
+
});
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
sendJson(res, 404, { ok: false, error: "Unknown API route" });
|
|
571
|
+
} catch (error) {
|
|
572
|
+
sendJson(res, error.statusCode || 500, { ok: false, error: safeMessage(error) });
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function resolveStaticPath(pathname) {
|
|
577
|
+
let decoded;
|
|
578
|
+
try {
|
|
579
|
+
decoded = decodeURIComponent(pathname);
|
|
580
|
+
} catch {
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
const target = decoded === "/" ? "/index.html" : decoded;
|
|
584
|
+
const filePath = resolve(dist, `.${target}`);
|
|
585
|
+
if (filePath !== dist && !filePath.startsWith(`${dist}${sep}`)) return null;
|
|
586
|
+
return filePath;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async function handleStatic(req, res, url) {
|
|
590
|
+
const requested = resolveStaticPath(url.pathname);
|
|
591
|
+
if (!requested) {
|
|
592
|
+
sendJson(res, 400, { ok: false, error: "Invalid path" });
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
let filePath = requested;
|
|
597
|
+
try {
|
|
598
|
+
const fileStat = await stat(filePath);
|
|
599
|
+
if (fileStat.isDirectory()) filePath = join(filePath, "index.html");
|
|
600
|
+
} catch {
|
|
601
|
+
filePath = join(dist, "index.html");
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const ext = extname(filePath);
|
|
605
|
+
res.writeHead(200, securityHeaders({
|
|
606
|
+
"content-type": mimeTypes[ext] || "application/octet-stream",
|
|
607
|
+
"cache-control": ext === ".html" ? "no-store" : "private, max-age=3600"
|
|
608
|
+
}));
|
|
609
|
+
createReadStream(filePath).pipe(res);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const server = createServer(async (req, res) => {
|
|
613
|
+
if (!hostAllowed(req)) {
|
|
614
|
+
sendJson(res, 400, { ok: false, error: "Host not allowed" });
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
|
|
619
|
+
|
|
620
|
+
if (req.method === "GET" && url.pathname === "/login") {
|
|
621
|
+
sendLoginPage(req, res, url);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
if (req.method === "GET" && url.pathname === "/auth.css") {
|
|
625
|
+
sendAuthCss(res);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
if (url.pathname.startsWith("/api/")) {
|
|
629
|
+
await handleApi(req, res, url);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (apiOnly) {
|
|
633
|
+
sendJson(res, 404, { ok: false, error: "API server only" });
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (!publicPath(url.pathname) && !getSession(req)) {
|
|
637
|
+
rejectUnauthenticated(req, res);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
await handleStatic(req, res, url);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
server.listen(port, host, () => {
|
|
644
|
+
console.log(`Planner server listening on http://${host}:${port}`);
|
|
645
|
+
});
|