e9c7e9df1a
- tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0: release bundle (Next.js app + Shield APK v1.4 + source) - compose/hf-pdocker-02/hobbs-organizer: override (local-disk secrets, watchtower off), .env (port 8340), Shield provisioning script - CLAUDE.md: service tree + internal ports Trusted-LAN mode. Secrets live only on .100 /var/lib/hobbs-organizer/secrets (not in git). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
107 lines
3.4 KiB
JavaScript
107 lines
3.4 KiB
JavaScript
import { access, readFile } from "node:fs/promises";
|
|
import { constants } from "node:fs";
|
|
import { spawn } from "node:child_process";
|
|
import process from "node:process";
|
|
|
|
async function readable(path) {
|
|
try {
|
|
await access(path, constants.R_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function settingFromFile(environmentName, fileEnvironmentName, defaultFile, required = false) {
|
|
const direct = process.env[environmentName];
|
|
if (direct !== undefined) return direct.trim();
|
|
|
|
const configuredFile = process.env[fileEnvironmentName] || defaultFile;
|
|
if (await readable(configuredFile)) return (await readFile(configuredFile, "utf8")).trim();
|
|
if (required) throw new Error(`${environmentName} is required; create ${configuredFile}`);
|
|
return "";
|
|
}
|
|
|
|
function validateCalendarConfiguration(raw) {
|
|
let values;
|
|
try {
|
|
values = JSON.parse(raw);
|
|
} catch {
|
|
throw new Error("Calendar secret must be a JSON array");
|
|
}
|
|
if (!Array.isArray(values) || values.length < 1 || values.length > 10) {
|
|
throw new Error("Calendar secret must contain between 1 and 10 URLs");
|
|
}
|
|
|
|
const normalized = values.map((value) => {
|
|
const url = new URL(String(value));
|
|
if (url.protocol !== "https:" || url.hostname !== "calendar.google.com" || !url.pathname.endsWith("/basic.ics")) {
|
|
throw new Error("Every calendar URL must be an HTTPS calendar.google.com basic.ics URL");
|
|
}
|
|
return url.toString();
|
|
});
|
|
return JSON.stringify([...new Set(normalized)]);
|
|
}
|
|
|
|
async function main() {
|
|
const calendarSecret = await settingFromFile(
|
|
"CALENDAR_ICAL_URLS",
|
|
"CALENDAR_ICAL_URLS_FILE",
|
|
"/run/secrets/calendar_ical_urls",
|
|
true,
|
|
);
|
|
const accessToken = await settingFromFile(
|
|
"ORGANIZER_ACCESS_TOKEN",
|
|
"ORGANIZER_ACCESS_TOKEN_FILE",
|
|
"/run/secrets/organizer_access_token",
|
|
);
|
|
if (accessToken && !/^[A-Za-z0-9._~-]{32,512}$/.test(accessToken)) {
|
|
throw new Error("ORGANIZER_ACCESS_TOKEN must be empty or 32 to 512 URL-safe characters");
|
|
}
|
|
const organizerTimeZone = process.env.ORGANIZER_TIMEZONE || "America/Chicago";
|
|
try {
|
|
new Intl.DateTimeFormat("en-US", { timeZone: organizerTimeZone }).format(new Date());
|
|
} catch {
|
|
throw new Error(`ORGANIZER_TIMEZONE is not a valid IANA timezone: ${organizerTimeZone}`);
|
|
}
|
|
|
|
const environment = {
|
|
...process.env,
|
|
CALENDAR_ICAL_URLS: validateCalendarConfiguration(calendarSecret),
|
|
ORGANIZER_ACCESS_TOKEN: accessToken,
|
|
ORGANIZER_TIMEZONE: organizerTimeZone,
|
|
HOSTNAME: process.env.HOSTNAME || "0.0.0.0",
|
|
PORT: process.env.PORT || "3000",
|
|
};
|
|
|
|
const childOptions = {
|
|
cwd: process.cwd(),
|
|
env: environment,
|
|
stdio: "inherit",
|
|
};
|
|
if (process.platform !== "win32" && typeof process.getuid === "function" && process.getuid() === 0) {
|
|
childOptions.uid = 10001;
|
|
childOptions.gid = 10001;
|
|
}
|
|
|
|
const child = spawn(
|
|
process.execPath,
|
|
["node_modules/next/dist/bin/next", "start", "-H", environment.HOSTNAME, "-p", environment.PORT],
|
|
childOptions,
|
|
);
|
|
const forwardSignal = (signal) => {
|
|
if (!child.killed) child.kill(signal);
|
|
};
|
|
process.on("SIGINT", () => forwardSignal("SIGINT"));
|
|
process.on("SIGTERM", () => forwardSignal("SIGTERM"));
|
|
child.on("exit", (code, signal) => {
|
|
if (signal) process.kill(process.pid, signal);
|
|
else process.exit(code ?? 1);
|
|
});
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(`[organizer] startup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
process.exit(1);
|
|
});
|