feat(hobbs-organizer): v1.1.0 password-protected LAN-only admin for calendar links

- /admin: first-visit password setup (scrypt), signed 30-day session cookie,
  calendars add/remove/reorder/rename/color/hide, masked URLs, per-feed test,
  history (20) with restore; /photo-admin -> /admin#photos placeholder
- settings in local /data (settings.json + auth.json + history/), seeded once
  from the calendar secret; dashboard + health read from settings
- start.mjs drops privileges after reading secrets and runs a front proxy that
  stamps the real client IP; admin refuses non-LAN and proxied requests
- deploy: HOBBS_DATA_DIR=/var/lib/hobbs-organizer/data, image 1.1.0 (1.0.0 kept)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
This commit is contained in:
2026-11-04 18:55:00 -06:00
parent e9c7e9df1a
commit 8715589cbb
28 changed files with 1532 additions and 380 deletions
+2 -2
View File
@@ -68,7 +68,7 @@ hf-pdocker-02 (7.6GB RAM, i7-6700T, HDD)
├── [REMOVED] Actual Budget -- decommissioned 2026-07-13 (data kept at /mnt/docker-data/configs/actual-budget)
├── [COMPOSE] n8n + n8n-db (PostgreSQL) -- workflow automation
├── [COMPOSE] CouchDB (:5984) -- Obsidian LiveSync backend for the Second Brain vault. LAN-only. LOCAL disk (NOT NFS).
├── [COMPOSE] **Hobbs Family Organizer (:8340)** -- Shield TV family dashboard (calendar/weather/headlines/markets), deployed 2026-11-04, replaces the ChatGPT-Sites-hosted `dayline-tv` page. Next.js 16 / Node 22, stateless, image **built locally** (`hobbs-family-organizer:1.0.0`, Watchtower disabled). Source = `tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/`; deploy dir `/mnt/docker-data/compose/hf-pdocker-02/hobbs-organizer` (bundle + `compose.override.yaml` + `.env`, repo copy `compose/hf-pdocker-02/hobbs-organizer/`). **Trusted-LAN mode** (empty token, per user). Secrets on LOCAL `/var/lib/hobbs-organizer/secrets/` (root 600 -- NFS root_squash would block start.mjs). Both Shields (.179, .186) run APK v1.4 pointed at it. See memory `hobbs-family-organizer`.
├── [COMPOSE] **Hobbs Family Organizer (:8340)** -- Shield TV family dashboard (calendar/weather/headlines/markets), deployed 2026-11-04, replaces the ChatGPT-Sites-hosted `dayline-tv` page. Next.js 16 / Node 22, image **built locally** (`hobbs-family-organizer:1.1.0` since 2026-11-04 evening; `1.0.0` kept for rollback; Watchtower disabled). **v1.1.0 adds `/admin`** (password, LAN-only, manages calendar links with masked display/test/history-revert); settings + scrypt password hash live in LOCAL `/var/lib/hobbs-organizer/data` (uid 10001, 700) — calendars now come from `data/settings.json`, the secrets file is only a first-start seed. Inside the container a small front proxy on :3000 stamps the real client IP (Next.js runs on 127.0.0.1:3001) so admin can refuse non-LAN and proxied requests. Password reset: `docker exec -u 10001 hobbs-family-organizer-organizer-1 node container/reset-admin.mjs`. Source = `tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/`; deploy dir `/mnt/docker-data/compose/hf-pdocker-02/hobbs-organizer` (bundle + `compose.override.yaml` + `.env`, repo copy `compose/hf-pdocker-02/hobbs-organizer/`). **Trusted-LAN mode** (empty token, per user). Secrets on LOCAL `/var/lib/hobbs-organizer/secrets/` (root 600 -- NFS root_squash would block start.mjs). Both Shields (.179, .186) run APK v1.4 pointed at it. See memory `hobbs-family-organizer`.
├── [COMPOSE] Farm Agent (:8889)
├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host, **v1.12.1** (was v1.7.0 from 2023 until 2026-10-26)
├── [COMPOSE] Telegraf -- metrics collection to InfluxDB
@@ -195,7 +195,7 @@ compose/ # Standalone Compose definitions (deployed per-node)
- MCPO: :8001
- Trivy: :4954
- CouchDB: :5984 -- Obsidian LiveSync backend. DB `secondbrain`; admin creds in Vaultwarden ("CouchDB - Obsidian LiveSync"). LAN-only (no Traefik route). Data on LOCAL disk `/var/lib/couchdb` (NFS breaks CouchDB file locking). **`local.ini` must be mounted READ-WRITE** -- with `:ro` CouchDB exits 1 and logs nothing. Auth required on everything except `/_up` (left open for the blackbox probe). Vault repo: git.hobbs.farm/jeremy/second-brain (private); vault lives at `C:\Users\Jeremy\second-brain` on the Windows box.
- Hobbs Family Organizer: :8340 -- Shield TV dashboard, `GET /api/health` (JSON, `calendarConfigurationValid`). LAN-only, no auth (trusted-LAN mode), no Traefik route.
- Hobbs Family Organizer: :8340 -- Shield TV dashboard, `GET /api/health` (JSON, `calendarConfigurationValid`, `adminConfigured`). Dashboard open on LAN (trusted-LAN mode); `/admin` is password-protected and LAN-only (direct 192.168.86.0/24 only -- anything arriving via a proxy gets 404, so do NOT add a Traefik route expecting admin to work through it). No Traefik route.
**bart (192.168.86.167):**
- Farm Agent: :8889
@@ -19,3 +19,7 @@ NWS_FORECAST_URL=https://api.weather.gov/gridpoints/TSA/37,98/forecast
# The National Weather Service asks clients to identify themselves. Replace
# the example contact with an address monitored by the server operator.
WEATHER_USER_AGENT="Hobbs-Family-Organizer/1.0 (self-hosted)"
# Admin (v1.1.0)
HOBBS_DATA_DIR=/var/lib/hobbs-organizer/data
ADMIN_ALLOWED_CIDRS=192.168.86.0/24,127.0.0.1/32
@@ -1,5 +1,13 @@
# Hobbs Family Organizer (Shield TV dashboard) — hf-pdocker-02
**v1.1.0 (2026-11-04): admin screen at http://192.168.86.100:8340/admin** — password (set on first visit, LAN only),
calendar links (add/remove/rename/color/hide, masked after save, per-calendar Test, history + Restore).
Settings live in `/var/lib/hobbs-organizer/data/` (local disk, owner 10001, mode 700):
`settings.json` (calendars, incl. URLs — treat as secret), `auth.json` (scrypt hash + session key), `history/` (last 20).
Forgot password: `docker exec -u 10001 hobbs-family-organizer-organizer-1 node container/reset-admin.mjs`.
The secrets-file calendar list is now only a seed used when `settings.json` does not exist yet.
Rollback: set `image: hobbs-family-organizer:1.0.0` (still on .100) — it ignores /data and reads the secrets file.
Deployed 2026-11-04. URL: http://192.168.86.100:8340/ (LAN only, trusted-LAN mode = no token).
- App source / release bundle: `tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/` (repo root).
@@ -27,11 +27,12 @@ ENV NODE_ENV=production \
COPY --from=production-dependencies --chown=10001:10001 /app/node_modules/ ./node_modules/
COPY --from=build --chown=10001:10001 /app/.next-build/ ./.next-build/
COPY --from=build --chown=10001:10001 /app/public/ ./public/
COPY --from=build --chown=10001:10001 /app/container/start.mjs ./container/start.mjs
COPY --from=build --chown=10001:10001 /app/container/start.mjs /app/container/reset-admin.mjs ./container/
COPY --from=build --chown=10001:10001 /app/package.json /app/next.config.mjs ./
RUN mkdir -p /app/.next-build/cache && chown -R 10001:10001 /app/.next-build
RUN mkdir -p /app/.next-build/cache /data && chown -R 10001:10001 /app/.next-build /data && chmod 700 /data
# The tiny supervisor starts as root only long enough to read Docker secrets,
# then launches Next.js as uid/gid 10001. Compose grants only SETUID/SETGID.
# then drops to uid/gid 10001 (Compose grants only SETUID/SETGID), prepares
# /data, runs the LAN-aware front proxy on :3000 and Next.js on 127.0.0.1:3001.
USER 0:0
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
@@ -103,6 +103,30 @@ Authorization: Bearer TOKEN
The Shield wrapper injects this header into every same-origin request. An ordinary browser address bar cannot add it, so browser access in bearer mode should go through an authenticated reverse proxy that injects the header. The configuration scripts intentionally do not print the token.
## Admin screen (v1.1.0)
Open `http://SERVER-IP:8080/admin` from a phone or laptop on the home network.
- **First visit:** choose the admin password. This works only while no password exists, and only from an allowed network.
- **Calendars tab:** add, remove, reorder, rename, recolor or hide calendars.
- Saved addresses are masked and never shown again.
- **Test** fetches a feed and reports its name and how many events are upcoming.
- Every save keeps the previous version. **History → Restore** brings any of the last 20 back.
- **Account tab:** change the password (this signs out other devices) or sign out.
- **Forgot the password:** `docker exec -u 10001 <container> node container/reset-admin.mjs`, then visit `/admin` again.
Settings live in the `/data` volume (`HOBBS_DATA_DIR`, default `./data`). It must be writable by uid 10001 and should be on local disk.
| File | Contents |
| --- | --- |
| `settings.json` | Calendars, including their private URLs. Treat it like a secret. |
| `auth.json` | The scrypt password hash and the session key. |
| `history/` | The last 20 saved versions. |
The `calendar_ical_urls` secret is now optional. It is used only to seed `settings.json` on first start.
Admin is refused unless the TCP client address is inside `ADMIN_ALLOWED_CIDRS`. It is also refused, with a 404, for any request carrying `X-Forwarded-For`, `Forwarded` or `X-Real-IP`, so it cannot be reached through a reverse proxy. The container's own front proxy (port 3000, with Next.js on 127.0.0.1:3001) records the real client address, and forged copies of that header are discarded.
## Configuration
Edit `.env` for nonsecret settings:
@@ -1,3 +1,10 @@
# 1.1.0 (2026-11-04)
- Password-protected, LAN-only `/admin` screen for calendar links (masked, test, history/restore).
- Calendars are read from `/data/settings.json` (seeded once from the calendar secret).
- The supervisor drops privileges right after reading secrets. A front proxy on :3000 records the real client IP.
- `/photo-admin` redirects to `/admin#photos`, a placeholder for the upcoming photo frame.
# Release 1.0.0
Built and validated on 2026-11-04.
@@ -1 +1 @@
1.0.0
1.1.0
@@ -0,0 +1,167 @@
import { createHmac, randomBytes, scrypt as scryptCallback, timingSafeEqual } from "node:crypto";
import { promisify } from "node:util";
import { readAuth, writeAuth } from "./store";
const scrypt = promisify(scryptCallback) as (password: string, salt: Buffer, keylen: number, options: object) => Promise<Buffer>;
const SCRYPT = { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
const SESSION_COOKIE = "hfo_admin";
const SESSION_SECONDS = 30 * 24 * 60 * 60;
export const MIN_PASSWORD_LENGTH = 8;
// ---------- network policy ----------
function ipv4ToInt(ip: string) {
const parts = ip.split(".").map(Number);
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
}
function inCidr(ip: string, cidr: string) {
const [base, bitsText] = cidr.trim().split("/");
const address = ipv4ToInt(ip);
const network = ipv4ToInt(base);
const bits = Number(bitsText ?? 32);
if (address === null || network === null || !(bits >= 0 && bits <= 32)) return false;
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
return (address & mask) === (network & mask);
}
/**
* Admin is reachable only from a direct LAN connection. The container's front proxy
* stamps x-hfo-client-ip with the TCP peer and x-hfo-forwarded when the request came
* through another proxy (e.g. a future Traefik route), which is always refused.
*/
export function isAdminNetworkAllowed(headers: Headers) {
if (headers.get("x-hfo-forwarded")) return false;
const ip = headers.get("x-hfo-client-ip") || "";
const allowed = (process.env.ADMIN_ALLOWED_CIDRS || "192.168.86.0/24,127.0.0.1/32").split(",");
return allowed.some((cidr) => inCidr(ip, cidr));
}
export function clientIp(headers: Headers) {
return headers.get("x-hfo-client-ip") || "unknown";
}
/** Mutating requests must come from this same origin (CSRF guard on top of SameSite=Strict). */
export function sameOrigin(request: Request) {
const origin = request.headers.get("origin");
const host = request.headers.get("host");
if (!origin || !host) return false;
try {
return new URL(origin).host === host;
} catch {
return false;
}
}
// ---------- passwords ----------
export async function hashPassword(password: string) {
const salt = randomBytes(16);
const key = await scrypt(password, salt, 32, SCRYPT);
return `scrypt$${SCRYPT.N}$${SCRYPT.r}$${SCRYPT.p}$${salt.toString("base64url")}$${key.toString("base64url")}`;
}
export async function verifyPassword(password: string, stored: string) {
const [scheme, n, r, p, saltText, keyText] = stored.split("$");
if (scheme !== "scrypt" || !saltText || !keyText) return false;
const expected = Buffer.from(keyText, "base64url");
const actual = await scrypt(password, Buffer.from(saltText, "base64url"), expected.length, {
N: Number(n),
r: Number(r),
p: Number(p),
maxmem: 64 * 1024 * 1024,
});
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
// ---------- login throttling (in memory, per client IP) ----------
const failures = new Map<string, { count: number; lockedUntil: number }>();
export function loginLockedFor(ip: string) {
const entry = failures.get(ip);
return entry && entry.lockedUntil > Date.now() ? Math.ceil((entry.lockedUntil - Date.now()) / 1000) : 0;
}
export function recordLoginFailure(ip: string) {
const entry = failures.get(ip) ?? { count: 0, lockedUntil: 0 };
entry.count += 1;
if (entry.count >= 5) entry.lockedUntil = Date.now() + Math.min(15 * 60, 30 * 2 ** (entry.count - 5)) * 1000;
failures.set(ip, entry);
}
export function clearLoginFailures(ip: string) {
failures.delete(ip);
}
// ---------- sessions ----------
function sign(payload: string, secret: string) {
return createHmac("sha256", secret).update(payload).digest("base64url");
}
export function sessionCookie(secret: string) {
const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + SESSION_SECONDS })).toString("base64url");
const value = `${payload}.${sign(payload, secret)}`;
return `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${SESSION_SECONDS}`;
}
export function clearedSessionCookie() {
return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`;
}
function readCookie(headers: Headers, name: string) {
for (const part of (headers.get("cookie") || "").split(";")) {
const [key, ...rest] = part.trim().split("=");
if (key === name) return rest.join("=");
}
return "";
}
export async function hasValidSession(headers: Headers) {
const value = readCookie(headers, SESSION_COOKIE);
const [payload, signature] = value.split(".");
if (!payload || !signature) return false;
const auth = await readAuth();
if (!auth.passwordHash) return false;
const expected = Buffer.from(sign(payload, auth.sessionSecret));
const actual = Buffer.from(signature);
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return false;
try {
const { exp } = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { exp: number };
return typeof exp === "number" && exp > Date.now() / 1000;
} catch {
return false;
}
}
/** Sets a new password and rotates the session secret, signing out every other session. */
export async function setPassword(password: string) {
const sessionSecret = randomBytes(32).toString("base64url");
await writeAuth({ passwordHash: await hashPassword(password), sessionSecret });
return sessionCookie(sessionSecret);
}
// ---------- route helpers ----------
export function json(body: unknown, status = 200, extraHeaders: Record<string, string> = {}) {
return Response.json(body, { status, headers: { "Cache-Control": "no-store", ...extraHeaders } });
}
/** Returns an error Response when the caller may not use admin APIs, otherwise null. */
export async function requireAdmin(request: Request, { mutating = false } = {}) {
if (!isAdminNetworkAllowed(request.headers)) return json({ error: "Admin is only available on the home network." }, 403);
if (mutating && !sameOrigin(request)) return json({ error: "Cross-origin request refused." }, 403);
if (!(await hasValidSession(request.headers))) return json({ error: "Sign in required." }, 401);
return null;
}
export async function readJsonBody<T>(request: Request): Promise<T | null> {
if (!(request.headers.get("content-type") || "").includes("application/json")) return null;
try {
return (await request.json()) as T;
} catch {
return null;
}
}
@@ -0,0 +1,232 @@
import ICAL from "ical.js";
import type { CalendarTone } from "./store";
export type CalendarEvent = {
id: string;
calendar: string;
title: string;
location?: string;
start: string;
end: string;
allDay: boolean;
tone: CalendarTone;
};
const maxFeedBytes = 5 * 1024 * 1024;
const maxOccurrencesPerEvent = 10_000;
function cleanText(value: unknown, fallback: string, maxLength: number) {
return String(value ?? fallback).replace(/\s+/g, " ").trim().slice(0, maxLength) || fallback;
}
function calendarDate(value: ICAL.Time) {
return `${String(value.year).padStart(4, "0")}-${String(value.month).padStart(2, "0")}-${String(value.day).padStart(2, "0")}`;
}
export function organizerDate(date: Date) {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: process.env.ORGANIZER_TIMEZONE || "America/Chicago",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const value = (part: string) => parts.find((entry) => entry.type === part)?.value ?? "";
return `${value("year")}-${value("month")}-${value("day")}`;
}
export function addCalendarDays(isoDate: string, days: number) {
const [year, month, day] = isoDate.split("-").map(Number);
const value = new Date(Date.UTC(year, month - 1, day + days));
return value.toISOString().slice(0, 10);
}
function hashId(value: string) {
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(36);
}
async function readLimitedText(response: Response) {
const declaredLength = Number(response.headers.get("content-length") ?? 0);
if (declaredLength > maxFeedBytes) throw new Error("Calendar feed is too large");
if (!response.body) return "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
let total = 0;
let text = "";
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
total += chunk.value.byteLength;
if (total > maxFeedBytes) {
await reader.cancel();
throw new Error("Calendar feed is too large");
}
text += decoder.decode(chunk.value, { stream: true });
}
return text + decoder.decode();
}
function componentString(component: ICAL.Component, name: string) {
const value = component.getFirstPropertyValue(name);
return typeof value === "string" ? value : "";
}
function isCancelled(event: ICAL.Event) {
return componentString(event.component, "status").toUpperCase() === "CANCELLED";
}
function detach(component: ICAL.Component) {
return new ICAL.Component(structuredClone(component.toJSON()));
}
function eventIsInWindow(
start: ICAL.Time,
end: ICAL.Time,
windowStart: number,
horizon: number,
windowStartDate: string,
horizonDate: string,
) {
if (start.isDate) {
return calendarDate(end) > windowStartDate && calendarDate(start) <= horizonDate;
}
return end.toJSDate().getTime() > windowStart && start.toJSDate().getTime() <= horizon;
}
function occurrenceIsPastHorizon(start: ICAL.Time, horizon: number, horizonDate: string) {
return start.isDate ? calendarDate(start) > horizonDate : start.toJSDate().getTime() > horizon;
}
function serializeOccurrence(
event: ICAL.Event,
start: ICAL.Time,
end: ICAL.Time,
calendar: string,
tone: CalendarTone,
feedIndex: number,
) {
const allDay = start.isDate;
const startValue = allDay ? calendarDate(start) : start.toJSDate().toISOString();
const endValue = allDay ? calendarDate(end) : end.toJSDate().toISOString();
const uid = cleanText(event.uid, "event", 180);
const location = cleanText(event.location, "", 120);
return {
id: hashId(`${feedIndex}|${uid}|${startValue}`),
calendar,
title: cleanText(event.summary, "Busy", 140),
...(location ? { location } : {}),
start: startValue,
end: endValue,
allDay,
tone,
} satisfies CalendarEvent;
}
export type FeedOptions = { feedIndex: number; tone: CalendarTone; nameOverride: string };
function expandCalendar(
ics: string,
{ feedIndex, tone, nameOverride }: FeedOptions,
windowStart: number,
horizon: number,
windowStartDate: string,
horizonDate: string,
) {
const root = new ICAL.Component(ICAL.parse(ics));
for (const timezone of root.getAllSubcomponents("vtimezone")) {
try {
ICAL.TimezoneService.register(timezone);
} catch {
// A feed may repeat a timezone that was already registered.
}
}
const sourceName = cleanText(root.getFirstPropertyValue("x-wr-calname"), `Calendar ${feedIndex + 1}`, 50);
const calendar = nameOverride || (sourceName.includes("@") ? "Personal" : sourceName);
const components = root.getAllSubcomponents("vevent");
const exceptions = new Map<string, ICAL.Component[]>();
for (const component of components) {
if (!component.hasProperty("recurrence-id")) continue;
const uid = componentString(component, "uid");
if (!uid) continue;
exceptions.set(uid, [...(exceptions.get(uid) ?? []), component]);
}
const items: CalendarEvent[] = [];
for (const component of components) {
if (component.hasProperty("recurrence-id")) continue;
try {
const event = new ICAL.Event(detach(component));
for (const exception of exceptions.get(event.uid) ?? []) {
event.relateException(detach(exception));
}
if (isCancelled(event)) continue;
if (!event.isRecurring()) {
if (eventIsInWindow(event.startDate, event.endDate, windowStart, horizon, windowStartDate, horizonDate)) {
items.push(serializeOccurrence(event, event.startDate, event.endDate, calendar, tone, feedIndex));
}
continue;
}
const iterator = event.iterator();
for (let count = 0; count < maxOccurrencesPerEvent; count += 1) {
const occurrence = iterator.next();
if (!occurrence) break;
if (occurrenceIsPastHorizon(occurrence, horizon, horizonDate)) break;
const details = event.getOccurrenceDetails(occurrence);
if (isCancelled(details.item)) continue;
if (eventIsInWindow(details.startDate, details.endDate, windowStart, horizon, windowStartDate, horizonDate)) {
items.push(serializeOccurrence(details.item, details.startDate, details.endDate, calendar, tone, feedIndex));
}
}
} catch {
// Ignore a malformed event while keeping the rest of the calendar usable.
}
}
return { calendar, sourceName, eventCount: components.length, items };
}
export async function readCalendar(
url: string,
options: FeedOptions,
windowStart: number,
horizon: number,
windowStartDate: string,
horizonDate: string,
) {
let currentUrl = url;
let response: Response | undefined;
for (let redirectCount = 0; redirectCount <= 3; redirectCount += 1) {
const validatedUrl = new URL(currentUrl);
if (validatedUrl.protocol !== "https:" || validatedUrl.hostname !== "calendar.google.com" || !validatedUrl.pathname.endsWith("/basic.ics")) {
throw new Error("Calendar redirect host is not allowed");
}
response = await fetch(validatedUrl, {
headers: { Accept: "text/calendar" },
redirect: "manual",
signal: AbortSignal.timeout(10_000),
});
if (![301, 302, 303, 307, 308].includes(response.status)) break;
const location = response.headers.get("location");
if (!location || redirectCount === 3) throw new Error("Calendar feed redirected too many times");
currentUrl = new URL(location, validatedUrl).toString();
}
if (!response) throw new Error("Calendar feed request failed");
if (!response.ok) throw new Error("Calendar feed request failed");
return expandCalendar(await readLimitedText(response), options, windowStart, horizon, windowStartDate, horizonDate);
}
@@ -0,0 +1,165 @@
import { randomUUID } from "node:crypto";
import { readdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
export const CALENDAR_TONES = ["cyan", "amber", "violet", "emerald", "rose"] as const;
export type CalendarTone = (typeof CALENDAR_TONES)[number];
export const MAX_CALENDARS = 10;
const HISTORY_LIMIT = 20;
export type CalendarSetting = {
id: string;
/** Display name override; empty means "use the feed's own name". */
name: string;
url: string;
tone: CalendarTone;
enabled: boolean;
};
export type Settings = {
schema: 1;
updatedAt: string;
calendars: CalendarSetting[];
};
export type AuthState = {
passwordHash: string;
sessionSecret: string;
};
const dataDir = () => process.env.ORGANIZER_DATA_DIR || "/data";
const settingsPath = () => path.join(dataDir(), "settings.json");
const authPath = () => path.join(dataDir(), "auth.json");
const historyDir = () => path.join(dataDir(), "history");
export function validCalendarUrl(value: unknown) {
let url: URL;
try {
url = new URL(String(value).trim());
} catch {
return null;
}
if (url.protocol !== "https:" || url.hostname !== "calendar.google.com" || !url.pathname.endsWith("/basic.ics")) return null;
if (!/^\/calendar\/ical\/[A-Za-z0-9._~%+@-]+\/(private-[A-Za-z0-9._~%+@-]+|public)\/basic\.ics$/.test(url.pathname)) return null;
return url.toString();
}
/** "…/ical/fam…%40group…/private-••••3854/basic.ics" — enough to tell feeds apart, not enough to use one. */
export function maskCalendarUrl(url: string) {
const match = /\/calendar\/ical\/([^/]+)\/(private-[^/]+|public)\/basic\.ics$/.exec(new URL(url).pathname);
if (!match) return "calendar.google.com/…";
const owner = decodeURIComponent(match[1]);
const shortOwner = owner.length > 18 ? `${owner.slice(0, 8)}…${owner.slice(owner.indexOf("@"))}` : owner;
const key = match[2] === "public" ? "public" : `private-••••${match[2].slice(-4)}`;
return `${shortOwner} · ${key}`;
}
function normalizeSettings(raw: unknown): Settings {
const value = (raw ?? {}) as Partial<Settings>;
const calendars = Array.isArray(value.calendars) ? value.calendars : [];
return {
schema: 1,
updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : new Date(0).toISOString(),
calendars: calendars
.map((entry) => {
const url = validCalendarUrl(entry?.url);
if (!url) return null;
return {
id: typeof entry.id === "string" && entry.id ? entry.id : randomUUID(),
name: typeof entry.name === "string" ? entry.name.trim().slice(0, 50) : "",
url,
tone: CALENDAR_TONES.includes(entry.tone as CalendarTone) ? (entry.tone as CalendarTone) : "cyan",
enabled: entry.enabled !== false,
} satisfies CalendarSetting;
})
.filter((entry): entry is CalendarSetting => entry !== null)
.slice(0, MAX_CALENDARS),
};
}
async function readJson(file: string) {
return JSON.parse(await readFile(file, "utf8")) as unknown;
}
async function writeJsonAtomic(file: string, value: unknown) {
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
await rename(temporary, file);
}
// All writes go through one queue so concurrent saves cannot interleave.
let writeQueue: Promise<unknown> = Promise.resolve();
function serialized<T>(task: () => Promise<T>) {
const run = writeQueue.then(task, task);
writeQueue = run.catch(() => undefined);
return run;
}
export async function readSettings(): Promise<Settings> {
try {
return normalizeSettings(await readJson(settingsPath()));
} catch {
return normalizeSettings({});
}
}
export type HistoryEntry = { id: string; savedAt: string; calendarCount: number; note: string };
async function pruneHistory() {
const files = (await readdir(historyDir())).filter((name) => name.endsWith(".json")).sort();
for (const name of files.slice(0, Math.max(0, files.length - HISTORY_LIMIT))) {
await unlink(path.join(historyDir(), name)).catch(() => undefined);
}
}
/** Saves new settings, first snapshotting the current ones into history. */
export function saveSettings(calendars: CalendarSetting[], note: string) {
return serialized(async () => {
const current = await readSettings();
const stamp = new Date().toISOString();
const snapshotId = `${stamp.replace(/[:.]/g, "-")}`;
await writeJsonAtomic(path.join(historyDir(), `${snapshotId}.json`), { ...current, note });
await pruneHistory();
const next = normalizeSettings({ schema: 1, updatedAt: stamp, calendars });
await writeJsonAtomic(settingsPath(), next);
return next;
});
}
export async function listHistory(): Promise<HistoryEntry[]> {
const files = (await readdir(historyDir()).catch(() => [] as string[])).filter((name) => name.endsWith(".json")).sort().reverse();
const entries: HistoryEntry[] = [];
for (const name of files) {
try {
const raw = (await readJson(path.join(historyDir(), name))) as Settings & { note?: string };
entries.push({
id: name.slice(0, -5),
savedAt: raw.updatedAt,
calendarCount: Array.isArray(raw.calendars) ? raw.calendars.length : 0,
note: typeof raw.note === "string" ? raw.note : "",
});
} catch {
// Skip an unreadable snapshot.
}
}
return entries;
}
export async function readHistorySnapshot(id: string) {
if (!/^[0-9TZ-]+$/.test(id)) return null;
try {
return normalizeSettings(await readJson(path.join(historyDir(), `${id}.json`)));
} catch {
return null;
}
}
export async function readAuth(): Promise<AuthState> {
const raw = (await readJson(authPath())) as Partial<AuthState>;
if (typeof raw.sessionSecret !== "string" || raw.sessionSecret.length < 32) throw new Error("auth.json is missing a session secret");
return { passwordHash: typeof raw.passwordHash === "string" ? raw.passwordHash : "", sessionSecret: raw.sessionSecret };
}
export function writeAuth(next: AuthState) {
return serialized(() => writeJsonAtomic(authPath(), next));
}
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Organizer Admin",
robots: { index: false, follow: false },
};
export default function AdminLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return children;
}
@@ -0,0 +1,463 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
type Tone = "cyan" | "amber" | "violet" | "emerald" | "rose";
const TONES: Tone[] = ["cyan", "amber", "violet", "emerald", "rose"];
const TONE_COLOR: Record<Tone, string> = {
cyan: "#67e8f9",
amber: "#fbbf24",
violet: "#a78bfa",
emerald: "#34d399",
rose: "#fb7185",
};
type SavedCalendar = { id: string; name: string; tone: Tone; enabled: boolean; masked: string };
type TestResult = { ok: boolean; sourceName?: string; eventCount?: number; upcoming?: number; error?: string };
type DraftCalendar = {
key: string;
id?: string;
name: string;
tone: Tone;
enabled: boolean;
masked?: string;
newUrl: string;
replacing: boolean;
test?: TestResult | "running";
};
type HistoryEntry = { id: string; savedAt: string; calendarCount: number; note: string };
type Tab = "calendars" | "photos" | "account";
async function api<T>(path: string, init?: RequestInit & { body?: string }) {
const response = await fetch(path, {
...init,
credentials: "same-origin",
cache: "no-store",
headers: init?.body ? { "Content-Type": "application/json" } : undefined,
});
const data = (await response.json().catch(() => ({}))) as T & { error?: string };
return { status: response.status, ok: response.ok, data };
}
function toDraft(calendar: SavedCalendar): DraftCalendar {
return { key: calendar.id, ...calendar, newUrl: "", replacing: false };
}
function formatWhen(iso: string) {
const date = new Date(iso);
if (Number.isNaN(date.getTime()) || date.getTime() === 0) return "initial setup";
return date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
}
const inputClass =
"w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:border-cyan-300/60 focus:outline-none";
const buttonClass =
"rounded-lg px-3 py-2 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-40";
const primaryButton = `${buttonClass} bg-cyan-300 text-slate-950 hover:bg-cyan-200`;
const ghostButton = `${buttonClass} border border-white/10 bg-white/5 text-slate-200 hover:bg-white/10`;
const dangerButton = `${buttonClass} border border-rose-400/30 bg-rose-400/10 text-rose-200 hover:bg-rose-400/20`;
function PasswordForm({ mode, onDone }: { mode: "setup" | "login"; onDone: () => void }) {
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
setError("");
if (mode === "setup" && password !== confirm) {
setError("The two passwords don't match.");
return;
}
setBusy(true);
const result = await api<{ ok?: boolean }>(`/api/admin/${mode}`, { method: "POST", body: JSON.stringify({ password }) });
setBusy(false);
if (result.ok) onDone();
else setError(result.data.error || "Something went wrong.");
};
return (
<form onSubmit={submit} className="mx-auto mt-16 max-w-sm space-y-4 rounded-2xl border border-white/10 bg-[#0c121d] p-6">
<div>
<h1 className="text-lg font-bold">{mode === "setup" ? "Set the admin password" : "Organizer admin"}</h1>
<p className="mt-1 text-sm text-slate-400">
{mode === "setup"
? "No password exists yet. Choose one now. You'll need it to change calendars and photos."
: "Sign in to manage calendars and photos."}
</p>
</div>
<input
className={inputClass}
type="password"
autoComplete={mode === "setup" ? "new-password" : "current-password"}
placeholder="Password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoFocus
/>
{mode === "setup" && (
<input
className={inputClass}
type="password"
autoComplete="new-password"
placeholder="Confirm password"
value={confirm}
onChange={(event) => setConfirm(event.target.value)}
/>
)}
{error && <p className="text-sm text-rose-300">{error}</p>}
<button className={`${primaryButton} w-full`} disabled={busy || password.length < 1}>
{busy ? "…" : mode === "setup" ? "Save password" : "Sign in"}
</button>
</form>
);
}
function TestBadge({ test }: { test?: TestResult | "running" }) {
if (!test) return null;
if (test === "running") return <span className="text-xs text-slate-400">Testing…</span>;
if (!test.ok) return <span className="text-xs text-rose-300">✕ {test.error || "Failed"}</span>;
return (
<span className="text-xs text-emerald-300">
✓ {test.sourceName} · {test.upcoming} upcoming
</span>
);
}
function CalendarsTab() {
const [drafts, setDrafts] = useState<DraftCalendar[]>([]);
const [saved, setSaved] = useState<SavedCalendar[]>([]);
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
const [busy, setBusy] = useState(false);
const [loaded, setLoaded] = useState(false);
const load = useCallback(async () => {
const [calendars, versions] = await Promise.all([
api<{ calendars: SavedCalendar[] }>("/api/admin/calendars"),
api<{ history: HistoryEntry[] }>("/api/admin/history"),
]);
if (calendars.ok) {
setSaved(calendars.data.calendars);
setDrafts(calendars.data.calendars.map(toDraft));
}
if (versions.ok) setHistory(versions.data.history);
setLoaded(true);
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial fetch on mount
void load();
}, [load]);
const update = (key: string, patch: Partial<DraftCalendar>) =>
setDrafts((current) => current.map((draft) => (draft.key === key ? { ...draft, ...patch } : draft)));
const dirty =
drafts.length !== saved.length ||
drafts.some((draft, index) => {
const original = saved[index];
return (
!original ||
draft.id !== original.id ||
draft.name !== original.name ||
draft.tone !== original.tone ||
draft.enabled !== original.enabled ||
draft.newUrl.trim() !== ""
);
});
const addCalendar = () =>
setDrafts((current) => [
...current,
{
key: `new-${Date.now()}`,
name: "",
tone: TONES[current.length % TONES.length],
enabled: true,
newUrl: "",
replacing: true,
},
]);
const move = (index: number, delta: number) =>
setDrafts((current) => {
const next = [...current];
const target = index + delta;
if (target < 0 || target >= next.length) return current;
[next[index], next[target]] = [next[target], next[index]];
return next;
});
const test = async (draft: DraftCalendar) => {
update(draft.key, { test: "running" });
const body = draft.newUrl.trim() ? { url: draft.newUrl.trim() } : { id: draft.id };
const result = await api<TestResult>("/api/admin/calendars/test", { method: "POST", body: JSON.stringify(body) });
update(draft.key, { test: result.data.ok !== undefined ? result.data : { ok: false, error: result.data.error } });
};
const save = async () => {
setBusy(true);
setMessage(null);
const result = await api<{ calendars: SavedCalendar[] }>("/api/admin/calendars", {
method: "PUT",
body: JSON.stringify({
calendars: drafts.map((draft) => ({
id: draft.id,
name: draft.name,
tone: draft.tone,
enabled: draft.enabled,
...(draft.newUrl.trim() ? { url: draft.newUrl.trim() } : {}),
})),
}),
});
setBusy(false);
if (result.ok) {
setMessage({ tone: "ok", text: "Saved. The TVs pick up calendar changes within about 5 minutes." });
await load();
} else {
setMessage({ tone: "error", text: result.data.error || "Save failed." });
}
};
const revert = async (entry: HistoryEntry) => {
if (!window.confirm(`Restore the calendars as they were on ${formatWhen(entry.savedAt)}? The current list is kept in history.`)) return;
const result = await api<{ ok: boolean }>("/api/admin/history", { method: "POST", body: JSON.stringify({ id: entry.id }) });
setMessage(result.ok ? { tone: "ok", text: "Restored." } : { tone: "error", text: result.data.error || "Restore failed." });
await load();
};
if (!loaded) return <p className="text-sm text-slate-400">Loading…</p>;
return (
<div className="space-y-6">
<section className="space-y-3">
{drafts.length === 0 && <p className="text-sm text-slate-400">No calendars yet. Add one below.</p>}
{drafts.map((draft, index) => (
<article key={draft.key} className="space-y-3 rounded-xl border border-white/10 bg-[#0c121d] p-4">
<div className="flex items-center gap-3">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ background: TONE_COLOR[draft.tone] }} />
<input
className={inputClass}
placeholder="Name (blank = use the calendar's own name)"
value={draft.name}
maxLength={50}
onChange={(event) => update(draft.key, { name: event.target.value })}
/>
<label className="flex shrink-0 items-center gap-2 text-xs text-slate-300">
<input
type="checkbox"
checked={draft.enabled}
onChange={(event) => update(draft.key, { enabled: event.target.checked })}
/>
Shown
</label>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-slate-400">Color</span>
{TONES.map((tone) => (
<button
key={tone}
type="button"
aria-label={tone}
onClick={() => update(draft.key, { tone })}
className={`h-6 w-6 rounded-full border-2 ${draft.tone === tone ? "border-white" : "border-transparent"}`}
style={{ background: TONE_COLOR[tone] }}
/>
))}
</div>
{draft.id && !draft.replacing ? (
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-400">
<code className="rounded bg-white/5 px-2 py-1 text-slate-300">{draft.masked}</code>
<button type="button" className="text-cyan-300 underline-offset-2 hover:underline" onClick={() => update(draft.key, { replacing: true })}>
Replace address
</button>
</div>
) : (
<input
className={inputClass}
placeholder="https://calendar.google.com/calendar/ical/…/basic.ics"
autoComplete="off"
spellCheck={false}
value={draft.newUrl}
onChange={(event) => update(draft.key, { newUrl: event.target.value, test: undefined })}
/>
)}
<div className="flex flex-wrap items-center gap-2">
<button type="button" className={ghostButton} onClick={() => test(draft)} disabled={!draft.id && !draft.newUrl.trim()}>
Test
</button>
<button type="button" className={ghostButton} onClick={() => move(index, -1)} disabled={index === 0} aria-label="Move up">
↑
</button>
<button type="button" className={ghostButton} onClick={() => move(index, 1)} disabled={index === drafts.length - 1} aria-label="Move down">
↓
</button>
<button
type="button"
className={dangerButton}
onClick={() => setDrafts((current) => current.filter((entry) => entry.key !== draft.key))}
>
Remove
</button>
<TestBadge test={draft.test} />
</div>
</article>
))}
<div className="flex flex-wrap items-center gap-3">
<button type="button" className={ghostButton} onClick={addCalendar} disabled={drafts.length >= 10}>
+ Add calendar
</button>
<button type="button" className={primaryButton} onClick={save} disabled={!dirty || busy}>
{busy ? "Saving…" : "Save changes"}
</button>
{dirty && (
<button type="button" className={ghostButton} onClick={() => setDrafts(saved.map(toDraft))}>
Discard
</button>
)}
</div>
{message && <p className={`text-sm ${message.tone === "ok" ? "text-emerald-300" : "text-rose-300"}`}>{message.text}</p>}
<p className="text-xs leading-relaxed text-slate-500">
In Google Calendar: Settings → pick the calendar → <em>Integrate calendar</em> → copy the{" "}
<em>Secret address in iCal format</em>. Saved addresses are never shown again. They work like passwords.
</p>
</section>
<section className="space-y-2">
<h2 className="text-sm font-bold uppercase tracking-wider text-slate-400">History</h2>
{history.length === 0 ? (
<p className="text-sm text-slate-500">No earlier versions yet.</p>
) : (
<ul className="divide-y divide-white/5 rounded-xl border border-white/10 bg-[#0c121d]">
{history.map((entry) => (
<li key={entry.id} className="flex items-center justify-between gap-3 px-4 py-2 text-sm">
<span className="text-slate-300">
{formatWhen(entry.savedAt)} <span className="text-slate-500">· {entry.calendarCount} calendar(s)</span>
</span>
<button type="button" className={ghostButton} onClick={() => revert(entry)}>
Restore
</button>
</li>
))}
</ul>
)}
</section>
</div>
);
}
function AccountTab({ onSignedOut }: { onSignedOut: () => void }) {
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
const change = async (event: React.FormEvent) => {
event.preventDefault();
const result = await api("/api/admin/password", {
method: "POST",
body: JSON.stringify({ currentPassword: current, newPassword: next }),
});
setMessage(result.ok ? { tone: "ok", text: "Password changed. Other devices were signed out." } : { tone: "error", text: result.data.error || "Failed." });
if (result.ok) {
setCurrent("");
setNext("");
}
};
const signOut = async () => {
await api("/api/admin/logout", { method: "POST" });
onSignedOut();
};
return (
<div className="space-y-6">
<form onSubmit={change} className="max-w-sm space-y-3 rounded-xl border border-white/10 bg-[#0c121d] p-4">
<h2 className="text-sm font-bold">Change password</h2>
<input className={inputClass} type="password" autoComplete="current-password" placeholder="Current password" value={current} onChange={(event) => setCurrent(event.target.value)} />
<input className={inputClass} type="password" autoComplete="new-password" placeholder="New password (8+ characters)" value={next} onChange={(event) => setNext(event.target.value)} />
<button className={primaryButton} disabled={!current || next.length < 8}>
Change password
</button>
{message && <p className={`text-sm ${message.tone === "ok" ? "text-emerald-300" : "text-rose-300"}`}>{message.text}</p>}
</form>
<button type="button" className={ghostButton} onClick={signOut}>
Sign out
</button>
<p className="text-xs text-slate-500">
Forgot the password? On the server: <code>docker exec -u 10001 hobbs-family-organizer-organizer-1 node container/reset-admin.mjs</code>
</p>
</div>
);
}
export default function AdminPage() {
const [state, setState] = useState<"loading" | "setup" | "login" | "ready" | "blocked">("loading");
const [tab, setTab] = useState<Tab>("calendars");
const check = useCallback(async () => {
const result = await api<{ setupRequired: boolean; authenticated: boolean }>("/api/admin/status");
if (result.status === 403) setState("blocked");
else if (result.data.setupRequired) setState("setup");
else setState(result.data.authenticated ? "ready" : "login");
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial fetch on mount
void check();
const fromHash = window.location.hash.replace("#", "") as Tab;
if (["calendars", "photos", "account"].includes(fromHash)) setTab(fromHash);
}, [check]);
const selectTab = (next: Tab) => {
setTab(next);
window.history.replaceState(null, "", `#${next}`);
};
return (
<div className="fixed inset-0 overflow-y-auto bg-[#070b12] text-slate-100">
<main className="mx-auto max-w-2xl px-4 py-6">
{state === "loading" && <p className="text-sm text-slate-400">Loading…</p>}
{state === "blocked" && <p className="mt-16 text-center text-sm text-slate-400">Admin is only available on the home network.</p>}
{(state === "setup" || state === "login") && <PasswordForm mode={state} onDone={() => setState("ready")} />}
{state === "ready" && (
<>
<header className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Organizer admin</h1>
<Link href="/" className="text-xs text-cyan-300 hover:underline">
← Open the dashboard
</Link>
</div>
</header>
<nav className="mb-6 flex gap-1 rounded-xl border border-white/10 bg-white/5 p-1">
{(["calendars", "photos", "account"] as Tab[]).map((name) => (
<button
key={name}
type="button"
onClick={() => selectTab(name)}
className={`flex-1 rounded-lg px-3 py-2 text-sm font-semibold capitalize ${tab === name ? "bg-cyan-300 text-slate-950" : "text-slate-300 hover:bg-white/5"}`}
>
{name}
</button>
))}
</nav>
{tab === "calendars" && <CalendarsTab />}
{tab === "photos" && (
<p className="rounded-xl border border-white/10 bg-[#0c121d] p-4 text-sm text-slate-400">
Photo albums arrive in the next update.
</p>
)}
{tab === "account" && <AccountTab onSignedOut={() => setState("login")} />}
</>
)}
</main>
</div>
);
}
@@ -0,0 +1,60 @@
import { randomUUID } from "node:crypto";
import { json, readJsonBody, requireAdmin } from "../../../_lib/auth";
import {
CALENDAR_TONES,
type CalendarSetting,
type CalendarTone,
MAX_CALENDARS,
maskCalendarUrl,
readSettings,
saveSettings,
validCalendarUrl,
} from "../../../_lib/store";
export const dynamic = "force-dynamic";
function publicView(calendars: CalendarSetting[]) {
return calendars.map(({ id, name, tone, enabled, url }) => ({ id, name, tone, enabled, masked: maskCalendarUrl(url) }));
}
export async function GET(request: Request) {
const denied = await requireAdmin(request);
if (denied) return denied;
const settings = await readSettings();
return json({ calendars: publicView(settings.calendars), updatedAt: settings.updatedAt, tones: CALENDAR_TONES });
}
type IncomingCalendar = { id?: string; name?: string; tone?: string; enabled?: boolean; url?: string };
export async function PUT(request: Request) {
const denied = await requireAdmin(request, { mutating: true });
if (denied) return denied;
const body = await readJsonBody<{ calendars?: IncomingCalendar[] }>(request);
if (!body || !Array.isArray(body.calendars)) return json({ error: "Expected a calendars list." }, 400);
if (body.calendars.length > MAX_CALENDARS) return json({ error: `At most ${MAX_CALENDARS} calendars.` }, 400);
const existing = new Map((await readSettings()).calendars.map((calendar) => [calendar.id, calendar]));
const next: CalendarSetting[] = [];
for (const [index, entry] of body.calendars.entries()) {
const label = entry.name?.trim() || `Calendar ${index + 1}`;
const previous = entry.id ? existing.get(entry.id) : undefined;
if (entry.id && !previous) return json({ error: `${label}: unknown calendar; reload the page.` }, 409);
const suppliedUrl = typeof entry.url === "string" && entry.url.trim() ? entry.url : "";
const url = suppliedUrl ? validCalendarUrl(suppliedUrl) : previous?.url;
if (!url) {
return json({ error: `${label}: paste a Google Calendar "Secret address in iCal format" (https://calendar.google.com/…/basic.ics).` }, 400);
}
next.push({
id: previous?.id ?? randomUUID(),
name: typeof entry.name === "string" ? entry.name.trim().slice(0, 50) : "",
url,
tone: CALENDAR_TONES.includes(entry.tone as CalendarTone) ? (entry.tone as CalendarTone) : "cyan",
enabled: entry.enabled !== false,
});
}
if (new Set(next.map((calendar) => calendar.url)).size !== next.length) return json({ error: "The same calendar is listed twice." }, 400);
const saved = await saveSettings(next, `Saved ${next.length} calendar(s)`);
console.log(`[admin] calendars saved (${saved.calendars.length})`);
return json({ calendars: publicView(saved.calendars), updatedAt: saved.updatedAt });
}
@@ -0,0 +1,32 @@
import { json, readJsonBody, requireAdmin } from "../../../../_lib/auth";
import { addCalendarDays, organizerDate, readCalendar } from "../../../../_lib/calendar-feed";
import { readSettings, validCalendarUrl } from "../../../../_lib/store";
export const dynamic = "force-dynamic";
/** Fetches one feed (saved by id, or a not-yet-saved URL) and reports what it found. */
export async function POST(request: Request) {
const denied = await requireAdmin(request, { mutating: true });
if (denied) return denied;
const body = await readJsonBody<{ id?: string; url?: string }>(request);
let url: string | null = null;
if (body?.url) url = validCalendarUrl(body.url);
else if (body?.id) url = (await readSettings()).calendars.find((calendar) => calendar.id === body.id)?.url ?? null;
if (!url) return json({ ok: false, error: "Not a Google Calendar basic.ics address." }, 400);
const now = Date.now();
const today = organizerDate(new Date(now));
try {
const result = await readCalendar(
url,
{ feedIndex: 0, tone: "cyan", nameOverride: "" },
now,
now + 60 * 24 * 60 * 60 * 1_000,
today,
addCalendarDays(today, 60),
);
return json({ ok: true, sourceName: result.sourceName, eventCount: result.eventCount, upcoming: result.items.length });
} catch (error) {
return json({ ok: false, error: error instanceof Error ? error.message : "Fetch failed" });
}
}
@@ -0,0 +1,22 @@
import { json, readJsonBody, requireAdmin } from "../../../_lib/auth";
import { listHistory, readHistorySnapshot, saveSettings } from "../../../_lib/store";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const denied = await requireAdmin(request);
if (denied) return denied;
return json({ history: await listHistory() });
}
/** Reverts calendars to a snapshot. The current state is itself snapshotted first, so a revert can be undone. */
export async function POST(request: Request) {
const denied = await requireAdmin(request, { mutating: true });
if (denied) return denied;
const body = await readJsonBody<{ id?: string }>(request);
const snapshot = body?.id ? await readHistorySnapshot(body.id) : null;
if (!snapshot) return json({ error: "That version no longer exists." }, 404);
const saved = await saveSettings(snapshot.calendars, `Reverted to version from ${snapshot.updatedAt}`);
console.log(`[admin] calendars reverted to ${body?.id}`);
return json({ ok: true, updatedAt: saved.updatedAt });
}
@@ -0,0 +1,36 @@
import {
clearLoginFailures,
clientIp,
isAdminNetworkAllowed,
json,
loginLockedFor,
readJsonBody,
recordLoginFailure,
sameOrigin,
sessionCookie,
verifyPassword,
} from "../../../_lib/auth";
import { readAuth } from "../../../_lib/store";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (!isAdminNetworkAllowed(request.headers)) return json({ error: "Admin is only available on the home network." }, 403);
if (!sameOrigin(request)) return json({ error: "Cross-origin request refused." }, 403);
const ip = clientIp(request.headers);
const locked = loginLockedFor(ip);
if (locked) return json({ error: `Too many attempts. Try again in ${locked} seconds.` }, 429);
const auth = await readAuth();
if (!auth.passwordHash) return json({ error: "No password is set yet." }, 409);
const body = await readJsonBody<{ password?: string }>(request);
const password = typeof body?.password === "string" ? body.password : "";
if (!password || !(await verifyPassword(password, auth.passwordHash))) {
recordLoginFailure(ip);
console.warn(`[admin] failed login from ${ip}`);
return json({ error: "Wrong password." }, 401);
}
clearLoginFailures(ip);
console.log(`[admin] login from ${ip}`);
return json({ ok: true }, 200, { "Set-Cookie": sessionCookie(auth.sessionSecret) });
}
@@ -0,0 +1,7 @@
import { clearedSessionCookie, json } from "../../../_lib/auth";
export const dynamic = "force-dynamic";
export async function POST() {
return json({ ok: true }, 200, { "Set-Cookie": clearedSessionCookie() });
}
@@ -0,0 +1,16 @@
import { json, MIN_PASSWORD_LENGTH, readJsonBody, requireAdmin, setPassword, verifyPassword } from "../../../_lib/auth";
import { readAuth } from "../../../_lib/store";
export const dynamic = "force-dynamic";
/** Changes the password; signs out every other session. */
export async function POST(request: Request) {
const denied = await requireAdmin(request, { mutating: true });
if (denied) return denied;
const body = await readJsonBody<{ currentPassword?: string; newPassword?: string }>(request);
const current = typeof body?.currentPassword === "string" ? body.currentPassword : "";
const next = typeof body?.newPassword === "string" ? body.newPassword : "";
if (!(await verifyPassword(current, (await readAuth()).passwordHash))) return json({ error: "Current password is wrong." }, 401);
if (next.length < MIN_PASSWORD_LENGTH || next.length > 256) return json({ error: `Use at least ${MIN_PASSWORD_LENGTH} characters.` }, 400);
return json({ ok: true }, 200, { "Set-Cookie": await setPassword(next) });
}
@@ -0,0 +1,17 @@
import { isAdminNetworkAllowed, json, MIN_PASSWORD_LENGTH, readJsonBody, sameOrigin, setPassword } from "../../../_lib/auth";
import { readAuth } from "../../../_lib/store";
export const dynamic = "force-dynamic";
/** First-run only: sets the admin password when none exists yet. */
export async function POST(request: Request) {
if (!isAdminNetworkAllowed(request.headers)) return json({ error: "Admin is only available on the home network." }, 403);
if (!sameOrigin(request)) return json({ error: "Cross-origin request refused." }, 403);
if ((await readAuth()).passwordHash) return json({ error: "A password is already set. Sign in instead." }, 409);
const body = await readJsonBody<{ password?: string }>(request);
const password = typeof body?.password === "string" ? body.password : "";
if (password.length < MIN_PASSWORD_LENGTH || password.length > 256) {
return json({ error: `Use at least ${MIN_PASSWORD_LENGTH} characters.` }, 400);
}
return json({ ok: true }, 200, { "Set-Cookie": await setPassword(password) });
}
@@ -0,0 +1,10 @@
import { hasValidSession, isAdminNetworkAllowed, json } from "../../../_lib/auth";
import { readAuth } from "../../../_lib/store";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
if (!isAdminNetworkAllowed(request.headers)) return json({ error: "Admin is only available on the home network." }, 403);
const { passwordHash } = await readAuth();
return json({ setupRequired: !passwordHash, authenticated: passwordHash ? await hasValidSession(request.headers) : false });
}
@@ -1,264 +1,12 @@
import ICAL from "ical.js";
type CalendarTone = "cyan" | "amber" | "violet" | "emerald" | "rose";
type CalendarEvent = {
id: string;
calendar: string;
title: string;
location?: string;
start: string;
end: string;
allDay: boolean;
tone: CalendarTone;
};
const tones: CalendarTone[] = ["cyan", "amber", "violet", "emerald", "rose"];
const maxFeedBytes = 5 * 1024 * 1024;
const maxOccurrencesPerEvent = 10_000;
import { addCalendarDays, organizerDate, readCalendar } from "../../_lib/calendar-feed";
import { readSettings } from "../../_lib/store";
export const dynamic = "force-dynamic";
function cleanText(value: unknown, fallback: string, maxLength: number) {
return String(value ?? fallback).replace(/\s+/g, " ").trim().slice(0, maxLength) || fallback;
}
function calendarDate(value: ICAL.Time) {
return `${String(value.year).padStart(4, "0")}-${String(value.month).padStart(2, "0")}-${String(value.day).padStart(2, "0")}`;
}
function organizerDate(date: Date) {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: process.env.ORGANIZER_TIMEZONE || "America/Chicago",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const value = (part: string) => parts.find((entry) => entry.type === part)?.value ?? "";
return `${value("year")}-${value("month")}-${value("day")}`;
}
function addCalendarDays(isoDate: string, days: number) {
const [year, month, day] = isoDate.split("-").map(Number);
const value = new Date(Date.UTC(year, month - 1, day + days));
return value.toISOString().slice(0, 10);
}
function hashId(value: string) {
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(36);
}
function parseFeedUrls() {
const raw = process.env.CALENDAR_ICAL_URLS;
if (!raw) return [];
const values = JSON.parse(raw) as unknown;
if (!Array.isArray(values) || values.length < 1 || values.length > 10) {
throw new Error("Calendar feed configuration is invalid");
}
return 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("Calendar feed host is not allowed");
}
return url.toString();
});
}
async function readLimitedText(response: Response) {
const declaredLength = Number(response.headers.get("content-length") ?? 0);
if (declaredLength > maxFeedBytes) throw new Error("Calendar feed is too large");
if (!response.body) return "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
let total = 0;
let text = "";
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
total += chunk.value.byteLength;
if (total > maxFeedBytes) {
await reader.cancel();
throw new Error("Calendar feed is too large");
}
text += decoder.decode(chunk.value, { stream: true });
}
return text + decoder.decode();
}
function componentString(component: ICAL.Component, name: string) {
const value = component.getFirstPropertyValue(name);
return typeof value === "string" ? value : "";
}
function isCancelled(event: ICAL.Event) {
return componentString(event.component, "status").toUpperCase() === "CANCELLED";
}
function detach(component: ICAL.Component) {
return new ICAL.Component(structuredClone(component.toJSON()));
}
function eventIsInWindow(
start: ICAL.Time,
end: ICAL.Time,
windowStart: number,
horizon: number,
windowStartDate: string,
horizonDate: string,
) {
if (start.isDate) {
return calendarDate(end) > windowStartDate && calendarDate(start) <= horizonDate;
}
return end.toJSDate().getTime() > windowStart && start.toJSDate().getTime() <= horizon;
}
function occurrenceIsPastHorizon(start: ICAL.Time, horizon: number, horizonDate: string) {
return start.isDate ? calendarDate(start) > horizonDate : start.toJSDate().getTime() > horizon;
}
function serializeOccurrence(
event: ICAL.Event,
start: ICAL.Time,
end: ICAL.Time,
calendar: string,
tone: CalendarTone,
feedIndex: number,
) {
const allDay = start.isDate;
const startValue = allDay ? calendarDate(start) : start.toJSDate().toISOString();
const endValue = allDay ? calendarDate(end) : end.toJSDate().toISOString();
const uid = cleanText(event.uid, "event", 180);
const location = cleanText(event.location, "", 120);
return {
id: hashId(`${feedIndex}|${uid}|${startValue}`),
calendar,
title: cleanText(event.summary, "Busy", 140),
...(location ? { location } : {}),
start: startValue,
end: endValue,
allDay,
tone,
} satisfies CalendarEvent;
}
function expandCalendar(
ics: string,
feedIndex: number,
windowStart: number,
horizon: number,
windowStartDate: string,
horizonDate: string,
) {
const root = new ICAL.Component(ICAL.parse(ics));
for (const timezone of root.getAllSubcomponents("vtimezone")) {
try {
ICAL.TimezoneService.register(timezone);
} catch {
// A feed may repeat a timezone that was already registered.
}
}
const sourceName = cleanText(root.getFirstPropertyValue("x-wr-calname"), `Calendar ${feedIndex + 1}`, 50);
const calendar = sourceName.includes("@") ? "Personal" : sourceName;
const tone = tones[feedIndex % tones.length];
const components = root.getAllSubcomponents("vevent");
const exceptions = new Map<string, ICAL.Component[]>();
for (const component of components) {
if (!component.hasProperty("recurrence-id")) continue;
const uid = componentString(component, "uid");
if (!uid) continue;
exceptions.set(uid, [...(exceptions.get(uid) ?? []), component]);
}
const items: CalendarEvent[] = [];
for (const component of components) {
if (component.hasProperty("recurrence-id")) continue;
try {
const event = new ICAL.Event(detach(component));
for (const exception of exceptions.get(event.uid) ?? []) {
event.relateException(detach(exception));
}
if (isCancelled(event)) continue;
if (!event.isRecurring()) {
if (eventIsInWindow(event.startDate, event.endDate, windowStart, horizon, windowStartDate, horizonDate)) {
items.push(serializeOccurrence(event, event.startDate, event.endDate, calendar, tone, feedIndex));
}
continue;
}
const iterator = event.iterator();
for (let count = 0; count < maxOccurrencesPerEvent; count += 1) {
const occurrence = iterator.next();
if (!occurrence) break;
if (occurrenceIsPastHorizon(occurrence, horizon, horizonDate)) break;
const details = event.getOccurrenceDetails(occurrence);
if (isCancelled(details.item)) continue;
if (eventIsInWindow(details.startDate, details.endDate, windowStart, horizon, windowStartDate, horizonDate)) {
items.push(serializeOccurrence(details.item, details.startDate, details.endDate, calendar, tone, feedIndex));
}
}
} catch {
// Ignore a malformed event while keeping the rest of the calendar usable.
}
}
return { calendar, items };
}
async function readCalendar(
url: string,
feedIndex: number,
windowStart: number,
horizon: number,
windowStartDate: string,
horizonDate: string,
) {
let currentUrl = url;
let response: Response | undefined;
for (let redirectCount = 0; redirectCount <= 3; redirectCount += 1) {
const validatedUrl = new URL(currentUrl);
if (validatedUrl.protocol !== "https:" || validatedUrl.hostname !== "calendar.google.com" || !validatedUrl.pathname.endsWith("/basic.ics")) {
throw new Error("Calendar redirect host is not allowed");
}
response = await fetch(validatedUrl, {
headers: { Accept: "text/calendar" },
redirect: "manual",
signal: AbortSignal.timeout(10_000),
});
if (![301, 302, 303, 307, 308].includes(response.status)) break;
const location = response.headers.get("location");
if (!location || redirectCount === 3) throw new Error("Calendar feed redirected too many times");
currentUrl = new URL(location, validatedUrl).toString();
}
if (!response) throw new Error("Calendar feed request failed");
if (!response.ok) throw new Error("Calendar feed request failed");
return expandCalendar(await readLimitedText(response), feedIndex, windowStart, horizon, windowStartDate, horizonDate);
}
export async function GET() {
let urls: string[];
try {
urls = parseFeedUrls();
} catch {
return Response.json({ items: [], calendarsAvailable: 0, calendarsTotal: 0, updatedAt: new Date().toISOString() }, { status: 503 });
}
if (!urls.length) {
const { calendars } = await readSettings();
const feeds = calendars.filter((calendar) => calendar.enabled);
if (!feeds.length) {
return Response.json({ items: [], calendarsAvailable: 0, calendarsTotal: 0, updatedAt: new Date().toISOString() }, { status: 503 });
}
@@ -269,7 +17,9 @@ export async function GET() {
const windowStartDate = addCalendarDays(today, -45);
const horizonDate = addCalendarDays(today, 60);
const results = await Promise.allSettled(
urls.map((url, index) => readCalendar(url, index, windowStart, horizon, windowStartDate, horizonDate)),
feeds.map((feed, feedIndex) =>
readCalendar(feed.url, { feedIndex, tone: feed.tone, nameOverride: feed.name }, windowStart, horizon, windowStartDate, horizonDate),
),
);
const successful = results.filter((result): result is PromiseFulfilledResult<Awaited<ReturnType<typeof readCalendar>>> => result.status === "fulfilled");
const items = successful
@@ -286,7 +36,7 @@ export async function GET() {
{
items,
calendarsAvailable: successful.length,
calendarsTotal: urls.length,
calendarsTotal: feeds.length,
updatedAt: new Date().toISOString(),
},
{
@@ -1,20 +1,24 @@
import { readAuth, readSettings } from "../../_lib/store";
export const dynamic = "force-dynamic";
export async function GET() {
let calendarConfigurationValid = false;
const { calendars } = await readSettings();
let adminConfigured = false;
try {
const parsed = JSON.parse(process.env.CALENDAR_ICAL_URLS || "[]") as unknown;
calendarConfigurationValid = Array.isArray(parsed) && parsed.length > 0;
adminConfigured = Boolean((await readAuth()).passwordHash);
} catch {
calendarConfigurationValid = false;
adminConfigured = false;
}
return Response.json(
{
status: "ok",
service: "hobbs-family-organizer",
version: "1.0.0",
calendarConfigurationValid,
version: "1.1.0",
calendarConfigurationValid: calendars.some((calendar) => calendar.enabled),
calendarsEnabled: calendars.filter((calendar) => calendar.enabled).length,
adminConfigured,
authenticationEnabled: Boolean(process.env.ORGANIZER_ACCESS_TOKEN),
checkedAt: new Date().toISOString(),
},
@@ -2,7 +2,7 @@ name: hobbs-family-organizer
services:
organizer:
image: hobbs-family-organizer:1.0.0
image: hobbs-family-organizer:1.1.0
build:
context: .
dockerfile: Dockerfile
@@ -21,11 +21,17 @@ services:
WEATHER_USER_AGENT: ${WEATHER_USER_AGENT:-Hobbs-Family-Organizer/1.0 (self-hosted)}
CALENDAR_ICAL_URLS_FILE: /run/secrets/calendar_ical_urls
ORGANIZER_ACCESS_TOKEN_FILE: /run/secrets/organizer_access_token
ORGANIZER_DATA_DIR: /data
ADMIN_ALLOWED_CIDRS: ${ADMIN_ALLOWED_CIDRS:-192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,127.0.0.1/32}
secrets:
- calendar_ical_urls
- organizer_access_token
ports:
- "${HOBBS_BIND_ADDRESS:-0.0.0.0}:${HOBBS_HTTP_PORT:-8080}:3000"
volumes:
# Admin settings, password hash and history. Must be writable by uid 10001,
# and must be local disk (not NFS with root_squash).
- ${HOBBS_DATA_DIR:-./data}:/data
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
@@ -0,0 +1,15 @@
// Clears the admin password so the next visit to /admin (from the LAN) can set a new one.
// Also rotates the session secret, signing out every existing admin session.
// Usage: docker exec -u 10001 hobbs-family-organizer-organizer-1 node container/reset-admin.mjs
import { randomBytes } from "node:crypto";
import { rename, writeFile } from "node:fs/promises";
const path = `${process.env.ORGANIZER_DATA_DIR || "/data"}/auth.json`;
const temporary = `${path}.reset.tmp`;
await writeFile(
temporary,
`${JSON.stringify({ passwordHash: "", sessionSecret: randomBytes(32).toString("base64url") }, null, 2)}\n`,
{ mode: 0o600 },
);
await rename(temporary, path);
console.log("Admin password cleared. Visit /admin from the LAN to set a new one.");
@@ -1,8 +1,17 @@
import { access, readFile } from "node:fs/promises";
import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { constants } from "node:fs";
import { spawn } from "node:child_process";
import { randomBytes, randomUUID } from "node:crypto";
import http from "node:http";
import process from "node:process";
const APP_UID = 10001;
const APP_GID = 10001;
const DATA_DIR = process.env.ORGANIZER_DATA_DIR || "/data";
const PUBLIC_PORT = Number(process.env.PORT || "3000");
const INTERNAL_PORT = Number(process.env.ORGANIZER_INTERNAL_PORT || "3001");
const TONES = ["cyan", "amber", "violet", "emerald", "rose"];
async function readable(path) {
try {
await access(path, constants.R_OK);
@@ -12,35 +21,109 @@ async function readable(path) {
}
}
async function settingFromFile(environmentName, fileEnvironmentName, defaultFile, required = false) {
async function settingFromFile(environmentName, fileEnvironmentName, defaultFile) {
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) {
function validCalendarUrl(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();
}
function parseCalendarSecret(raw) {
if (!raw) return [];
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");
if (!Array.isArray(values) || values.length > 10) throw new Error("Calendar secret must contain at most 10 URLs");
return [...new Set(values.map(validCalendarUrl))];
}
async function writeJsonAtomic(path, value) {
const temporary = `${path}.${process.pid}.tmp`;
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
await rename(temporary, path);
}
function dropPrivileges() {
if (process.platform === "win32" || typeof process.getuid !== "function" || process.getuid() !== 0) return;
process.setgroups([]);
process.setgid(APP_GID);
process.setuid(APP_UID);
}
async function prepareDataDirectory(seedUrls) {
await mkdir(`${DATA_DIR}/history`, { recursive: true, mode: 0o700 });
const probe = `${DATA_DIR}/.write-test`;
try {
await writeFile(probe, "ok", { mode: 0o600 });
} catch {
throw new Error(`${DATA_DIR} must be writable by uid ${APP_UID}`);
}
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();
const settingsPath = `${DATA_DIR}/settings.json`;
if (!(await readable(settingsPath))) {
const now = new Date().toISOString();
await writeJsonAtomic(settingsPath, {
schema: 1,
updatedAt: now,
calendars: seedUrls.map((url, index) => ({
id: randomUUID(),
name: "",
url,
tone: TONES[index % TONES.length],
enabled: true,
})),
});
console.log(`[organizer] created settings.json with ${seedUrls.length} calendar(s) from the startup secret`);
}
const authPath = `${DATA_DIR}/auth.json`;
if (!(await readable(authPath))) {
await writeJsonAtomic(authPath, { passwordHash: "", sessionSecret: randomBytes(32).toString("base64url") });
}
}
const FORWARDING_HEADERS = ["x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "forwarded", "x-real-ip"];
const INTERNAL_HEADERS = ["x-hfo-client-ip", "x-hfo-forwarded"];
// The public listener stamps each request with the real TCP peer address so the
// app can enforce LAN-only admin access. Client-supplied copies are discarded.
function startFrontProxy() {
const agent = new http.Agent({ keepAlive: true, maxSockets: 64 });
const server = http.createServer((request, response) => {
const headers = { ...request.headers };
for (const name of INTERNAL_HEADERS) delete headers[name];
headers["x-hfo-client-ip"] = String(request.socket.remoteAddress || "").replace(/^::ffff:/, "");
if (FORWARDING_HEADERS.some((name) => request.headers[name] !== undefined)) headers["x-hfo-forwarded"] = "1";
const upstream = http.request(
{ host: "127.0.0.1", port: INTERNAL_PORT, method: request.method, path: request.url, headers, agent },
(upstreamResponse) => {
response.writeHead(upstreamResponse.statusCode || 502, upstreamResponse.headers);
upstreamResponse.pipe(response);
},
);
upstream.on("error", () => {
if (!response.headersSent) response.writeHead(502, { "Content-Type": "text/plain" });
response.end("Upstream unavailable");
});
request.pipe(upstream);
});
return JSON.stringify([...new Set(normalized)]);
server.headersTimeout = 20_000;
server.requestTimeout = 60_000;
server.listen(PUBLIC_PORT, process.env.HOSTNAME || "0.0.0.0");
return server;
}
async function main() {
@@ -48,7 +131,6 @@ async function main() {
"CALENDAR_ICAL_URLS",
"CALENDAR_ICAL_URLS_FILE",
"/run/secrets/calendar_ical_urls",
true,
);
const accessToken = await settingFromFile(
"ORGANIZER_ACCESS_TOKEN",
@@ -64,32 +146,30 @@ async function main() {
} catch {
throw new Error(`ORGANIZER_TIMEZONE is not a valid IANA timezone: ${organizerTimeZone}`);
}
const seedUrls = parseCalendarSecret(calendarSecret);
const environment = {
...process.env,
CALENDAR_ICAL_URLS: validateCalendarConfiguration(calendarSecret),
// Secrets are read; everything from here on runs unprivileged.
dropPrivileges();
await prepareDataDirectory(seedUrls);
const environment = { ...process.env };
delete environment.CALENDAR_ICAL_URLS;
Object.assign(environment, {
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;
}
ORGANIZER_DATA_DIR: DATA_DIR,
HOSTNAME: "127.0.0.1",
PORT: String(INTERNAL_PORT),
});
const child = spawn(
process.execPath,
["node_modules/next/dist/bin/next", "start", "-H", environment.HOSTNAME, "-p", environment.PORT],
childOptions,
["node_modules/next/dist/bin/next", "start", "-H", "127.0.0.1", "-p", String(INTERNAL_PORT)],
{ cwd: process.cwd(), env: environment, stdio: "inherit" },
);
const server = startFrontProxy();
const forwardSignal = (signal) => {
server.close();
if (!child.killed) child.kill(signal);
};
process.on("SIGINT", () => forwardSignal("SIGINT"));
@@ -1,12 +1,12 @@
{
"name": "hobbs-family-organizer",
"version": "1.0.0",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hobbs-family-organizer",
"version": "1.0.0",
"version": "1.1.0",
"dependencies": {
"ical.js": "^2.2.1",
"lucide-react": "^1.31.0",
@@ -1,6 +1,6 @@
{
"name": "hobbs-family-organizer",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"engines": {
"node": ">=22.13.0"
@@ -1,6 +1,7 @@
import { createHash, timingSafeEqual } from "node:crypto";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { isAdminNetworkAllowed } from "./app/_lib/auth";
function tokenMatches(actual: string, expected: string) {
const actualValue = createHash("sha256").update(actual).digest();
@@ -9,7 +10,22 @@ function tokenMatches(actual: string, expected: string) {
}
export function proxy(request: NextRequest) {
if (request.nextUrl.pathname === "/api/health") return NextResponse.next();
const { pathname } = request.nextUrl;
if (pathname === "/api/health") return NextResponse.next();
// Admin has its own password + session and is reachable only from a direct LAN connection.
if (pathname === "/admin" || pathname.startsWith("/admin/") || pathname.startsWith("/api/admin/") || pathname === "/photo-admin") {
if (!isAdminNetworkAllowed(request.headers)) {
return new NextResponse("Not found", { status: 404, headers: { "Cache-Control": "no-store" } });
}
if (pathname === "/photo-admin") {
const target = new URL("/admin", request.url);
target.host = request.headers.get("host") || target.host;
target.hash = "photos";
return NextResponse.redirect(target, 307);
}
return NextResponse.next();
}
const configuredToken = process.env.ORGANIZER_ACCESS_TOKEN?.trim();
if (!configuredToken) return NextResponse.next();