From 66b8fc5d4db3060a68d35564246b9fa5cff51b42 Mon Sep 17 00:00:00 2001 From: ort Date: Sat, 15 Aug 2026 13:48:01 -0400 Subject: [PATCH] Add self-hosted kids calendar app Express + SQLite (node:sqlite, no native build step) family calendar: parent accounts with household invites, per-child calendars with save/duplicate/print, a token-gated read-only kiosk view for tablets, and polling to keep parent and kiosk views in sync. Defaults to port 3007. --- .env.example | 18 + .gitignore | 4 + Dockerfile | 16 + README.md | 73 +++ docker-compose.yml | 15 + package-lock.json | 908 +++++++++++++++++++++++++++++ package.json | 19 + public/calendar.html | 44 ++ public/css/shared.css | 451 ++++++++++++++ public/dashboard.html | 115 ++++ public/join.html | 77 +++ public/js/api.js | 29 + public/js/calendar.js | 151 +++++ public/js/calendarRender.js | 165 ++++++ public/js/dashboard.js | 213 +++++++ public/js/kiosk.js | 59 ++ public/kiosk.html | 38 ++ public/login.html | 53 ++ public/signup.html | 63 ++ server.js | 13 + src/app.js | 62 ++ src/config.js | 12 + src/db/index.js | 14 + src/db/migrate.js | 9 + src/db/schema.sql | 76 +++ src/lib/calendarHydrate.js | 52 ++ src/lib/calendarSeed.js | 52 ++ src/lib/defaults.js | 21 + src/lib/sqliteSessionStore.js | 59 ++ src/lib/tokens.js | 7 + src/lib/touchCalendar.js | 13 + src/lib/validate.js | 15 + src/middleware/csrf.js | 28 + src/middleware/requireAuth.js | 19 + src/middleware/requireHousehold.js | 33 ++ src/middleware/resolveKiosk.js | 15 + src/routes/auth.js | 84 +++ src/routes/calendars.js | 164 ++++++ src/routes/children.js | 128 ++++ src/routes/household.js | 29 + src/routes/invites.js | 103 ++++ src/routes/kiosk.js | 48 ++ 42 files changed, 3567 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/calendar.html create mode 100644 public/css/shared.css create mode 100644 public/dashboard.html create mode 100644 public/join.html create mode 100644 public/js/api.js create mode 100644 public/js/calendar.js create mode 100644 public/js/calendarRender.js create mode 100644 public/js/dashboard.js create mode 100644 public/js/kiosk.js create mode 100644 public/kiosk.html create mode 100644 public/login.html create mode 100644 public/signup.html create mode 100644 server.js create mode 100644 src/app.js create mode 100644 src/config.js create mode 100644 src/db/index.js create mode 100644 src/db/migrate.js create mode 100644 src/db/schema.sql create mode 100644 src/lib/calendarHydrate.js create mode 100644 src/lib/calendarSeed.js create mode 100644 src/lib/defaults.js create mode 100644 src/lib/sqliteSessionStore.js create mode 100644 src/lib/tokens.js create mode 100644 src/lib/touchCalendar.js create mode 100644 src/lib/validate.js create mode 100644 src/middleware/csrf.js create mode 100644 src/middleware/requireAuth.js create mode 100644 src/middleware/requireHousehold.js create mode 100644 src/middleware/resolveKiosk.js create mode 100644 src/routes/auth.js create mode 100644 src/routes/calendars.js create mode 100644 src/routes/children.js create mode 100644 src/routes/household.js create mode 100644 src/routes/invites.js create mode 100644 src/routes/kiosk.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bfbc1a2 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Copy to .env and fill in before running with docker-compose, +# or export these yourself before `node server.js`. + +# Required: a long random string used to sign session cookies. +# Generate one with: node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))" +SESSION_SECRET= + +# Set to true once this is served over HTTPS (e.g. behind a reverse proxy). +# Leave false for plain-HTTP LAN use, otherwise cookies won't be sent. +COOKIE_SECURE=false + +# Set to true after your household(s) are created to stop accepting new signups. +DISABLE_PUBLIC_SIGNUP=false + +# Only needed outside Docker if you want the SQLite file somewhere specific. +# DATA_DIR=./data + +# PORT=3007 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97725b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +data/ +.env +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fb903ce --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package.json ./ +RUN npm install --omit=dev + +COPY . . + +ENV PORT=3007 +ENV DATA_DIR=/app/data +VOLUME ["/app/data"] + +EXPOSE 3007 + +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea5b3ce --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# Kids Calendar + +A self-hosted weekly calendar for your family. Parents log in and edit; each +child gets a read-only "kiosk" link for a tablet, where they can only tick +off tasks that are already there. + +- Parents: sign up, invite your spouse, create a calendar per child, edit + freely, print, duplicate a calendar for the next week. +- Kids: open their kiosk link on a tablet (add it to the home screen). They + can see the board and check boxes — nothing else. +- Everything a child checks off shows up on the parents' devices within a + few seconds, no reload needed. + +## Running it + +Requires Node.js 22.5+ (for the built-in `node:sqlite` module — nothing else +to compile, no native build tools needed). + +```bash +npm install +export SESSION_SECRET=$(node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))") +node server.js +``` + +Plain `node` doesn't read `.env` files — export the variables from +`.env.example` yourself (as above), or use a process manager (pm2, systemd) +that does it for you. Docker (below) reads `.env` automatically. Then open +http://localhost:3007/signup.html. + +### Docker (recommended for a NAS / home server) + +```bash +cp .env.example .env # set SESSION_SECRET +docker compose up -d --build +``` + +Data lives in a named volume (`kids-calendar-data`), so it survives +container rebuilds. To back it up, back up that volume (or bind-mount +`./data:/app/data` in `docker-compose.yml` instead and back up that folder). + +### Environment variables + +| Variable | Default | Notes | +|---|---|---| +| `SESSION_SECRET` | *(insecure dev default)* | Required. A long random string — see `.env.example` for how to generate one. | +| `PORT` | `3007` | | +| `DATA_DIR` | `./data` | Where the SQLite file lives. | +| `COOKIE_SECURE` | `false` | Set `true` once this is behind HTTPS (e.g. a reverse proxy with a real certificate), so cookies are marked secure. Leave `false` for plain-HTTP LAN access, or login cookies won't be sent. | +| `DISABLE_PUBLIC_SIGNUP` | `false` | Set `true` once your household(s) exist, to stop the `/signup.html` page from creating new ones. Existing invite links still work. | + +## How access works + +- **Parents** have real accounts (email + password) tied to a household. + The first person to sign up creates the household; from the dashboard + they can generate an invite link (valid 7 days, one-time use) for a + spouse to join the same household with their own login. +- **Kids** never get an account. Each child has an unguessable link + (`/k/`) that shows only their *active* calendar (set from the + dashboard) and lets them toggle checkboxes — the server has no route at + all for a kiosk link to edit text, add/delete tasks, or see other + children's data. If a tablet is lost, regenerate that child's link from + the dashboard to invalidate the old one. + +## Exposing this beyond your home network + +This is built for LAN use by default (plain HTTP, cookies not marked +secure). If you want access from outside your home: + +- Put it behind a reverse proxy (e.g. Caddy, nginx, Traefik) that terminates + HTTPS, then set `COOKIE_SECURE=true`. +- Set `SESSION_SECRET` to a real random value (never the dev default). +- Consider setting `DISABLE_PUBLIC_SIGNUP=true` once your family's + household(s) are created. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4618d4d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + kids-calendar: + build: . + ports: + - "3007:3007" + volumes: + - kids-calendar-data:/app/data + environment: + - SESSION_SECRET=${SESSION_SECRET:?set a long random value in .env} + - COOKIE_SECURE=${COOKIE_SECURE:-false} + - DISABLE_PUBLIC_SIGNUP=${DISABLE_PUBLIC_SIGNUP:-false} + restart: unless-stopped + +volumes: + kids-calendar-data: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..edf64c1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,908 @@ +{ + "name": "kids-calendar", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kids-calendar", + "version": "1.0.0", + "dependencies": { + "bcryptjs": "^2.4.3", + "express": "^4.19.2", + "express-rate-limit": "^7.4.0", + "express-session": "^1.18.0" + }, + "engines": { + "node": ">=22.5.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", + "license": "MIT", + "dependencies": { + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "~5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..92eeb8a --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "kids-calendar", + "version": "1.0.0", + "description": "Self-hosted family weekly calendar with parent editing and a read-only kid kiosk view.", + "main": "server.js", + "private": true, + "engines": { + "node": ">=22.5.0" + }, + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "express": "^4.19.2", + "express-rate-limit": "^7.4.0", + "express-session": "^1.18.0" + } +} diff --git a/public/calendar.html b/public/calendar.html new file mode 100644 index 0000000..8c6daa0 --- /dev/null +++ b/public/calendar.html @@ -0,0 +1,44 @@ + + + + +My Week + + + + + + + + + +
+
+

's Week

+
Tap a box to check it off. Click any word to edit it.
+
+
+ + + +
+
+ +
+ Show / print: +
+ +
+ + +
+
Tip: the colors stay the same every day on purpose — same color always means same kind of task, so she can recognize it at a glance.
+ +
+ + + + + + + diff --git a/public/css/shared.css b/public/css/shared.css new file mode 100644 index 0000000..1134ea7 --- /dev/null +++ b/public/css/shared.css @@ -0,0 +1,451 @@ +:root{ + --bg: #F1F5FB; + --card: #FFFFFF; + --ink: #2E3446; + --ink-soft: #6B7280; + --line: #E3E8F0; + + --morning: #FF8C64; + --morning-bg: #FFF0E7; + --school: #4A90D9; + --school-bg: #E9F2FC; + --after: #4FAE72; + --after-bg: #E9F7EE; + --evening: #8B7FD1; + --evening-bg: #F0EDFB; + + --today-ring: #FFC94A; + --danger: #D14343; +} + +*{ box-sizing: border-box; } + +body{ + margin:0; + background: var(--bg); + font-family: 'Nunito', sans-serif; + color: var(--ink); + padding: 24px 16px 60px; +} + +a{ color: var(--school); } + +h1{ + font-family: 'Baloo 2', sans-serif; + font-size: 2rem; + margin: 0; + color: var(--ink); +} +h1 span{ color: var(--morning); } + +header{ + max-width: 1400px; + margin: 0 auto 20px; + display:flex; + align-items:center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; +} + +.subtitle{ + font-size: 0.95rem; + color: var(--ink-soft); + margin-top: 2px; +} + +.toolbar{ + display:flex; + gap: 10px; + flex-wrap: wrap; +} + +button.tool, a.tool{ + font-family: 'Nunito', sans-serif; + font-weight: 800; + font-size: 0.85rem; + border: none; + border-radius: 999px; + padding: 10px 18px; + cursor: pointer; + background: var(--card); + color: var(--ink); + border: 2px solid var(--line); + transition: transform .12s ease, background .12s ease; + text-decoration: none; + display: inline-block; +} +button.tool:hover, a.tool:hover{ transform: translateY(-1px); background: #F7F9FC; } +button.tool.primary, a.tool.primary{ background: var(--school); color: white; border-color: var(--school); } +button.tool.primary:hover, a.tool.primary:hover{ background: #3d7fc4; } +button.tool.danger{ background: var(--card); color: var(--danger); border-color: #F3D0D0; } +button.tool.danger:hover{ background: #FDF1F1; } +button.tool:disabled{ opacity: .5; cursor: default; transform:none; } + +.board{ + max-width: 1400px; + margin: 0 auto; + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 14px; +} + +.weekend-toggle-wrap{ + max-width: 1400px; + margin: 14px auto 0; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 14px; +} + +.day-card{ + background: var(--card); + border-radius: 20px; + padding: 14px; + display:flex; + flex-direction:column; + gap: 10px; + border: 3px solid transparent; + box-shadow: 0 2px 10px rgba(46,52,70,0.06); +} + +.day-card.today{ + border-color: var(--today-ring); + box-shadow: 0 4px 18px rgba(255,201,74,0.35); +} + +.day-head{ + display:flex; + align-items:baseline; + justify-content: space-between; +} + +.day-name{ + font-family: 'Baloo 2', sans-serif; + font-weight: 700; + font-size: 1.15rem; +} + +.today-badge{ + font-size: 0.65rem; + font-weight: 800; + background: var(--today-ring); + color: #6B4A00; + padding: 3px 9px; + border-radius: 999px; + letter-spacing: .03em; +} + +.block{ + border-radius: 14px; + padding: 10px 10px 8px; +} + +.block.morning{ background: var(--morning-bg); } +.block.school{ background: var(--school-bg); } +.block.after{ background: var(--after-bg); } +.block.evening{ background: var(--evening-bg); } + +.block-title{ + font-family: 'Baloo 2', sans-serif; + font-weight: 700; + font-size: 0.85rem; + display:flex; + align-items:center; + gap: 6px; + margin-bottom: 6px; +} +.morning .block-title{ color: #C0501F; } +.school .block-title{ color: #2C6BAF; } +.after .block-title{ color: #2D7B4C; } +.evening .block-title{ color: #5F51B0; } + +.task{ + display:flex; + align-items:flex-start; + gap: 8px; + padding: 5px 4px; + border-radius: 8px; +} +.task:hover{ background: rgba(255,255,255,0.6); } +.task:hover .del{ opacity: 1; } + +.task input[type=checkbox]{ + margin-top: 3px; + width: 18px; + height: 18px; + flex: none; + accent-color: var(--ink); + cursor: pointer; +} +.task.kiosk input[type=checkbox]{ + width: 24px; + height: 24px; +} + +.task-text{ + flex: 1; + font-size: 0.92rem; + font-weight: 700; + outline: none; + line-height: 1.25; + padding: 1px 2px; + border-radius: 4px; +} +.task-text:focus{ background: rgba(255,255,255,0.85); } +.task.done .task-text{ + text-decoration: line-through; + opacity: 0.45; +} + +.del{ + opacity: 0; + border: none; + background: none; + color: var(--ink-soft); + font-weight: 800; + cursor: pointer; + font-size: 0.85rem; + padding: 0 3px; + transition: opacity .12s ease; +} +.del:hover{ color: var(--danger); } + +.add-task{ + background: none; + border: none; + color: var(--ink-soft); + font-size: 0.82rem; + font-weight: 800; + cursor: pointer; + padding: 4px 4px 2px; + opacity: 0.75; +} +.add-task:hover{ opacity: 1; text-decoration: underline; } + +.legend{ + max-width: 1400px; + margin: 22px auto 0; + display:flex; + gap: 16px; + flex-wrap: wrap; + font-size: 0.82rem; + color: var(--ink-soft); + align-items:center; +} +.legend .dot{ + display:inline-block; + width: 10px; height:10px; + border-radius: 50%; + margin-right: 5px; +} +.legend .item{ display:flex; align-items:center; } + +.hint{ + max-width: 1400px; + margin: 6px auto 0; + font-size: 0.78rem; + color: var(--ink-soft); +} + +@media (max-width: 1100px){ + .board{ grid-template-columns: repeat(2, 1fr); } + .weekend-toggle-wrap{ grid-template-columns: 1fr; } +} +@media (max-width: 600px){ + .board{ grid-template-columns: 1fr; } +} + +@media print{ + body{ background: white; padding: 0; } + .toolbar, .add-task, .del, .hint, .period-toggles, nav.crumbs{ display:none !important; } + .board{ grid-template-columns: repeat(5, 1fr); gap: 8px; } + .day-card{ box-shadow:none; border: 2px solid var(--line) !important; break-inside: avoid; } + .weekend-toggle-wrap{ margin-top: 8px; grid-template-columns: repeat(2,1fr); } +} + +#weekendWrap.hidden{ display:none; } + +body.hide-morning .block.morning{ display:none; } +body.hide-school .block.school{ display:none; } +body.hide-after .block.after{ display:none; } +body.hide-evening .block.evening{ display:none; } + +[contenteditable="true"]{ + outline: none; + border-radius: 4px; + padding: 0 2px; +} +[contenteditable="true"]:focus{ background: rgba(255,255,255,0.7); } + +.period-toggles{ + max-width: 1400px; + margin: 0 auto 18px; + display:flex; + gap: 18px; + flex-wrap: wrap; + align-items:center; + background: var(--card); + border: 2px solid var(--line); + border-radius: 14px; + padding: 10px 16px; +} +.period-toggles .label{ + font-size: 0.8rem; + font-weight: 800; + color: var(--ink-soft); +} +.period-toggles label.opt{ + display:flex; + align-items:center; + gap: 6px; + font-size: 0.85rem; + font-weight: 700; + cursor: pointer; +} +.period-toggles input[type=checkbox]{ + width: 16px; height:16px; + cursor: pointer; +} + +.block-title .icon{ margin-right: 2px; } +.block-title .label{ + outline: none; + border-radius: 4px; + padding: 0 2px; +} +.block-title .label:focus{ background: rgba(255,255,255,0.75); } + +/* --- App chrome shared across auth/dashboard pages --- */ + +nav.crumbs{ + max-width: 1400px; + margin: 0 auto 14px; + font-size: 0.85rem; + color: var(--ink-soft); +} +nav.crumbs a{ text-decoration: none; font-weight: 700; } + +.auth-wrap{ + max-width: 420px; + margin: 60px auto; + background: var(--card); + border-radius: 20px; + padding: 32px; + box-shadow: 0 2px 10px rgba(46,52,70,0.06); +} +.auth-wrap h1{ font-size: 1.5rem; margin-bottom: 4px; } +.auth-wrap p.lead{ color: var(--ink-soft); margin-top: 0; margin-bottom: 20px; font-size: 0.9rem; } + +.field{ margin-bottom: 14px; display:flex; flex-direction:column; gap: 6px; } +.field label{ font-size: 0.82rem; font-weight: 800; color: var(--ink-soft); } +.field input{ + font-family: 'Nunito', sans-serif; + font-size: 0.95rem; + padding: 10px 12px; + border-radius: 10px; + border: 2px solid var(--line); + outline: none; +} +.field input:focus{ border-color: var(--school); } + +.error-msg{ + background: #FDF1F1; + border: 2px solid #F3D0D0; + color: var(--danger); + border-radius: 10px; + padding: 10px 12px; + font-size: 0.85rem; + font-weight: 700; + margin-bottom: 14px; +} + +.form-actions{ margin-top: 18px; display:flex; flex-direction:column; gap: 10px; } +.form-actions button.tool{ width: 100%; text-align:center; } +.switch-link{ font-size: 0.85rem; color: var(--ink-soft); text-align:center; } + +.section{ + max-width: 1400px; + margin: 0 auto 28px; +} +.section h2{ + font-family: 'Baloo 2', sans-serif; + font-size: 1.3rem; + margin: 0 0 12px; +} + +.card-grid{ + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 14px; +} + +.entity-card{ + background: var(--card); + border-radius: 16px; + padding: 16px; + border: 2px solid var(--line); + display:flex; + flex-direction: column; + gap: 10px; +} +.entity-card h3{ margin: 0; font-family: 'Baloo 2', sans-serif; font-size: 1.1rem; } +.entity-card .meta{ font-size: 0.8rem; color: var(--ink-soft); } +.entity-card .row{ display:flex; gap: 8px; flex-wrap: wrap; } +.entity-card .row button.tool, .entity-card .row a.tool{ padding: 7px 12px; font-size: 0.78rem; } + +.empty-state{ + color: var(--ink-soft); + font-size: 0.9rem; + padding: 20px; + text-align: center; + background: var(--card); + border: 2px dashed var(--line); + border-radius: 16px; +} + +.kiosk-lock-note{ + max-width: 1400px; + margin: 0 auto 14px; + font-size: 0.85rem; + color: var(--ink-soft); + display:flex; + align-items:center; + gap: 8px; +} + +.modal-backdrop{ + position: fixed; inset: 0; + background: rgba(46,52,70,0.35); + display:flex; align-items:center; justify-content:center; + padding: 20px; + z-index: 50; +} +.modal-backdrop.hidden{ display:none; } +.modal{ + background: var(--card); + border-radius: 20px; + padding: 24px; + max-width: 420px; + width: 100%; +} +.modal h2{ margin-top:0; font-family:'Baloo 2', sans-serif; } +.modal .form-actions{ flex-direction: row; } +.modal .form-actions button{ flex: 1; } + +.toast{ + position: fixed; + bottom: 20px; left: 50%; + transform: translateX(-50%); + background: var(--ink); + color: white; + padding: 10px 18px; + border-radius: 999px; + font-size: 0.85rem; + font-weight: 700; + opacity: 0; + pointer-events: none; + transition: opacity .2s ease; + z-index: 100; +} +.toast.show{ opacity: 1; } diff --git a/public/dashboard.html b/public/dashboard.html new file mode 100644 index 0000000..304c730 --- /dev/null +++ b/public/dashboard.html @@ -0,0 +1,115 @@ + + + + +Dashboard — Kids Calendar + + + + + + + +
+
+

Loading…

+
+
+
+ + +
+
+ +
+

Household

+
+
+ +
+
+

Children

+ +
+
+
+ + + + + + + + + + + + + +
+ + + + + + diff --git a/public/join.html b/public/join.html new file mode 100644 index 0000000..683894e --- /dev/null +++ b/public/join.html @@ -0,0 +1,77 @@ + + + + +Join family — Kids Calendar + + + + + + + +
+

Join family

+

Checking your invite link…

+ + +
+ + + + + + diff --git a/public/js/api.js b/public/js/api.js new file mode 100644 index 0000000..f14854a --- /dev/null +++ b/public/js/api.js @@ -0,0 +1,29 @@ +const api = { + async request(method, path, body) { + const res = await fetch(path, { + method, + credentials: 'include', + headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + + if (res.status === 204) return null; + + let data = null; + const text = await res.text(); + if (text) { + try { data = JSON.parse(text); } catch { data = null; } + } + + if (!res.ok) { + const err = new Error((data && data.error) || `Request failed (${res.status})`); + err.status = res.status; + throw err; + } + return data; + }, + get(path) { return this.request('GET', path); }, + post(path, body) { return this.request('POST', path, body === undefined ? {} : body); }, + patch(path, body) { return this.request('PATCH', path, body === undefined ? {} : body); }, + del(path) { return this.request('DELETE', path); }, +}; diff --git a/public/js/calendar.js b/public/js/calendar.js new file mode 100644 index 0000000..8c99663 --- /dev/null +++ b/public/js/calendar.js @@ -0,0 +1,151 @@ +const calendarId = new URLSearchParams(window.location.search).get('calendarId'); +let calendar = null; + +const weekdayBoard = document.getElementById('weekdayBoard'); +const weekendWrap = document.getElementById('weekendWrap'); +const legend = document.getElementById('legend'); +const toast = document.getElementById('toast'); + +function showToast(msg) { + toast.textContent = msg; + toast.classList.add('show'); + setTimeout(() => toast.classList.remove('show'), 1800); +} + +function debounce(fn, ms) { + let t; + return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; +} + +const handlers = { + onToggleDone: (taskId, done) => api.patch(`/api/calendars/${calendarId}/tasks/${taskId}`, { done }).catch(() => showToast('Could not save')), + onTextEdit: (taskId, text) => api.patch(`/api/calendars/${calendarId}/tasks/${taskId}`, { text }).catch(() => showToast('Could not save')), + onLabelEdit: (blockKey, label) => api.patch(`/api/calendars/${calendarId}/blocks/${blockKey}`, { label }).catch(() => showToast('Could not save')), + onAddTask: async (dayOfWeek, blockKey, text) => { + const { task } = await api.post(`/api/calendars/${calendarId}/tasks`, { dayOfWeek, blockKey, text }); + return task; + }, + onDeleteTask: (taskId) => api.del(`/api/calendars/${calendarId}/tasks/${taskId}`).catch(() => showToast('Could not delete')), +}; + +function todayName() { + return new Date().toLocaleDateString('en-US', { weekday: 'long' }); +} + +function draw() { + const childNameEl = document.getElementById('childName'); + if (document.activeElement !== childNameEl) childNameEl.textContent = calendar.childName || ''; + const titleEl = document.getElementById('calTitle'); + if (document.activeElement !== titleEl) titleEl.textContent = calendar.title; + + renderCalendar({ + calendar, + editable: true, + weekdayBoard, + weekendWrap, + handlers, + todayName: todayName(), + }); + renderLegend(legend, calendar); + + weekendWrap.classList.toggle('hidden', !calendar.showWeekend); + document.getElementById('toggleWeekend').textContent = calendar.showWeekend ? 'Hide weekend' : 'Show weekend'; + + buildPeriodToggles(); +} + +function buildPeriodToggles() { + const el = document.getElementById('periodToggles'); + el.querySelectorAll('label.opt').forEach((n) => n.remove()); + calendar.blocks.forEach((block) => { + const label = document.createElement('label'); + label.className = 'opt'; + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.checked = !document.body.classList.contains('hide-' + block.key); + cb.onchange = () => document.body.classList.toggle('hide-' + block.key, !cb.checked); + const span = document.createElement('span'); + span.textContent = block.label; + label.appendChild(cb); + label.appendChild(span); + el.appendChild(label); + }); +} + +async function load() { + if (!calendarId) { + document.body.innerHTML = '

No calendar selected. Back to dashboard

'; + return; + } + try { + const data = await api.get(`/api/calendars/${calendarId}`); + calendar = data.calendar; + } catch (err) { + if (err.status === 401) { window.location.href = '/login.html'; return; } + document.body.innerHTML = `

${err.message}. Back to dashboard

`; + return; + } + draw(); +} + +document.getElementById('toggleWeekend').addEventListener('click', async () => { + calendar.showWeekend = !calendar.showWeekend; + weekendWrap.classList.toggle('hidden', !calendar.showWeekend); + document.getElementById('toggleWeekend').textContent = calendar.showWeekend ? 'Hide weekend' : 'Show weekend'; + await api.patch(`/api/calendars/${calendarId}`, { showWeekend: calendar.showWeekend }); +}); + +document.getElementById('resetChecks').addEventListener('click', async () => { + const data = await api.post(`/api/calendars/${calendarId}/reset-checks`); + calendar = data.calendar; + draw(); + showToast('Checkboxes reset'); +}); + +document.getElementById('printBtn').addEventListener('click', () => window.print()); + +const titleEl = document.getElementById('calTitle'); +titleEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); titleEl.blur(); } }); +const debouncedTitleSave = debounce(() => { + if (titleEl.textContent.trim()) api.patch(`/api/calendars/${calendarId}`, { title: titleEl.textContent.trim() }); +}, 500); +titleEl.addEventListener('input', debouncedTitleSave); + +const childNameEl = document.getElementById('childName'); +childNameEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); childNameEl.blur(); } }); +const debouncedChildNameSave = debounce(() => { + if (childNameEl.textContent.trim()) api.patch(`/api/children/${calendar.childId}`, { name: childNameEl.textContent.trim() }); +}, 500); +childNameEl.addEventListener('input', debouncedChildNameSave); + +// Polling keeps this view in sync with kiosk checkbox taps (and the other +// parent's edits) without a manual reload. Paused while the parent is mid-edit +// in any contenteditable field, so an incoming poll can't yank their cursor. +let isEditingFocused = false; +document.addEventListener('focusin', (e) => { + if (e.target.isContentEditable) isEditingFocused = true; +}); +document.addEventListener('focusout', (e) => { + if (e.target.isContentEditable) { + setTimeout(() => { + isEditingFocused = !!(document.activeElement && document.activeElement.isContentEditable); + }, 0); + } +}); + +async function poll() { + if (!calendar || isEditingFocused || document.hidden) return; + try { + const data = await api.get(`/api/calendars/${calendarId}/poll?since=${encodeURIComponent(calendar.updatedAt)}`); + if (data.changed) { + calendar = data.calendar; + draw(); + } + } catch (err) { + if (err.status === 401) window.location.href = '/login.html'; + } +} +setInterval(poll, 4000); +document.addEventListener('visibilitychange', () => { if (!document.hidden) poll(); }); + +load(); diff --git a/public/js/calendarRender.js b/public/js/calendarRender.js new file mode 100644 index 0000000..3a0254a --- /dev/null +++ b/public/js/calendarRender.js @@ -0,0 +1,165 @@ +const BLOCK_ICONS = { morning: '🌅', school: '🎒', after: '⚽', evening: '🌙' }; +const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']; +const WEEKEND = ['Saturday', 'Sunday']; + +function debounce(fn, ms) { + let t; + return (...args) => { + clearTimeout(t); + t = setTimeout(() => fn(...args), ms); + }; +} + +// Renders the weekly board. `editable` gates every mutation affordance +// (contenteditable, delete buttons, add-task buttons) — the kiosk view passes +// editable:false and gets a board with nothing but working checkboxes. +function renderCalendar({ calendar, editable, weekdayBoard, weekendWrap, handlers, todayName }) { + weekdayBoard.innerHTML = ''; + weekendWrap.innerHTML = ''; + + const currentLabels = {}; + calendar.blocks.forEach((b) => { currentLabels[b.key] = b.label; }); + + function syncLabel(key, sourceEl) { + document.querySelectorAll(`.label[data-block="${key}"]`).forEach((el) => { + if (el !== sourceEl) el.textContent = sourceEl.textContent; + }); + document.querySelectorAll(`.legend-label[data-block="${key}"]`).forEach((el) => { + el.textContent = sourceEl.textContent; + }); + } + + function makeTask(dayName, blockKey, task) { + const row = document.createElement('div'); + row.className = 'task' + (task.done ? ' done' : '') + (editable ? '' : ' kiosk'); + row.dataset.taskId = task.id; + + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.checked = task.done; + cb.onchange = () => { + row.classList.toggle('done', cb.checked); + handlers.onToggleDone(task.id, cb.checked); + }; + + const span = document.createElement('div'); + span.className = 'task-text'; + span.textContent = task.text; + + row.appendChild(cb); + row.appendChild(span); + + if (editable) { + span.contentEditable = 'true'; + span.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); span.blur(); } + }); + const debouncedEdit = debounce(() => { + if (span.textContent.trim()) handlers.onTextEdit(task.id, span.textContent.trim()); + }, 500); + span.addEventListener('input', debouncedEdit); + span.addEventListener('blur', () => { + if (!span.textContent.trim()) span.textContent = task.text; + }); + + const del = document.createElement('button'); + del.className = 'del'; + del.textContent = '✕'; + del.onclick = () => { + row.remove(); + handlers.onDeleteTask(task.id); + }; + row.appendChild(del); + } + + return row; + } + + function makeDayCard(dayName) { + const card = document.createElement('div'); + card.className = 'day-card' + (dayName === todayName ? ' today' : ''); + + const head = document.createElement('div'); + head.className = 'day-head'; + head.innerHTML = `
${dayName}
` + + (dayName === todayName ? `
TODAY
` : ''); + card.appendChild(head); + + calendar.blocks.forEach((block) => { + const blockEl = document.createElement('div'); + blockEl.className = 'block ' + block.key; + + const title = document.createElement('div'); + title.className = 'block-title'; + + const iconSpan = document.createElement('span'); + iconSpan.className = 'icon'; + iconSpan.textContent = BLOCK_ICONS[block.key] || ''; + + const labelSpan = document.createElement('span'); + labelSpan.className = 'label'; + labelSpan.dataset.block = block.key; + labelSpan.textContent = currentLabels[block.key]; + + if (editable) { + labelSpan.contentEditable = 'true'; + labelSpan.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); labelSpan.blur(); } + }); + const debouncedLabel = debounce(() => { + if (labelSpan.textContent.trim()) { + currentLabels[block.key] = labelSpan.textContent.trim(); + handlers.onLabelEdit(block.key, labelSpan.textContent.trim()); + } + }, 500); + labelSpan.addEventListener('input', () => { syncLabel(block.key, labelSpan); debouncedLabel(); }); + } + + title.appendChild(iconSpan); + title.appendChild(labelSpan); + blockEl.appendChild(title); + + const list = document.createElement('div'); + list.className = 'task-list'; + (calendar.days[dayName][block.key] || []).forEach((task) => { + list.appendChild(makeTask(dayName, block.key, task)); + }); + blockEl.appendChild(list); + + if (editable) { + const addBtn = document.createElement('button'); + addBtn.className = 'add-task'; + addBtn.textContent = '+ add task'; + addBtn.onclick = async () => { + const newTask = await handlers.onAddTask(dayName, block.key, 'New task'); + const row = makeTask(dayName, block.key, newTask); + list.appendChild(row); + const span = row.querySelector('.task-text'); + span.focus(); + document.execCommand('selectAll', false, null); + }; + blockEl.appendChild(addBtn); + } + + card.appendChild(blockEl); + }); + + return card; + } + + WEEKDAYS.forEach((d) => weekdayBoard.appendChild(makeDayCard(d))); + WEEKEND.forEach((d) => weekendWrap.appendChild(makeDayCard(d))); + + return { currentLabels }; +} + +function renderLegend(legendEl, calendar) { + legendEl.innerHTML = ''; + calendar.blocks.forEach((block) => { + const item = document.createElement('span'); + item.className = 'item'; + item.innerHTML = `` + + `${block.label}`; + legendEl.appendChild(item); + }); +} diff --git a/public/js/dashboard.js b/public/js/dashboard.js new file mode 100644 index 0000000..936329f --- /dev/null +++ b/public/js/dashboard.js @@ -0,0 +1,213 @@ +let children = []; + +function showToast(msg) { + const toast = document.getElementById('toast'); + toast.textContent = msg; + toast.classList.add('show'); + setTimeout(() => toast.classList.remove('show'), 2200); +} + +function fmtDate(iso) { + if (!iso) return ''; + return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); +} + +async function boot() { + try { + const me = await api.get('/api/auth/me'); + document.getElementById('householdName').textContent = me.household.name; + document.getElementById('whoami').textContent = `Logged in as ${me.parent.name} (${me.parent.email})`; + } catch { + window.location.href = '/login.html'; + return; + } + + await Promise.all([loadParents(), loadChildren()]); +} + +async function loadParents() { + const { parents } = await api.get('/api/household'); + const el = document.getElementById('parentsList'); + el.innerHTML = ''; + parents.forEach((p) => { + const card = document.createElement('div'); + card.className = 'entity-card'; + card.innerHTML = `

${escapeHtml(p.name)}

${escapeHtml(p.email)}
`; + el.appendChild(card); + }); +} + +async function loadChildren() { + const data = await api.get('/api/children'); + children = data.children; + const el = document.getElementById('childrenList'); + el.innerHTML = ''; + + if (children.length === 0) { + el.innerHTML = '
No children yet — add one to create their first calendar.
'; + return; + } + + for (const child of children) { + const card = await renderChildCard(child); + el.appendChild(card); + } +} + +async function renderChildCard(child) { + const { calendars } = await api.get(`/api/children/${child.id}/calendars`); + + const card = document.createElement('div'); + card.className = 'entity-card'; + + const calendarsHtml = calendars.length + ? calendars.map((c) => calendarRowHtml(child, c)).join('') + : '
No calendars yet.
'; + + card.innerHTML = ` +

${escapeHtml(child.name)}

+
${calendars.length} calendar${calendars.length === 1 ? '' : 's'}
+
${calendarsHtml}
+
+ + + +
+ `; + + card.querySelectorAll('[data-action]').forEach((btn) => { + btn.addEventListener('click', () => handleCardAction(btn.dataset.action, btn.dataset)); + }); + + return card; +} + +function calendarRowHtml(child, cal) { + const isActive = child.activeCalendarId === cal.id; + return ` +
+
+ ${escapeHtml(cal.title)} +
Updated ${fmtDate(cal.updated_at)}${isActive ? ' · on tablet' : ''}
+
+
+ Open + + ${isActive ? '' : ``} + +
+
+ `; +} + +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +async function handleCardAction(action, ds) { + if (action === 'new-cal') { + document.getElementById('newCalChildId').value = ds.child; + document.getElementById('newCalTitle').value = ''; + document.getElementById('newCalDuplicate').checked = false; + openModal('newCalendarModal'); + } else if (action === 'duplicate-cal') { + const title = prompt('Title for the duplicated calendar:', `Copy of ${ds.title}`); + if (!title) return; + await api.post(`/api/children/${ds.child}/calendars`, { title, duplicateFromCalendarId: Number(ds.cal) }); + showToast('Calendar duplicated'); + await loadChildren(); + } else if (action === 'set-active') { + await api.patch(`/api/children/${ds.child}`, { activeCalendarId: Number(ds.cal) }); + showToast('Tablet will now show this calendar'); + await loadChildren(); + } else if (action === 'delete-cal') { + if (!confirm('Delete this calendar? This cannot be undone.')) return; + await api.del(`/api/calendars/${ds.cal}`); + showToast('Calendar deleted'); + await loadChildren(); + } else if (action === 'kiosk-link') { + const child = children.find((c) => c.id === Number(ds.child)); + openKioskModal(child); + } else if (action === 'delete-child') { + if (!confirm('Delete this child and all their calendars? This cannot be undone.')) return; + await api.del(`/api/children/${ds.child}`); + showToast('Child deleted'); + await loadChildren(); + } +} + +function openModal(id) { document.getElementById(id).classList.remove('hidden'); } +function closeModal(id) { document.getElementById(id).classList.add('hidden'); } + +document.getElementById('addChildBtn').addEventListener('click', () => { + document.getElementById('childNameInput').value = ''; + openModal('addChildModal'); +}); +document.getElementById('cancelAddChildBtn').addEventListener('click', () => closeModal('addChildModal')); +document.getElementById('addChildForm').addEventListener('submit', async (e) => { + e.preventDefault(); + await api.post('/api/children', { name: document.getElementById('childNameInput').value }); + closeModal('addChildModal'); + showToast('Child added'); + await loadChildren(); +}); + +document.getElementById('cancelNewCalBtn').addEventListener('click', () => closeModal('newCalendarModal')); +document.getElementById('newCalendarForm').addEventListener('submit', async (e) => { + e.preventDefault(); + const childId = document.getElementById('newCalChildId').value; + const title = document.getElementById('newCalTitle').value; + const duplicate = document.getElementById('newCalDuplicate').checked; + + let body = { title }; + if (duplicate) { + const { calendars } = await api.get(`/api/children/${childId}/calendars`); + if (calendars.length) body.duplicateFromCalendarId = calendars[0].id; + } + const { calendar } = await api.post(`/api/children/${childId}/calendars`, body); + closeModal('newCalendarModal'); + window.location.href = `/calendar.html?calendarId=${calendar.id}`; +}); + +document.getElementById('inviteBtn').addEventListener('click', async () => { + const { invite } = await api.post('/api/household/invites'); + const link = `${window.location.origin}${invite.joinPath}`; + document.getElementById('inviteLinkField').value = link; + openModal('inviteModal'); +}); +document.getElementById('closeInviteBtn').addEventListener('click', () => closeModal('inviteModal')); +document.getElementById('copyInviteBtn').addEventListener('click', () => { + document.getElementById('inviteLinkField').select(); + navigator.clipboard.writeText(document.getElementById('inviteLinkField').value); + showToast('Link copied'); +}); + +let kioskModalChild = null; +function openKioskModal(child) { + kioskModalChild = child; + document.getElementById('kioskChildName').textContent = child.name; + document.getElementById('kioskLinkField').value = `${window.location.origin}${child.kioskPath}`; + openModal('kioskModal'); +} +document.getElementById('closeKioskBtn').addEventListener('click', () => closeModal('kioskModal')); +document.getElementById('copyKioskBtn').addEventListener('click', () => { + document.getElementById('kioskLinkField').select(); + navigator.clipboard.writeText(document.getElementById('kioskLinkField').value); + showToast('Link copied'); +}); +document.getElementById('regenKioskBtn').addEventListener('click', async () => { + if (!confirm('Regenerate this tablet link? The old link will stop working.')) return; + const data = await api.post(`/api/children/${kioskModalChild.id}/kiosk-token/regenerate`); + document.getElementById('kioskLinkField').value = `${window.location.origin}${data.kioskPath}`; + showToast('Link regenerated'); + await loadChildren(); +}); + +document.getElementById('logoutBtn').addEventListener('click', async () => { + await api.post('/api/auth/logout'); + window.location.href = '/login.html'; +}); + +boot(); diff --git a/public/js/kiosk.js b/public/js/kiosk.js new file mode 100644 index 0000000..d94de1e --- /dev/null +++ b/public/js/kiosk.js @@ -0,0 +1,59 @@ +const token = window.location.pathname.split('/').filter(Boolean).pop(); + +const weekdayBoard = document.getElementById('weekdayBoard'); +const weekendWrap = document.getElementById('weekendWrap'); + +async function apiGet(path) { + const res = await fetch(path, { credentials: 'omit' }); + if (!res.ok) throw new Error('request failed'); + return res.json(); +} +async function apiPatch(path, body) { + const res = await fetch(path, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error('request failed'); + return res.json(); +} + +const handlers = { + onToggleDone: (taskId, done) => apiPatch(`/api/kiosk/${token}/tasks/${taskId}`, { done }).catch(() => {}), +}; + +function todayName() { + return new Date().toLocaleDateString('en-US', { weekday: 'long' }); +} + +function draw(calendar) { + document.getElementById('childName').textContent = calendar.childName || ''; + document.getElementById('calTitle').textContent = calendar.title || ''; + + renderCalendar({ + calendar, + editable: false, + weekdayBoard, + weekendWrap, + handlers, + todayName: todayName(), + }); +} + +async function poll() { + try { + const data = await apiGet(`/api/kiosk/${token}/calendar`); + if (!data.calendar) { + document.getElementById('emptyState').style.display = 'block'; + document.getElementById('childName').textContent = data.child ? data.child.name : ''; + return; + } + document.getElementById('emptyState').style.display = 'none'; + draw(data.calendar); + } catch { + // transient network error — try again next tick + } +} + +poll(); +setInterval(poll, 4000); diff --git a/public/kiosk.html b/public/kiosk.html new file mode 100644 index 0000000..d283f61 --- /dev/null +++ b/public/kiosk.html @@ -0,0 +1,38 @@ + + + + +My Week + + + + + + + + +
+
+

+
+
+
+ +
+
+ + + + + + + + diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..be7f6bb --- /dev/null +++ b/public/login.html @@ -0,0 +1,53 @@ + + + + +Log in — Kids Calendar + + + + + + + +
+

Welcome back

+

Log in to see and edit your family's calendars.

+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + + + diff --git a/public/signup.html b/public/signup.html new file mode 100644 index 0000000..218b4ed --- /dev/null +++ b/public/signup.html @@ -0,0 +1,63 @@ + + + + +Sign up — Kids Calendar + + + + + + + +
+

Create your family

+

This creates a new household. Your spouse can join it afterward with an invite link.

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + + + diff --git a/server.js b/server.js new file mode 100644 index 0000000..823a67f --- /dev/null +++ b/server.js @@ -0,0 +1,13 @@ +const app = require('./src/app'); +const config = require('./src/config'); + +if (config.sessionSecret === 'dev-secret-change-me') { + console.warn( + 'WARNING: SESSION_SECRET is not set — using an insecure default. ' + + 'Set SESSION_SECRET to a long random value before exposing this beyond localhost.' + ); +} + +app.listen(config.port, () => { + console.log(`Kids Calendar listening on http://localhost:${config.port}`); +}); diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..212588e --- /dev/null +++ b/src/app.js @@ -0,0 +1,62 @@ +const path = require('node:path'); +const express = require('express'); +const session = require('express-session'); +const rateLimit = require('express-rate-limit'); +const config = require('./config'); +const SqliteSessionStore = require('./lib/sqliteSessionStore'); +const csrfCheck = require('./middleware/csrf'); + +const authRoutes = require('./routes/auth'); +const householdRoutes = require('./routes/household'); +const { manageRouter: inviteManageRoutes, publicRouter: invitePublicRoutes } = require('./routes/invites'); +const childrenRoutes = require('./routes/children'); +const calendarRoutes = require('./routes/calendars'); +const kioskRoutes = require('./routes/kiosk'); + +const app = express(); + +app.disable('x-powered-by'); +app.use(express.json({ limit: '256kb' })); + +app.use(session({ + store: new SqliteSessionStore(), + name: 'kc.sid', + secret: config.sessionSecret, + resave: false, + saveUninitialized: false, + cookie: { + httpOnly: true, + sameSite: 'lax', + secure: config.cookieSecure, + maxAge: 30 * 24 * 60 * 60 * 1000, + }, +})); + +app.use('/api', csrfCheck); + +const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false }); +app.use('/api/auth/login', authLimiter); +app.use('/api/auth/signup', authLimiter); +app.use('/api/invites', authLimiter); + +app.use('/api/auth', authRoutes); +app.use('/api/household/invites', inviteManageRoutes); +app.use('/api/household', householdRoutes); +app.use('/api/invites', invitePublicRoutes); +app.use('/api/children', childrenRoutes); +app.use('/api/calendars', calendarRoutes); +app.use('/api/kiosk/:token', kioskRoutes); + +// Short, bookmarkable kiosk URL for the tablet: redirect to the static kiosk page, +// which reads the token back out of the path client-side. +app.get('/k/:token', (req, res) => { + res.sendFile(path.join(__dirname, '..', 'public', 'kiosk.html')); +}); + +app.use(express.static(path.join(__dirname, '..', 'public'))); + +app.use('/api', (req, res) => { + res.status(404).json({ error: 'Not found' }); +}); + +module.exports = app; diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..6b57727 --- /dev/null +++ b/src/config.js @@ -0,0 +1,12 @@ +const path = require('node:path'); + +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data'); + +module.exports = { + port: parseInt(process.env.PORT || '3007', 10), + dataDir: DATA_DIR, + dbPath: path.join(DATA_DIR, 'kids-calendar.sqlite'), + sessionSecret: process.env.SESSION_SECRET || 'dev-secret-change-me', + cookieSecure: process.env.COOKIE_SECURE === 'true', + disablePublicSignup: process.env.DISABLE_PUBLIC_SIGNUP === 'true', +}; diff --git a/src/db/index.js b/src/db/index.js new file mode 100644 index 0000000..493007f --- /dev/null +++ b/src/db/index.js @@ -0,0 +1,14 @@ +const fs = require('node:fs'); +const { DatabaseSync } = require('node:sqlite'); +const config = require('../config'); +const { migrate } = require('./migrate'); + +fs.mkdirSync(config.dataDir, { recursive: true }); + +const db = new DatabaseSync(config.dbPath); +db.exec('PRAGMA foreign_keys = ON;'); +db.exec('PRAGMA journal_mode = WAL;'); + +migrate(db); + +module.exports = db; diff --git a/src/db/migrate.js b/src/db/migrate.js new file mode 100644 index 0000000..cf1e888 --- /dev/null +++ b/src/db/migrate.js @@ -0,0 +1,9 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +function migrate(db) { + const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8'); + db.exec(schema); +} + +module.exports = { migrate }; diff --git a/src/db/schema.sql b/src/db/schema.sql new file mode 100644 index 0000000..fd2e3fa --- /dev/null +++ b/src/db/schema.sql @@ -0,0 +1,76 @@ +CREATE TABLE IF NOT EXISTS households ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +CREATE TABLE IF NOT EXISTS parents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE, + email TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX IF NOT EXISTS idx_parents_household ON parents(household_id); + +CREATE TABLE IF NOT EXISTS household_invites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + created_by_parent_id INTEGER NOT NULL REFERENCES parents(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + used_at TEXT, + used_by_parent_id INTEGER REFERENCES parents(id), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX IF NOT EXISTS idx_invites_household ON household_invites(household_id); + +CREATE TABLE IF NOT EXISTS children ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE, + name TEXT NOT NULL, + kiosk_token TEXT NOT NULL UNIQUE, + active_calendar_id INTEGER REFERENCES calendars(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX IF NOT EXISTS idx_children_household ON children(household_id); + +CREATE TABLE IF NOT EXISTS calendars ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + child_id INTEGER NOT NULL REFERENCES children(id) ON DELETE CASCADE, + title TEXT NOT NULL, + week_start_date TEXT, + show_weekend INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX IF NOT EXISTS idx_calendars_child ON calendars(child_id); + +CREATE TABLE IF NOT EXISTS calendar_blocks ( + calendar_id INTEGER NOT NULL REFERENCES calendars(id) ON DELETE CASCADE, + block_key TEXT NOT NULL CHECK (block_key IN ('morning','school','after','evening')), + label TEXT NOT NULL, + PRIMARY KEY (calendar_id, block_key) +); + +CREATE TABLE IF NOT EXISTS calendar_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + calendar_id INTEGER NOT NULL REFERENCES calendars(id) ON DELETE CASCADE, + day_of_week TEXT NOT NULL CHECK (day_of_week IN + ('Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday')), + block_key TEXT NOT NULL CHECK (block_key IN ('morning','school','after','evening')), + text TEXT NOT NULL, + done INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX IF NOT EXISTS idx_tasks_calendar ON calendar_tasks(calendar_id, day_of_week, block_key); + +CREATE TABLE IF NOT EXISTS sessions ( + sid TEXT PRIMARY KEY, + sess TEXT NOT NULL, + expires_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); diff --git a/src/lib/calendarHydrate.js b/src/lib/calendarHydrate.js new file mode 100644 index 0000000..41fb468 --- /dev/null +++ b/src/lib/calendarHydrate.js @@ -0,0 +1,52 @@ +const db = require('../db'); +const { BLOCK_KEYS, ALL_DAYS } = require('./defaults'); + +const getCalendarStmt = db.prepare('SELECT * FROM calendars WHERE id = ?'); +const getChildStmt = db.prepare('SELECT id, name FROM children WHERE id = ?'); +const getBlocksStmt = db.prepare('SELECT block_key, label FROM calendar_blocks WHERE calendar_id = ?'); +const getTasksStmt = db.prepare( + 'SELECT id, day_of_week, block_key, text, done, sort_order FROM calendar_tasks WHERE calendar_id = ? ORDER BY sort_order, id' +); + +// Shared shape consumed by the parent editor, the kiosk view, and the poll endpoint. +function hydrateCalendar(calendarId) { + const calendar = getCalendarStmt.get(calendarId); + if (!calendar) return null; + + const child = getChildStmt.get(calendar.child_id); + const blockRows = getBlocksStmt.all(calendarId); + const blockLabels = Object.fromEntries(blockRows.map((b) => [b.block_key, b.label])); + + const days = {}; + ALL_DAYS.forEach((day) => { + days[day] = {}; + BLOCK_KEYS.forEach((key) => { + days[day][key] = []; + }); + }); + + getTasksStmt.all(calendarId).forEach((t) => { + if (!days[t.day_of_week]) return; + days[t.day_of_week][t.block_key].push({ + id: t.id, + text: t.text, + done: !!t.done, + sortOrder: t.sort_order, + }); + }); + + return { + id: calendar.id, + childId: calendar.child_id, + childName: child ? child.name : null, + title: calendar.title, + weekStartDate: calendar.week_start_date, + showWeekend: !!calendar.show_weekend, + updatedAt: calendar.updated_at, + createdAt: calendar.created_at, + blocks: BLOCK_KEYS.map((key) => ({ key, label: blockLabels[key] || key })), + days, + }; +} + +module.exports = { hydrateCalendar }; diff --git a/src/lib/calendarSeed.js b/src/lib/calendarSeed.js new file mode 100644 index 0000000..47ee58d --- /dev/null +++ b/src/lib/calendarSeed.js @@ -0,0 +1,52 @@ +const db = require('../db'); +const { BLOCK_KEYS, DEFAULT_BLOCK_LABELS, DEFAULT_TASKS, ALL_DAYS } = require('./defaults'); +const { hydrateCalendar } = require('./calendarHydrate'); + +const insertCalendarStmt = db.prepare( + 'INSERT INTO calendars (child_id, title, week_start_date) VALUES (?, ?, ?)' +); +const setShowWeekendStmt = db.prepare('UPDATE calendars SET show_weekend = ? WHERE id = ?'); +const insertBlockStmt = db.prepare( + 'INSERT INTO calendar_blocks (calendar_id, block_key, label) VALUES (?, ?, ?)' +); +const insertTaskStmt = db.prepare( + 'INSERT INTO calendar_tasks (calendar_id, day_of_week, block_key, text, sort_order) VALUES (?, ?, ?, ?, ?)' +); +const getBlocksStmt = db.prepare('SELECT block_key, label FROM calendar_blocks WHERE calendar_id = ?'); +const getTasksStmt = db.prepare( + 'SELECT day_of_week, block_key, text, sort_order FROM calendar_tasks WHERE calendar_id = ? ORDER BY sort_order' +); +const getCalendarStmt = db.prepare('SELECT * FROM calendars WHERE id = ?'); + +function createBlankCalendar(childId, title, weekStartDate) { + const calendarId = Number(insertCalendarStmt.run(childId, title, weekStartDate).lastInsertRowid); + + BLOCK_KEYS.forEach((key) => { + insertBlockStmt.run(calendarId, key, DEFAULT_BLOCK_LABELS[key]); + DEFAULT_TASKS[key].forEach((text, i) => { + ALL_DAYS.forEach((day) => { + insertTaskStmt.run(calendarId, day, key, text, i); + }); + }); + }); + + return hydrateCalendar(calendarId); +} + +function createDuplicateCalendar(sourceCalendarId, childId, title, weekStartDate) { + const source = getCalendarStmt.get(sourceCalendarId); + const calendarId = Number(insertCalendarStmt.run(childId, title, weekStartDate).lastInsertRowid); + setShowWeekendStmt.run(source.show_weekend, calendarId); + + getBlocksStmt.all(sourceCalendarId).forEach((b) => { + insertBlockStmt.run(calendarId, b.block_key, b.label); + }); + // done intentionally not copied — new week starts unchecked (column default is 0) + getTasksStmt.all(sourceCalendarId).forEach((t) => { + insertTaskStmt.run(calendarId, t.day_of_week, t.block_key, t.text, t.sort_order); + }); + + return hydrateCalendar(calendarId); +} + +module.exports = { createBlankCalendar, createDuplicateCalendar }; diff --git a/src/lib/defaults.js b/src/lib/defaults.js new file mode 100644 index 0000000..f2360be --- /dev/null +++ b/src/lib/defaults.js @@ -0,0 +1,21 @@ +const BLOCK_KEYS = ['morning', 'school', 'after', 'evening']; + +const DEFAULT_BLOCK_LABELS = { + morning: 'Morning', + school: 'School', + after: 'After School', + evening: 'Evening', +}; + +const DEFAULT_TASKS = { + morning: ['Wake up & get dressed', 'Brush teeth', 'Eat breakfast', 'Pack backpack'], + school: ['Reading time', 'Math practice', 'Lunch & recess'], + after: ['Snack', '20 min free play', 'Homework'], + evening: ['Dinner', 'Bath', "Pick tomorrow's clothes", 'Read a book', 'Lights out'], +}; + +const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']; +const WEEKEND = ['Saturday', 'Sunday']; +const ALL_DAYS = [...WEEKDAYS, ...WEEKEND]; + +module.exports = { BLOCK_KEYS, DEFAULT_BLOCK_LABELS, DEFAULT_TASKS, WEEKDAYS, WEEKEND, ALL_DAYS }; diff --git a/src/lib/sqliteSessionStore.js b/src/lib/sqliteSessionStore.js new file mode 100644 index 0000000..b253d74 --- /dev/null +++ b/src/lib/sqliteSessionStore.js @@ -0,0 +1,59 @@ +const session = require('express-session'); +const db = require('../db'); + +const insertStmt = db.prepare( + 'INSERT INTO sessions (sid, sess, expires_at) VALUES (?, ?, ?) ' + + 'ON CONFLICT(sid) DO UPDATE SET sess = excluded.sess, expires_at = excluded.expires_at' +); +const selectStmt = db.prepare('SELECT sess, expires_at FROM sessions WHERE sid = ?'); +const deleteStmt = db.prepare('DELETE FROM sessions WHERE sid = ?'); +const pruneStmt = db.prepare('DELETE FROM sessions WHERE expires_at < ?'); + +const DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days + +class SqliteSessionStore extends session.Store { + constructor() { + super(); + this._pruneInterval = setInterval(() => { + pruneStmt.run(Date.now()); + }, 60 * 60 * 1000); + this._pruneInterval.unref(); + } + + get(sid, cb) { + try { + const row = selectStmt.get(sid); + if (!row || row.expires_at < Date.now()) return cb(null, null); + cb(null, JSON.parse(row.sess)); + } catch (err) { + cb(err); + } + } + + set(sid, sessionData, cb) { + try { + const ttl = sessionData.cookie && sessionData.cookie.maxAge + ? sessionData.cookie.maxAge + : DEFAULT_TTL_MS; + insertStmt.run(sid, JSON.stringify(sessionData), Date.now() + ttl); + cb(null); + } catch (err) { + cb(err); + } + } + + destroy(sid, cb) { + try { + deleteStmt.run(sid); + cb(null); + } catch (err) { + cb(err); + } + } + + touch(sid, sessionData, cb) { + this.set(sid, sessionData, cb || (() => {})); + } +} + +module.exports = SqliteSessionStore; diff --git a/src/lib/tokens.js b/src/lib/tokens.js new file mode 100644 index 0000000..bc8f4fc --- /dev/null +++ b/src/lib/tokens.js @@ -0,0 +1,7 @@ +const crypto = require('node:crypto'); + +function randomToken(bytes = 24) { + return crypto.randomBytes(bytes).toString('base64url'); +} + +module.exports = { randomToken }; diff --git a/src/lib/touchCalendar.js b/src/lib/touchCalendar.js new file mode 100644 index 0000000..2f70497 --- /dev/null +++ b/src/lib/touchCalendar.js @@ -0,0 +1,13 @@ +const db = require('../db'); + +const touchStmt = db.prepare( + "UPDATE calendars SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?" +); + +// Bumped inside the same synchronous call as any task/block write so the +// poll endpoint's since= comparison sees every mutation. +function touchCalendar(calendarId) { + touchStmt.run(calendarId); +} + +module.exports = touchCalendar; diff --git a/src/lib/validate.js b/src/lib/validate.js new file mode 100644 index 0000000..ea78208 --- /dev/null +++ b/src/lib/validate.js @@ -0,0 +1,15 @@ +function isNonEmptyString(value, maxLength) { + return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength; +} + +const LIMITS = { + NAME: 100, + EMAIL: 254, + PASSWORD: 200, + HOUSEHOLD_NAME: 100, + TITLE: 200, + LABEL: 60, + TASK_TEXT: 300, +}; + +module.exports = { isNonEmptyString, LIMITS }; diff --git a/src/middleware/csrf.js b/src/middleware/csrf.js new file mode 100644 index 0000000..95610d8 --- /dev/null +++ b/src/middleware/csrf.js @@ -0,0 +1,28 @@ +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +// Cookie-based sessions + same-origin fetch calls: block cross-site state +// changes by checking Origin (falling back to Referer) against the request's +// own Host. Simpler than a full CSRF-token library for a small LAN-scale app. +function csrfCheck(req, res, next) { + if (SAFE_METHODS.has(req.method)) return next(); + + const origin = req.get('origin'); + const referer = req.get('referer'); + const source = origin || referer; + if (!source) return res.status(403).json({ error: 'Missing origin' }); + + let sourceHost; + try { + sourceHost = new URL(source).host; + } catch { + return res.status(403).json({ error: 'Invalid origin' }); + } + + if (sourceHost !== req.get('host')) { + return res.status(403).json({ error: 'Cross-origin request blocked' }); + } + + next(); +} + +module.exports = csrfCheck; diff --git a/src/middleware/requireAuth.js b/src/middleware/requireAuth.js new file mode 100644 index 0000000..4e8f258 --- /dev/null +++ b/src/middleware/requireAuth.js @@ -0,0 +1,19 @@ +const db = require('../db'); + +const getParentStmt = db.prepare('SELECT id, household_id, email, name FROM parents WHERE id = ?'); + +function requireAuth(req, res, next) { + const parentId = req.session && req.session.parentId; + if (!parentId) return res.status(401).json({ error: 'Not signed in' }); + + const parent = getParentStmt.get(parentId); + if (!parent) { + req.session.destroy(() => {}); + return res.status(401).json({ error: 'Not signed in' }); + } + + req.parent = parent; + next(); +} + +module.exports = requireAuth; diff --git a/src/middleware/requireHousehold.js b/src/middleware/requireHousehold.js new file mode 100644 index 0000000..5e291d4 --- /dev/null +++ b/src/middleware/requireHousehold.js @@ -0,0 +1,33 @@ +const db = require('../db'); + +const getChildStmt = db.prepare('SELECT * FROM children WHERE id = ?'); +const getCalendarStmt = db.prepare('SELECT * FROM calendars WHERE id = ?'); + +// Resource nesting differs per route (child vs. calendar vs. task-under-calendar), +// so ownership checks are exposed as helpers route handlers call directly, +// rather than a one-size-fits-all param middleware. + +function loadOwnedChild(req, res, childId) { + const child = getChildStmt.get(childId); + if (!child || child.household_id !== req.parent.household_id) { + res.status(404).json({ error: 'Child not found' }); + return null; + } + return child; +} + +function loadOwnedCalendar(req, res, calendarId) { + const calendar = getCalendarStmt.get(calendarId); + if (!calendar) { + res.status(404).json({ error: 'Calendar not found' }); + return null; + } + const child = getChildStmt.get(calendar.child_id); + if (!child || child.household_id !== req.parent.household_id) { + res.status(404).json({ error: 'Calendar not found' }); + return null; + } + return calendar; +} + +module.exports = { loadOwnedChild, loadOwnedCalendar }; diff --git a/src/middleware/resolveKiosk.js b/src/middleware/resolveKiosk.js new file mode 100644 index 0000000..260661e --- /dev/null +++ b/src/middleware/resolveKiosk.js @@ -0,0 +1,15 @@ +const db = require('../db'); + +const getChildByTokenStmt = db.prepare('SELECT * FROM children WHERE kiosk_token = ?'); + +function resolveKiosk(req, res, next) { + if (typeof req.params.token !== 'string' || !req.params.token) { + return res.status(404).json({ error: 'Invalid kiosk link' }); + } + const child = getChildByTokenStmt.get(req.params.token); + if (!child) return res.status(404).json({ error: 'Invalid kiosk link' }); + req.child = child; + next(); +} + +module.exports = resolveKiosk; diff --git a/src/routes/auth.js b/src/routes/auth.js new file mode 100644 index 0000000..02f26d8 --- /dev/null +++ b/src/routes/auth.js @@ -0,0 +1,84 @@ +const express = require('express'); +const bcrypt = require('bcryptjs'); +const db = require('../db'); +const config = require('../config'); +const requireAuth = require('../middleware/requireAuth'); +const { isNonEmptyString, LIMITS } = require('../lib/validate'); + +const router = express.Router(); + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +const insertHouseholdStmt = db.prepare('INSERT INTO households (name) VALUES (?)'); +const insertParentStmt = db.prepare( + 'INSERT INTO parents (household_id, email, password_hash, name) VALUES (?, ?, ?, ?)' +); +const getParentByEmailStmt = db.prepare('SELECT * FROM parents WHERE email = ?'); +const getHouseholdStmt = db.prepare('SELECT * FROM households WHERE id = ?'); + +function publicParent(parent) { + return { id: parent.id, email: parent.email, name: parent.name, householdId: parent.household_id }; +} + +router.post('/signup', (req, res) => { + if (config.disablePublicSignup) { + return res.status(403).json({ error: 'New signups are disabled on this server' }); + } + + const { email, password, name, householdName } = req.body || {}; + if (!isNonEmptyString(email, LIMITS.EMAIL) || !EMAIL_RE.test(email)) { + return res.status(400).json({ error: 'A valid email is required' }); + } + if (typeof password !== 'string' || password.length < 8 || password.length > LIMITS.PASSWORD) { + return res.status(400).json({ error: `Password must be 8-${LIMITS.PASSWORD} characters` }); + } + if (!isNonEmptyString(name, LIMITS.NAME)) { + return res.status(400).json({ error: 'Name is required' }); + } + if (householdName !== undefined && householdName !== '' && !isNonEmptyString(householdName, LIMITS.HOUSEHOLD_NAME)) { + return res.status(400).json({ error: 'Family name is too long' }); + } + if (getParentByEmailStmt.get(email)) { + return res.status(409).json({ error: 'An account with that email already exists' }); + } + + const hhName = (typeof householdName === 'string' && householdName.trim()) + ? householdName.trim() + : `${name.trim()}'s Family`; + + const householdId = insertHouseholdStmt.run(hhName).lastInsertRowid; + const passwordHash = bcrypt.hashSync(password, 12); + const parentId = insertParentStmt.run(householdId, email.toLowerCase(), passwordHash, name.trim()).lastInsertRowid; + + req.session.parentId = Number(parentId); + res.status(201).json({ parent: publicParent({ id: parentId, email, name: name.trim(), household_id: householdId }) }); +}); + +router.post('/login', (req, res) => { + const { email, password } = req.body || {}; + if (!isNonEmptyString(email, LIMITS.EMAIL) || typeof password !== 'string' || password.length > LIMITS.PASSWORD) { + return res.status(400).json({ error: 'Email and password are required' }); + } + + const parent = getParentByEmailStmt.get(email.toLowerCase()); + if (!parent || !bcrypt.compareSync(password, parent.password_hash)) { + return res.status(401).json({ error: 'Invalid email or password' }); + } + + req.session.parentId = parent.id; + res.json({ parent: publicParent(parent) }); +}); + +router.post('/logout', (req, res) => { + req.session.destroy(() => { + res.clearCookie('kc.sid'); + res.status(204).end(); + }); +}); + +router.get('/me', requireAuth, (req, res) => { + const household = getHouseholdStmt.get(req.parent.household_id); + res.json({ parent: publicParent(req.parent), household: { id: household.id, name: household.name } }); +}); + +module.exports = router; diff --git a/src/routes/calendars.js b/src/routes/calendars.js new file mode 100644 index 0000000..4f3a895 --- /dev/null +++ b/src/routes/calendars.js @@ -0,0 +1,164 @@ +const express = require('express'); +const db = require('../db'); +const requireAuth = require('../middleware/requireAuth'); +const { loadOwnedCalendar } = require('../middleware/requireHousehold'); +const { hydrateCalendar } = require('../lib/calendarHydrate'); +const touchCalendar = require('../lib/touchCalendar'); +const { BLOCK_KEYS, ALL_DAYS } = require('../lib/defaults'); +const { isNonEmptyString, LIMITS } = require('../lib/validate'); + +const router = express.Router(); +router.use(requireAuth); + +const updateCalendarStmt = db.prepare( + 'UPDATE calendars SET title = COALESCE(?, title), week_start_date = COALESCE(?, week_start_date), show_weekend = COALESCE(?, show_weekend) WHERE id = ?' +); +const deleteCalendarStmt = db.prepare('DELETE FROM calendars WHERE id = ?'); +const upsertBlockStmt = db.prepare( + 'INSERT INTO calendar_blocks (calendar_id, block_key, label) VALUES (?, ?, ?) ' + + 'ON CONFLICT(calendar_id, block_key) DO UPDATE SET label = excluded.label' +); +const insertTaskStmt = db.prepare( + 'INSERT INTO calendar_tasks (calendar_id, day_of_week, block_key, text, sort_order) VALUES (?, ?, ?, ?, ?)' +); +const maxSortOrderStmt = db.prepare( + 'SELECT COALESCE(MAX(sort_order), -1) AS maxOrder FROM calendar_tasks WHERE calendar_id = ? AND day_of_week = ? AND block_key = ?' +); +const getTaskStmt = db.prepare('SELECT * FROM calendar_tasks WHERE id = ?'); +const updateTaskStmt = db.prepare( + 'UPDATE calendar_tasks SET text = COALESCE(?, text), done = COALESCE(?, done), sort_order = COALESCE(?, sort_order), ' + + "updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?" +); +const deleteTaskStmt = db.prepare('DELETE FROM calendar_tasks WHERE id = ?'); +const resetChecksStmt = db.prepare('UPDATE calendar_tasks SET done = 0 WHERE calendar_id = ?'); + +router.get('/:id', (req, res) => { + const calendar = loadOwnedCalendar(req, res, req.params.id); + if (!calendar) return; + res.json({ calendar: hydrateCalendar(calendar.id) }); +}); + +router.get('/:id/poll', (req, res) => { + const calendar = loadOwnedCalendar(req, res, req.params.id); + if (!calendar) return; + const since = req.query.since; + if (since && calendar.updated_at <= since) { + return res.json({ changed: false }); + } + res.json({ changed: true, calendar: hydrateCalendar(calendar.id) }); +}); + +router.patch('/:id', (req, res) => { + const calendar = loadOwnedCalendar(req, res, req.params.id); + if (!calendar) return; + + const { title, weekStartDate, showWeekend } = req.body || {}; + if (title !== undefined && !isNonEmptyString(title, LIMITS.TITLE)) { + return res.status(400).json({ error: 'Title cannot be empty' }); + } + + updateCalendarStmt.run( + title !== undefined ? title.trim() : null, + weekStartDate !== undefined ? weekStartDate : null, + showWeekend !== undefined ? (showWeekend ? 1 : 0) : null, + calendar.id + ); + touchCalendar(calendar.id); + res.json({ calendar: hydrateCalendar(calendar.id) }); +}); + +router.delete('/:id', (req, res) => { + const calendar = loadOwnedCalendar(req, res, req.params.id); + if (!calendar) return; + deleteCalendarStmt.run(calendar.id); + res.status(204).end(); +}); + +router.patch('/:id/blocks/:blockKey', (req, res) => { + const calendar = loadOwnedCalendar(req, res, req.params.id); + if (!calendar) return; + + const { blockKey } = req.params; + if (!BLOCK_KEYS.includes(blockKey)) { + return res.status(400).json({ error: 'Unknown block' }); + } + const { label } = req.body || {}; + if (!isNonEmptyString(label, LIMITS.LABEL)) { + return res.status(400).json({ error: 'Label cannot be empty' }); + } + + upsertBlockStmt.run(calendar.id, blockKey, label.trim()); + touchCalendar(calendar.id); + res.json({ calendar: hydrateCalendar(calendar.id) }); +}); + +router.post('/:id/tasks', (req, res) => { + const calendar = loadOwnedCalendar(req, res, req.params.id); + if (!calendar) return; + + const { dayOfWeek, blockKey, text } = req.body || {}; + if (!ALL_DAYS.includes(dayOfWeek)) return res.status(400).json({ error: 'Invalid day' }); + if (!BLOCK_KEYS.includes(blockKey)) return res.status(400).json({ error: 'Invalid block' }); + if (!isNonEmptyString(text, LIMITS.TASK_TEXT)) return res.status(400).json({ error: 'Text is required' }); + + const nextOrder = maxSortOrderStmt.get(calendar.id, dayOfWeek, blockKey).maxOrder + 1; + const taskId = insertTaskStmt.run(calendar.id, dayOfWeek, blockKey, text.trim(), nextOrder).lastInsertRowid; + touchCalendar(calendar.id); + + const task = getTaskStmt.get(taskId); + res.status(201).json({ + task: { id: task.id, text: task.text, done: !!task.done, sortOrder: task.sort_order, dayOfWeek, blockKey }, + }); +}); + +router.patch('/:id/tasks/:taskId', (req, res) => { + const calendar = loadOwnedCalendar(req, res, req.params.id); + if (!calendar) return; + + const task = getTaskStmt.get(req.params.taskId); + if (!task || task.calendar_id !== calendar.id) { + return res.status(404).json({ error: 'Task not found' }); + } + + const { text, done, sortOrder } = req.body || {}; + if (text !== undefined && !isNonEmptyString(text, LIMITS.TASK_TEXT)) { + return res.status(400).json({ error: 'Text cannot be empty' }); + } + + updateTaskStmt.run( + text !== undefined ? text.trim() : null, + done !== undefined ? (done ? 1 : 0) : null, + sortOrder !== undefined ? sortOrder : null, + task.id + ); + touchCalendar(calendar.id); + + const updated = getTaskStmt.get(task.id); + res.json({ + task: { id: updated.id, text: updated.text, done: !!updated.done, sortOrder: updated.sort_order }, + }); +}); + +router.delete('/:id/tasks/:taskId', (req, res) => { + const calendar = loadOwnedCalendar(req, res, req.params.id); + if (!calendar) return; + + const task = getTaskStmt.get(req.params.taskId); + if (!task || task.calendar_id !== calendar.id) { + return res.status(404).json({ error: 'Task not found' }); + } + + deleteTaskStmt.run(task.id); + touchCalendar(calendar.id); + res.status(204).end(); +}); + +router.post('/:id/reset-checks', (req, res) => { + const calendar = loadOwnedCalendar(req, res, req.params.id); + if (!calendar) return; + resetChecksStmt.run(calendar.id); + touchCalendar(calendar.id); + res.json({ calendar: hydrateCalendar(calendar.id) }); +}); + +module.exports = router; diff --git a/src/routes/children.js b/src/routes/children.js new file mode 100644 index 0000000..f9482cd --- /dev/null +++ b/src/routes/children.js @@ -0,0 +1,128 @@ +const express = require('express'); +const db = require('../db'); +const requireAuth = require('../middleware/requireAuth'); +const { loadOwnedChild, loadOwnedCalendar } = require('../middleware/requireHousehold'); +const { randomToken } = require('../lib/tokens'); +const { createBlankCalendar, createDuplicateCalendar } = require('../lib/calendarSeed'); +const { isNonEmptyString, LIMITS } = require('../lib/validate'); + +const router = express.Router(); +router.use(requireAuth); + +const listChildrenStmt = db.prepare('SELECT * FROM children WHERE household_id = ? ORDER BY created_at'); +const insertChildStmt = db.prepare( + 'INSERT INTO children (household_id, name, kiosk_token) VALUES (?, ?, ?)' +); +const updateChildNameStmt = db.prepare('UPDATE children SET name = ? WHERE id = ?'); +const updateChildActiveCalendarStmt = db.prepare('UPDATE children SET active_calendar_id = ? WHERE id = ?'); +const deleteChildStmt = db.prepare('DELETE FROM children WHERE id = ?'); +const regenKioskTokenStmt = db.prepare('UPDATE children SET kiosk_token = ? WHERE id = ?'); +const listCalendarsForChildStmt = db.prepare( + 'SELECT id, title, week_start_date, show_weekend, updated_at, created_at FROM calendars WHERE child_id = ? ORDER BY created_at DESC' +); + +function publicChild(child) { + return { + id: child.id, + name: child.name, + kioskToken: child.kiosk_token, + kioskPath: `/k/${child.kiosk_token}`, + activeCalendarId: child.active_calendar_id, + }; +} + +router.get('/', (req, res) => { + const children = listChildrenStmt.all(req.parent.household_id).map(publicChild); + res.json({ children }); +}); + +router.post('/', (req, res) => { + const { name } = req.body || {}; + if (!isNonEmptyString(name, LIMITS.NAME)) { + return res.status(400).json({ error: 'Name is required' }); + } + const kioskToken = randomToken(); + const id = insertChildStmt.run(req.parent.household_id, name.trim(), kioskToken).lastInsertRowid; + const child = { id: Number(id), name: name.trim(), kiosk_token: kioskToken, active_calendar_id: null }; + res.status(201).json({ child: publicChild(child) }); +}); + +router.get('/:id', (req, res) => { + const child = loadOwnedChild(req, res, req.params.id); + if (!child) return; + res.json({ child: publicChild(child) }); +}); + +router.patch('/:id', (req, res) => { + const child = loadOwnedChild(req, res, req.params.id); + if (!child) return; + + const { name, activeCalendarId } = req.body || {}; + if (name !== undefined) { + if (!isNonEmptyString(name, LIMITS.NAME)) { + return res.status(400).json({ error: 'Name cannot be empty' }); + } + updateChildNameStmt.run(name.trim(), child.id); + } + if (activeCalendarId !== undefined) { + if (activeCalendarId !== null) { + const cal = loadOwnedCalendar(req, res, activeCalendarId); + if (!cal) return; + if (cal.child_id !== child.id) { + return res.status(400).json({ error: 'That calendar does not belong to this child' }); + } + } + updateChildActiveCalendarStmt.run(activeCalendarId, child.id); + } + + const updated = loadOwnedChild(req, res, child.id); + res.json({ child: publicChild(updated) }); +}); + +router.delete('/:id', (req, res) => { + const child = loadOwnedChild(req, res, req.params.id); + if (!child) return; + deleteChildStmt.run(child.id); + res.status(204).end(); +}); + +router.post('/:id/kiosk-token/regenerate', (req, res) => { + const child = loadOwnedChild(req, res, req.params.id); + if (!child) return; + const kioskToken = randomToken(); + regenKioskTokenStmt.run(kioskToken, child.id); + res.json({ kioskToken, kioskPath: `/k/${kioskToken}` }); +}); + +router.get('/:childId/calendars', (req, res) => { + const child = loadOwnedChild(req, res, req.params.childId); + if (!child) return; + const calendars = listCalendarsForChildStmt.all(child.id); + res.json({ calendars }); +}); + +router.post('/:childId/calendars', (req, res) => { + const child = loadOwnedChild(req, res, req.params.childId); + if (!child) return; + + const { title, weekStartDate, duplicateFromCalendarId } = req.body || {}; + if (!isNonEmptyString(title, LIMITS.TITLE)) { + return res.status(400).json({ error: 'Title is required' }); + } + + let calendar; + if (duplicateFromCalendarId) { + const source = loadOwnedCalendar(req, res, duplicateFromCalendarId); + if (!source) return; + if (source.child_id !== child.id) { + return res.status(400).json({ error: 'That calendar does not belong to this child' }); + } + calendar = createDuplicateCalendar(source.id, child.id, title.trim(), weekStartDate || null); + } else { + calendar = createBlankCalendar(child.id, title.trim(), weekStartDate || null); + } + + res.status(201).json({ calendar }); +}); + +module.exports = router; diff --git a/src/routes/household.js b/src/routes/household.js new file mode 100644 index 0000000..a7dc45a --- /dev/null +++ b/src/routes/household.js @@ -0,0 +1,29 @@ +const express = require('express'); +const db = require('../db'); +const requireAuth = require('../middleware/requireAuth'); +const { isNonEmptyString, LIMITS } = require('../lib/validate'); + +const router = express.Router(); + +const getHouseholdStmt = db.prepare('SELECT * FROM households WHERE id = ?'); +const listParentsStmt = db.prepare('SELECT id, email, name, created_at FROM parents WHERE household_id = ? ORDER BY created_at'); +const renameHouseholdStmt = db.prepare('UPDATE households SET name = ? WHERE id = ?'); + +router.use(requireAuth); + +router.get('/', (req, res) => { + const household = getHouseholdStmt.get(req.parent.household_id); + const parents = listParentsStmt.all(req.parent.household_id); + res.json({ household: { id: household.id, name: household.name }, parents }); +}); + +router.patch('/', (req, res) => { + const { name } = req.body || {}; + if (!isNonEmptyString(name, LIMITS.HOUSEHOLD_NAME)) { + return res.status(400).json({ error: 'Name is required' }); + } + renameHouseholdStmt.run(name.trim(), req.parent.household_id); + res.json({ household: { id: req.parent.household_id, name: name.trim() } }); +}); + +module.exports = router; diff --git a/src/routes/invites.js b/src/routes/invites.js new file mode 100644 index 0000000..6b3f96d --- /dev/null +++ b/src/routes/invites.js @@ -0,0 +1,103 @@ +const express = require('express'); +const bcrypt = require('bcryptjs'); +const db = require('../db'); +const requireAuth = require('../middleware/requireAuth'); +const { randomToken } = require('../lib/tokens'); +const { isNonEmptyString, LIMITS } = require('../lib/validate'); + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +const insertInviteStmt = db.prepare( + 'INSERT INTO household_invites (household_id, token, created_by_parent_id, expires_at) VALUES (?, ?, ?, ?)' +); +const listInvitesStmt = db.prepare( + 'SELECT id, token, expires_at, used_at, created_at FROM household_invites WHERE household_id = ? ORDER BY created_at DESC' +); +const getInviteForRevokeStmt = db.prepare('SELECT * FROM household_invites WHERE id = ?'); +const deleteInviteStmt = db.prepare('DELETE FROM household_invites WHERE id = ?'); +const getInviteByTokenStmt = db.prepare('SELECT * FROM household_invites WHERE token = ?'); +const getHouseholdStmt = db.prepare('SELECT * FROM households WHERE id = ?'); +const markInviteUsedStmt = db.prepare('UPDATE household_invites SET used_at = ?, used_by_parent_id = ? WHERE id = ?'); +const getParentByEmailStmt = db.prepare('SELECT * FROM parents WHERE email = ?'); +const insertParentStmt = db.prepare( + 'INSERT INTO parents (household_id, email, password_hash, name) VALUES (?, ?, ?, ?)' +); + +function publicParent(parent) { + return { id: parent.id, email: parent.email, name: parent.name, householdId: parent.household_id }; +} + +// Session-authenticated: manage invites for the caller's own household. +const manageRouter = express.Router(); +manageRouter.use(requireAuth); + +manageRouter.post('/', (req, res) => { + const token = randomToken(); + const expiresAt = new Date(Date.now() + INVITE_TTL_MS).toISOString(); + const id = insertInviteStmt.run(req.parent.household_id, token, req.parent.id, expiresAt).lastInsertRowid; + res.status(201).json({ + invite: { id: Number(id), token, expiresAt, joinPath: `/join.html?token=${token}` }, + }); +}); + +manageRouter.get('/', (req, res) => { + const invites = listInvitesStmt.all(req.parent.household_id); + res.json({ invites }); +}); + +manageRouter.delete('/:id', (req, res) => { + const invite = getInviteForRevokeStmt.get(req.params.id); + if (!invite || invite.household_id !== req.parent.household_id) { + return res.status(404).json({ error: 'Invite not found' }); + } + deleteInviteStmt.run(req.params.id); + res.status(204).end(); +}); + +// Public: view + accept an invite (no session yet — the whole point is to create one). +const publicRouter = express.Router(); + +publicRouter.get('/:token', (req, res) => { + const invite = getInviteByTokenStmt.get(req.params.token); + if (!invite || invite.used_at || invite.expires_at < new Date().toISOString()) { + return res.status(404).json({ error: 'This invite link is invalid or has expired' }); + } + const household = getHouseholdStmt.get(invite.household_id); + res.json({ householdName: household.name }); +}); + +publicRouter.post('/:token/accept', (req, res) => { + const invite = getInviteByTokenStmt.get(req.params.token); + if (!invite || invite.used_at || invite.expires_at < new Date().toISOString()) { + return res.status(404).json({ error: 'This invite link is invalid or has expired' }); + } + + const { email, password, name } = req.body || {}; + if (!isNonEmptyString(email, LIMITS.EMAIL) || !EMAIL_RE.test(email)) { + return res.status(400).json({ error: 'A valid email is required' }); + } + if (typeof password !== 'string' || password.length < 8 || password.length > LIMITS.PASSWORD) { + return res.status(400).json({ error: `Password must be 8-${LIMITS.PASSWORD} characters` }); + } + if (!isNonEmptyString(name, LIMITS.NAME)) { + return res.status(400).json({ error: 'Name is required' }); + } + if (getParentByEmailStmt.get(email.toLowerCase())) { + return res.status(409).json({ error: 'An account with that email already exists' }); + } + + const passwordHash = bcrypt.hashSync(password, 12); + const parentId = insertParentStmt.run( + invite.household_id, email.toLowerCase(), passwordHash, name.trim() + ).lastInsertRowid; + + markInviteUsedStmt.run(new Date().toISOString(), Number(parentId), invite.id); + + req.session.parentId = Number(parentId); + res.status(201).json({ + parent: publicParent({ id: parentId, email, name: name.trim(), household_id: invite.household_id }), + }); +}); + +module.exports = { manageRouter, publicRouter }; diff --git a/src/routes/kiosk.js b/src/routes/kiosk.js new file mode 100644 index 0000000..52ea40f --- /dev/null +++ b/src/routes/kiosk.js @@ -0,0 +1,48 @@ +const express = require('express'); +const db = require('../db'); +const resolveKiosk = require('../middleware/resolveKiosk'); +const { hydrateCalendar } = require('../lib/calendarHydrate'); +const touchCalendar = require('../lib/touchCalendar'); + +const router = express.Router({ mergeParams: true }); +router.use(resolveKiosk); + +const getTaskStmt = db.prepare('SELECT * FROM calendar_tasks WHERE id = ?'); +const setDoneStmt = db.prepare( + "UPDATE calendar_tasks SET done = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?" +); + +// Deliberately only two routes exist on this router: read the active calendar, +// and toggle a task's `done` flag. No create/delete/text-edit/label routes are +// wired up here at all — the access boundary is what routes exist, not UI hiding. + +router.get('/calendar', (req, res) => { + if (!req.child.active_calendar_id) { + return res.json({ child: { id: req.child.id, name: req.child.name }, calendar: null }); + } + const calendar = hydrateCalendar(req.child.active_calendar_id); + res.json({ child: { id: req.child.id, name: req.child.name }, calendar }); +}); + +router.patch('/tasks/:taskId', (req, res) => { + if (!req.child.active_calendar_id) { + return res.status(404).json({ error: 'No active calendar for this child' }); + } + + const task = getTaskStmt.get(req.params.taskId); + if (!task || task.calendar_id !== req.child.active_calendar_id) { + return res.status(404).json({ error: 'Task not found' }); + } + + const { done } = req.body || {}; + if (typeof done !== 'boolean') { + return res.status(400).json({ error: 'done must be a boolean' }); + } + + setDoneStmt.run(done ? 1 : 0, task.id); + touchCalendar(task.calendar_id); + + res.json({ task: { id: task.id, done } }); +}); + +module.exports = router;