From 7e9406a8ae89748e9bf71e9e3c5983f0f3a461d4 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Tue, 16 Jun 2026 13:14:36 -0600 Subject: [PATCH] first commit --- .env.example | 6 + .gitignore | 7 + README.md | 66 ++ db/index.js | 62 ++ db/schema.sql | 60 ++ package-lock.json | 1426 +++++++++++++++++++++++++ package.json | 26 + public/admin.html | 144 +++ public/css/app.css | 96 ++ public/index.html | 212 ++++ public/js/admin.js | 117 ++ public/js/api.js | 42 + public/js/app.js | 242 +++++ public/js/theme.js | 27 + public/login.html | 65 ++ scripts/vendor.js | 33 + server.js | 77 ++ src/auth.js | 50 + src/middleware/csrf.js | 15 + src/middleware/requireAdmin.js | 9 + src/middleware/requireAuth.js | 9 + src/middleware/requireOwnerOrAdmin.js | 37 + src/routes/auth.routes.js | 61 ++ src/routes/chart.routes.js | 61 ++ src/routes/entries.routes.js | 54 + src/routes/kpis.routes.js | 70 ++ src/routes/projects.routes.js | 57 + src/routes/schemes.routes.js | 99 ++ src/routes/users.routes.js | 96 ++ 29 files changed, 3326 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 db/index.js create mode 100644 db/schema.sql create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/admin.html create mode 100644 public/css/app.css create mode 100644 public/index.html create mode 100644 public/js/admin.js create mode 100644 public/js/api.js create mode 100644 public/js/app.js create mode 100644 public/js/theme.js create mode 100644 public/login.html create mode 100644 scripts/vendor.js create mode 100644 server.js create mode 100644 src/auth.js create mode 100644 src/middleware/csrf.js create mode 100644 src/middleware/requireAdmin.js create mode 100644 src/middleware/requireAuth.js create mode 100644 src/middleware/requireOwnerOrAdmin.js create mode 100644 src/routes/auth.routes.js create mode 100644 src/routes/chart.routes.js create mode 100644 src/routes/entries.routes.js create mode 100644 src/routes/kpis.routes.js create mode 100644 src/routes/projects.routes.js create mode 100644 src/routes/schemes.routes.js create mode 100644 src/routes/users.routes.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bae4ab2 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Copy to .env and adjust. The admin account is created on first run if the DB is empty. +PORT=3000 +SESSION_SECRET=change-me-to-a-long-random-string +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me +ADMIN_EMAIL=admin@example.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ce814f2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.env +db/*.db +db/*.db-shm +db/*.db-wal +public/vendor/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..e61cc63 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# Bowler Chart + +A small, self-hosted web app for a team to collaboratively build and report on a +**bowler chart** (a.k.a. bowling chart) — tracking project and KPI status month by month. + +- **Everyone** can view the whole chart. +- Each **user** can create/edit/delete only **their own** projects and KPIs. +- A single **administrator** manages users and color schemes. +- **Material 3** UI (Beer CSS) with admin-defined, per-user-switchable color schemes. + +## Tech stack + +| Layer | Choice | +|---|---| +| Server | Node.js + Express | +| Database | SQLite (`better-sqlite3`) | +| Sessions | `express-session` + SQLite store | +| Passwords | `bcryptjs` | +| Hardening | `helmet` + double-submit CSRF tokens | +| UI | Beer CSS (Material 3) + Alpine.js, **no build step** | +| Theming | `material-dynamic-colors` (seed color → full palette) | + +Front-end libraries are **vendored** into `public/vendor/` (no remote CDN calls). + +## Setup + +```sh +npm install # installs deps (needs Node 18+; tested on Node 24) +npm run vendor # copies UI libs from node_modules into public/vendor +cp .env.example .env # then edit .env (set ADMIN_PASSWORD + SESSION_SECRET) +npm start +``` + +Open http://localhost:3000 and sign in with the admin credentials from `.env`. +The admin account and two starter color schemes are created automatically on first run +(only when the database is empty). + +> Native module note: `better-sqlite3` must be **v12+** on Node 24 — earlier versions +> have no prebuilt binary for Node 24 and would require a C/Python build toolchain. + +## Project layout + +``` +server.js Express bootstrap, middleware, route mounting +db/ schema.sql + connection/seed (db/index.js) +src/ + auth.js credential check, hashing, CSRF token + middleware/ requireAuth, requireAdmin, csrf, requireOwnerOrAdmin + routes/ auth, users, schemes, chart, projects, kpis, entries +public/ login.html, index.html, admin.html, css, js, vendor +scripts/vendor.js copies UI libs into public/vendor +``` + +## Data model + +`users` → owns → `projects` → has → `kpis` → has 12 monthly → `entries` +(`value` + manual `status`: green / amber / red / none). `color_schemes` define a +seed color + light/dark mode; users pick a preferred one (falls back to the default). + +## Authorization + +- View endpoints (`GET /api/chart`, `GET /api/schemes`) require only login. +- Mutations re-check ownership server-side: a user may modify a project (and its + KPIs/entries) only if they own it; the admin may modify anything. +- All state-changing requests must include the `X-CSRF-Token` header (issued via + `GET /api/me` and login). diff --git a/db/index.js b/db/index.js new file mode 100644 index 0000000..ad3085c --- /dev/null +++ b/db/index.js @@ -0,0 +1,62 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const Database = require('better-sqlite3'); +const bcrypt = require('bcryptjs'); + +const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'bowler.db'); +const SCHEMA_PATH = path.join(__dirname, 'schema.sql'); + +const db = new Database(DB_PATH); +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +// Apply schema (idempotent — all statements use IF NOT EXISTS). +db.exec(fs.readFileSync(SCHEMA_PATH, 'utf8')); + +/** + * First-run seed: if there are no users yet, create the admin from env vars + * and a sensible default color scheme. + */ +function seed() { + const userCount = db.prepare('SELECT COUNT(*) AS n FROM users').get().n; + if (userCount > 0) return; + + const schemeCount = db.prepare('SELECT COUNT(*) AS n FROM color_schemes').get().n; + let defaultSchemeId; + if (schemeCount === 0) { + const info = db + .prepare( + `INSERT INTO color_schemes (name, seed_color, mode, is_default) + VALUES (@name, @seed_color, @mode, 1)` + ) + .run({ name: 'Default', seed_color: '#1565c0', mode: 'light' }); + defaultSchemeId = info.lastInsertRowid; + // A second scheme so the picker has something to toggle out of the box. + db.prepare( + `INSERT INTO color_schemes (name, seed_color, mode, is_default) + VALUES (@name, @seed_color, @mode, 0)` + ).run({ name: 'Midnight', seed_color: '#7e57c2', mode: 'dark' }); + } else { + defaultSchemeId = db + .prepare('SELECT id FROM color_schemes ORDER BY is_default DESC, id ASC LIMIT 1') + .get().id; + } + + const username = process.env.ADMIN_USERNAME || 'admin'; + const password = process.env.ADMIN_PASSWORD || 'change-me'; + const email = process.env.ADMIN_EMAIL || null; + const hash = bcrypt.hashSync(password, 10); + + db.prepare( + `INSERT INTO users (username, email, password_hash, role, preferred_scheme_id, active) + VALUES (@username, @email, @hash, 'admin', @scheme, 1)` + ).run({ username, email, hash, scheme: defaultSchemeId }); + + console.log(`[seed] Created admin user "${username}" and default color schemes.`); +} + +seed(); + +module.exports = db; diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..436265d --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,60 @@ +-- Bowler Chart schema. Applied idempotently on startup. +PRAGMA journal_mode = WAL; +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS color_schemes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + seed_color TEXT NOT NULL, -- #rrggbb + mode TEXT NOT NULL DEFAULT 'light', -- 'light' | 'dark' + is_default INTEGER NOT NULL DEFAULT 0, -- 0/1; exactly one row should be 1 + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + email TEXT, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', -- 'admin' | 'user' + preferred_scheme_id INTEGER REFERENCES color_schemes(id) ON DELETE SET NULL, + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS kpis ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + unit TEXT, + goal_text TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kpi_id INTEGER NOT NULL REFERENCES kpis(id) ON DELETE CASCADE, + year INTEGER NOT NULL, + month INTEGER NOT NULL CHECK (month BETWEEN 1 AND 12), + value TEXT, + status TEXT NOT NULL DEFAULT 'none', -- 'green' | 'amber' | 'red' | 'none' + note TEXT, + updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (kpi_id, year, month) +); + +CREATE INDEX IF NOT EXISTS idx_projects_owner ON projects(owner_id); +CREATE INDEX IF NOT EXISTS idx_kpis_project ON kpis(project_id); +CREATE INDEX IF NOT EXISTS idx_entries_kpi ON entries(kpi_id, year); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ff0fe95 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1426 @@ +{ + "name": "bowler-chart", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bowler-chart", + "version": "1.0.0", + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^12.2.0", + "better-sqlite3-session-store": "^0.1.0", + "dotenv": "^16.4.5", + "express": "^4.21.0", + "express-session": "^1.18.0", + "helmet": "^8.0.0" + }, + "devDependencies": { + "alpinejs": "^3.14.1", + "beercss": "^3.7.0", + "material-dynamic-colors": "^1.1.2" + } + }, + "node_modules/@material/material-color-utilities": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@material/material-color-utilities/-/material-color-utilities-0.3.0.tgz", + "integrity": "sha512-ztmtTd6xwnuh2/xu+Vb01btgV8SQWYCaK56CkRK8gEkWe5TuDyBcYJ0wgkMRn+2VcE9KUmhvkz+N9GHrqw/C0g==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@vue/reactivity": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz", + "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.1.5" + } + }, + "node_modules/@vue/shared": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz", + "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==", + "dev": true, + "license": "MIT" + }, + "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/alpinejs": { + "version": "3.15.12", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz", + "integrity": "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "~3.1.1" + } + }, + "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/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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/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/beercss": { + "version": "3.13.3", + "resolved": "https://registry.npmjs.org/beercss/-/beercss-3.13.3.tgz", + "integrity": "sha512-tMs2kYBiq+wYqqnE+H2wy0BBIMpzRwy3EKnR40XnE41JUFdkM1CdsCnV+RGMB0fokAQVW2WPaNIik0VPUfitLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "material-dynamic-colors": "^1.1.2" + } + }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/better-sqlite3-session-store": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/better-sqlite3-session-store/-/better-sqlite3-session-store-0.1.0.tgz", + "integrity": "sha512-O4EO5jOGTEa/c1DbZpP3C7VTDLSWe5lrOu1S/j86ipdGZxrSb8bSUVuRgWCgl/SCgEGmyeEqvlMY9HtyOSMOWA==", + "license": "GPL-3.0-only", + "dependencies": { + "date-fns": "2.16.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "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/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "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", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "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/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "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/date-fns": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.16.1.tgz", + "integrity": "sha512-sAJVKx/FqrLYHAQeN7VpJrPhagZc9R4ImZIWYRFZaaohR3KzmuK88touwsSwSVT8Qcbd4zoDsnGfX4GFB4imyQ==", + "license": "MIT", + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "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/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "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/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "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/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=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-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/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "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/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "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/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "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/helmet": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz", + "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, + "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/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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": "BSD-3-Clause" + }, + "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/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "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/material-dynamic-colors": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/material-dynamic-colors/-/material-dynamic-colors-1.1.4.tgz", + "integrity": "sha512-0HnLNoxDC/+pCTfhOgvSeqQKXO8n6PLO1D3IwtYr0Gg2SR2pGxft5nNngajVvtEADzXtxWvinmTdig+lUJqkxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@material/material-color-utilities": "^0.3.0" + }, + "bin": { + "material-dynamic-colors": "cli.js" + } + }, + "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/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "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/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "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/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "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/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "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/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "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/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "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/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "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/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "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/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "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", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "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/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "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/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "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" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8a26dcc --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "bowler-chart", + "version": "1.0.0", + "description": "Collaborative bowler chart (bowling chart) for tracking project and KPI status", + "type": "commonjs", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js", + "vendor": "node scripts/vendor.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^12.2.0", + "better-sqlite3-session-store": "^0.1.0", + "dotenv": "^16.4.5", + "express": "^4.21.0", + "express-session": "^1.18.0", + "helmet": "^8.0.0" + }, + "devDependencies": { + "alpinejs": "^3.14.1", + "beercss": "^3.7.0", + "material-dynamic-colors": "^1.1.2" + } +} diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..1de359e --- /dev/null +++ b/public/admin.html @@ -0,0 +1,144 @@ + + + + + + Admin · Bowler Chart + + + + + + + + + + + + + +
+ + + +
+
+
Users
+ +
+ + + + + +
UsernameEmailRoleStatus
+
+ + +
+
+
Color schemes
+ +
+
+ +
+
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ +
+ + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +

Tip: the live UI previews this color as you adjust it.

+ +
+ +
+ + + diff --git a/public/css/app.css b/public/css/app.css new file mode 100644 index 0000000..6293f5d --- /dev/null +++ b/public/css/app.css @@ -0,0 +1,96 @@ +/* App-specific styling layered on top of Beer CSS (Material 3). */ + +:root { + --status-green: #2e7d32; + --status-amber: #f9a825; + --status-red: #c62828; + --status-none: transparent; +} + +body { padding-bottom: 4rem; } + +.app-bar-title { font-weight: 600; letter-spacing: 0.2px; } + +/* ---- Bowler chart grid ---- */ +.bowler-wrap { overflow-x: auto; } + +table.bowler { + border-collapse: collapse; + width: 100%; + min-width: 64rem; + font-size: 0.9rem; +} + +table.bowler th, +table.bowler td { + border: 1px solid var(--outline-variant, #cac4d0); + padding: 0.35rem 0.5rem; + text-align: center; + white-space: nowrap; +} + +table.bowler thead th { + background: var(--surface-container-high, #ece6f0); + position: sticky; + top: 0; + z-index: 2; +} + +/* Frozen first column (KPI name) */ +table.bowler .kpi-col { + text-align: left; + position: sticky; + left: 0; + background: var(--surface, #fef7ff); + z-index: 1; + min-width: 16rem; + max-width: 22rem; + white-space: normal; +} + +table.bowler tr.project-row td { + text-align: left; + background: var(--secondary-container, #e8def8); + color: var(--on-secondary-container, #1d192b); + font-weight: 600; + position: sticky; + left: 0; +} + +/* Status cells */ +.cell { cursor: default; } +.cell.editable { cursor: pointer; } +.cell.editable:hover { outline: 2px solid var(--primary, #6750a4); outline-offset: -2px; } + +.cell.green { background: var(--status-green); color: #fff; } +.cell.amber { background: var(--status-amber); color: #000; } +.cell.red { background: var(--status-red); color: #fff; } +.cell.none { background: var(--status-none); } + +.cell .cell-value { font-weight: 600; } +.cell.has-note::after { + content: '•'; + position: absolute; + top: 2px; + right: 4px; + font-size: 0.7rem; + opacity: 0.7; +} +.cell { position: relative; } + +/* Sparkline trend column */ +.trend-col svg { display: block; } + +.muted { opacity: 0.7; } +.right { text-align: right; } +.nowrap { white-space: nowrap; } + +/* Login screen */ +.login-shell { + min-height: 100vh; + display: grid; + place-items: center; +} +.login-card { width: min(92vw, 24rem); } + +dialog.modal { width: min(94vw, 32rem); } diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..54d44d8 --- /dev/null +++ b/public/index.html @@ -0,0 +1,212 @@ + + + + + + Bowler Chart + + + + + + + + + + + + + + +
+ +
+
KPI Status —
+ +
+ + + +
+
+

No projects yet. Create one to start tracking KPIs.

+
+ + + + + + + + + + + + + + +
KPIGoalTrend
+
+
+ + + +
—
+
+ + +
+
+ + +
+
+ + +
+ +
+ + + +
+
+ + +
+
+ + +
+ +
+ + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ + +
+ + + + diff --git a/public/js/admin.js b/public/js/admin.js new file mode 100644 index 0000000..9c9e566 --- /dev/null +++ b/public/js/admin.js @@ -0,0 +1,117 @@ +'use strict'; + +function adminApp() { + return { + me: null, + users: [], + schemes: [], + error: '', + tab: 'users', + + user: { id: null, username: '', email: '', password: '', role: 'user', active: true }, + scheme: { id: null, name: '', seed_color: '#1565c0', mode: 'light', is_default: false }, + + async init() { + try { + const me = await API.get('/api/me'); + this.me = me.user; + if (me.user.role !== 'admin') { window.location.href = '/'; return; } + } catch (_) { + window.location.href = '/login.html'; + return; + } + await this.loadSchemes(); + await this.loadUsers(); + const scheme = Theme.resolve(this.schemes, this.me.preferred_scheme_id); + await Theme.apply(scheme); + }, + + async loadUsers() { + try { this.users = (await API.get('/api/users')).users; } + catch (e) { this.toast(e.message); } + }, + async loadSchemes() { + try { this.schemes = (await API.get('/api/schemes')).schemes; } + catch (e) { this.toast(e.message); } + }, + schemeName(id) { + const s = this.schemes.find((x) => x.id === id); + return s ? s.name : '—'; + }, + + // ---- Users ---- + openNewUser() { + this.user = { id: null, username: '', email: '', password: '', role: 'user', active: true }; + this.$refs.userDialog.showModal(); + }, + openEditUser(u) { + this.user = { id: u.id, username: u.username, email: u.email || '', password: '', role: u.role, active: !!u.active }; + this.$refs.userDialog.showModal(); + }, + async saveUser() { + try { + if (this.user.id) { + const body = { email: this.user.email, role: this.user.role, active: this.user.active }; + if (this.user.password) body.password = this.user.password; + await API.patch(`/api/users/${this.user.id}`, body); + } else { + await API.post('/api/users', { + username: this.user.username, + email: this.user.email, + password: this.user.password, + role: this.user.role, + active: this.user.active, + }); + } + this.$refs.userDialog.close(); + await this.loadUsers(); + } catch (e) { this.toast(e.message); } + }, + async deleteUser(u) { + if (!confirm(`Delete user "${u.username}"? Their projects and KPIs will also be removed.`)) return; + try { await API.del(`/api/users/${u.id}`); await this.loadUsers(); } + catch (e) { this.toast(e.message); } + }, + + // ---- Schemes ---- + openNewScheme() { + this.scheme = { id: null, name: '', seed_color: '#1565c0', mode: 'light', is_default: false }; + this.$refs.schemeDialog.showModal(); + }, + openEditScheme(s) { + this.scheme = { id: s.id, name: s.name, seed_color: s.seed_color, mode: s.mode, is_default: !!s.is_default }; + this.$refs.schemeDialog.showModal(); + }, + previewScheme() { + Theme.apply({ seed_color: this.scheme.seed_color, mode: this.scheme.mode }); + }, + async saveScheme() { + try { + const body = { + name: this.scheme.name, + seed_color: this.scheme.seed_color, + mode: this.scheme.mode, + is_default: this.scheme.is_default, + }; + if (this.scheme.id) await API.patch(`/api/schemes/${this.scheme.id}`, body); + else await API.post('/api/schemes', body); + this.$refs.schemeDialog.close(); + await this.loadSchemes(); + // Restore the admin's own theme after previewing. + await Theme.apply(Theme.resolve(this.schemes, this.me.preferred_scheme_id)); + } catch (e) { this.toast(e.message); } + }, + async deleteScheme(s) { + if (!confirm(`Delete scheme "${s.name}"?`)) return; + try { await API.del(`/api/schemes/${s.id}`); await this.loadSchemes(); } + catch (e) { this.toast(e.message); } + }, + + toast(msg) { + this.error = msg; + if (typeof window.ui === 'function') window.ui('#snackbar'); + }, + }; +} + +window.adminApp = adminApp; diff --git a/public/js/api.js b/public/js/api.js new file mode 100644 index 0000000..6239276 --- /dev/null +++ b/public/js/api.js @@ -0,0 +1,42 @@ +'use strict'; + +// Tiny fetch wrapper. Tracks the CSRF token (from GET /api/me or login) and +// attaches it to every state-changing request as X-CSRF-Token. +window.API = (function () { + let csrfToken = null; + + async function request(method, url, body) { + const headers = {}; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + if (method !== 'GET' && csrfToken) headers['X-CSRF-Token'] = csrfToken; + + const res = await fetch(url, { + method, + headers, + credentials: 'same-origin', + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + + let data = null; + const text = await res.text(); + if (text) { + try { data = JSON.parse(text); } catch (_) { data = { raw: text }; } + } + if (!res.ok) { + const err = new Error((data && data.error) || `Request failed (${res.status})`); + err.status = res.status; + throw err; + } + if (data && data.csrfToken) csrfToken = data.csrfToken; + return data; + } + + return { + setCsrf: (t) => { csrfToken = t; }, + get: (url) => request('GET', url), + post: (url, body) => request('POST', url, body || {}), + patch: (url, body) => request('PATCH', url, body || {}), + put: (url, body) => request('PUT', url, body || {}), + del: (url) => request('DELETE', url), + }; +})(); diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..797f7e7 --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,242 @@ +'use strict'; + +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; +const STATUS_OPTIONS = [ + { value: 'green', label: 'On track' }, + { value: 'amber', label: 'At risk' }, + { value: 'red', label: 'Off track' }, + { value: 'none', label: 'No data' }, +]; + +function bowlerApp() { + return { + me: null, + isAdmin: false, + schemes: [], + year: new Date().getFullYear(), + years: [], + projects: [], + loading: true, + error: '', + months: MONTHS, + statusOptions: STATUS_OPTIONS, + + // Dialog state + cell: { kpiId: null, kpiName: '', month: 1, value: '', status: 'none', note: '' }, + proj: { id: null, name: '', description: '' }, + kpi: { id: null, projectId: null, name: '', unit: '', goal_text: '', description: '' }, + + async init() { + const y = new Date().getFullYear(); + this.years = [y - 2, y - 1, y, y + 1]; + try { + const me = await API.get('/api/me'); + this.me = me.user; + this.isAdmin = me.user.role === 'admin'; + } catch (_) { + window.location.href = '/login.html'; + return; + } + await this.loadSchemes(); + await this.loadChart(); + }, + + async loadSchemes() { + try { + const { schemes } = await API.get('/api/schemes'); + this.schemes = schemes; + const scheme = Theme.resolve(schemes, this.me.preferred_scheme_id); + await Theme.apply(scheme); + } catch (_) {} + }, + + async loadChart() { + this.loading = true; + this.error = ''; + try { + const data = await API.get(`/api/chart?year=${this.year}`); + this.projects = data.projects; + } catch (e) { + this.error = e.message; + } finally { + this.loading = false; + } + }, + + canEditAny() { + return this.projects.some((p) => p.can_edit); + }, + + // ---- Theme ---- + async changeScheme(id) { + const scheme = this.schemes.find((s) => s.id === Number(id)); + await Theme.apply(scheme); + try { + await API.patch('/api/me/scheme', { scheme_id: scheme ? scheme.id : null }); + this.me.preferred_scheme_id = scheme ? scheme.id : null; + } catch (e) { + this.toast(e.message); + } + }, + + // ---- Cell editing ---- + openCell(project, kpi, m) { + if (!project.can_edit) return; + this.cell = { + kpiId: kpi.id, + kpiName: kpi.name, + month: m.month, + value: m.value ?? '', + status: m.status || 'none', + note: m.note ?? '', + }; + this.$refs.cellDialog.showModal(); + }, + async saveCell() { + try { + const { entry } = await API.put('/api/entries', { + kpi_id: this.cell.kpiId, + year: this.year, + month: this.cell.month, + value: this.cell.value, + status: this.cell.status, + note: this.cell.note, + }); + // Patch local state in place. + for (const p of this.projects) { + const k = p.kpis.find((x) => x.id === this.cell.kpiId); + if (k) { + const slot = k.months[this.cell.month - 1]; + slot.value = entry.value; + slot.status = entry.status; + slot.note = entry.note; + } + } + this.$refs.cellDialog.close(); + } catch (e) { + this.toast(e.message); + } + }, + + // ---- Project CRUD ---- + openNewProject() { + this.proj = { id: null, name: '', description: '' }; + this.$refs.projectDialog.showModal(); + }, + openEditProject(p) { + this.proj = { id: p.id, name: p.name, description: p.description || '' }; + this.$refs.projectDialog.showModal(); + }, + async saveProject() { + try { + if (this.proj.id) { + await API.patch(`/api/projects/${this.proj.id}`, { + name: this.proj.name, + description: this.proj.description, + }); + } else { + await API.post('/api/projects', { + name: this.proj.name, + description: this.proj.description, + }); + } + this.$refs.projectDialog.close(); + await this.loadChart(); + } catch (e) { + this.toast(e.message); + } + }, + async deleteProject(p) { + if (!confirm(`Delete project "${p.name}" and all its KPIs?`)) return; + try { + await API.del(`/api/projects/${p.id}`); + await this.loadChart(); + } catch (e) { + this.toast(e.message); + } + }, + + // ---- KPI CRUD ---- + openNewKpi(project) { + this.kpi = { id: null, projectId: project.id, name: '', unit: '', goal_text: '', description: '' }; + this.$refs.kpiDialog.showModal(); + }, + openEditKpi(project, k) { + this.kpi = { + id: k.id, + projectId: project.id, + name: k.name, + unit: k.unit || '', + goal_text: k.goal_text || '', + description: k.description || '', + }; + this.$refs.kpiDialog.showModal(); + }, + async saveKpi() { + try { + if (this.kpi.id) { + await API.patch(`/api/kpis/${this.kpi.id}`, { + name: this.kpi.name, + unit: this.kpi.unit, + goal_text: this.kpi.goal_text, + description: this.kpi.description, + }); + } else { + await API.post('/api/kpis', { + project_id: this.kpi.projectId, + name: this.kpi.name, + unit: this.kpi.unit, + goal_text: this.kpi.goal_text, + description: this.kpi.description, + }); + } + this.$refs.kpiDialog.close(); + await this.loadChart(); + } catch (e) { + this.toast(e.message); + } + }, + async deleteKpi(k) { + if (!confirm(`Delete KPI "${k.name}"?`)) return; + try { + await API.del(`/api/kpis/${k.id}`); + await this.loadChart(); + } catch (e) { + this.toast(e.message); + } + }, + + // ---- Sparkline (numeric months only) ---- + sparkline(kpi) { + const pts = kpi.months + .map((m, i) => ({ i, v: parseFloat(m.value) })) + .filter((p) => !Number.isNaN(p.v)); + if (pts.length < 2) return ''; + const xs = pts.map((p) => p.i); + const vs = pts.map((p) => p.v); + const minV = Math.min(...vs); + const maxV = Math.max(...vs); + const range = maxV - minV || 1; + const W = 88; + const H = 24; + const coords = pts.map((p) => { + const x = (p.i / 11) * W; + const y = H - ((p.v - minV) / range) * (H - 4) - 2; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }); + return coords.join(' '); + }, + + async logout() { + try { await API.post('/api/logout'); } catch (_) {} + window.location.href = '/login.html'; + }, + + toast(msg) { + this.error = msg; + if (typeof window.ui === 'function') window.ui('#snackbar'); + }, + }; +} + +window.bowlerApp = bowlerApp; diff --git a/public/js/theme.js b/public/js/theme.js new file mode 100644 index 0000000..2dd2572 --- /dev/null +++ b/public/js/theme.js @@ -0,0 +1,27 @@ +'use strict'; + +// Applies a color scheme using Beer CSS's dynamic theming, which derives a full +// Material 3 palette from a single seed color. `ui()` is provided by beer.min.js. +window.Theme = (function () { + async function apply(scheme) { + if (!scheme || typeof window.ui !== 'function') return; + try { + await window.ui('theme', scheme.seed_color); + window.ui('mode', scheme.mode === 'dark' ? 'dark' : 'light'); + } catch (e) { + console.warn('Theme apply failed', e); + } + } + + // Resolve the scheme to show: the user's preferred one, else the default. + function resolve(schemes, preferredId) { + if (!schemes || !schemes.length) return null; + return ( + schemes.find((s) => s.id === preferredId) || + schemes.find((s) => s.is_default) || + schemes[0] + ); + } + + return { apply, resolve }; +})(); diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..6260c2b --- /dev/null +++ b/public/login.html @@ -0,0 +1,65 @@ + + + + + + Sign in · Bowler Chart + + + + + + + + +
+ +
+ + + + diff --git a/scripts/vendor.js b/scripts/vendor.js new file mode 100644 index 0000000..84fb56d --- /dev/null +++ b/scripts/vendor.js @@ -0,0 +1,33 @@ +'use strict'; + +// Copies frontend libraries from node_modules into public/vendor so the app +// loads no remote scripts (works on a locked-down network, reproducible). +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..'); +const OUT = path.join(ROOT, 'public', 'vendor'); + +const FILES = [ + ['beercss/dist/cdn/beer.min.css', 'beer.min.css'], + ['beercss/dist/cdn/beer.min.js', 'beer.min.js'], + ['material-dynamic-colors/dist/cdn/material-dynamic-colors.min.js', 'material-dynamic-colors.min.js'], + ['alpinejs/dist/cdn.min.js', 'alpine.min.js'], +]; + +fs.mkdirSync(OUT, { recursive: true }); + +let ok = 0; +for (const [src, dest] of FILES) { + const from = path.join(ROOT, 'node_modules', src); + const to = path.join(OUT, dest); + try { + fs.copyFileSync(from, to); + console.log(`vendored ${dest}`); + ok++; + } catch (e) { + console.error(`FAILED to vendor ${src}: ${e.message}`); + } +} +console.log(`\n${ok}/${FILES.length} files vendored into public/vendor`); +if (ok < FILES.length) process.exitCode = 1; diff --git a/server.js b/server.js new file mode 100644 index 0000000..945ff39 --- /dev/null +++ b/server.js @@ -0,0 +1,77 @@ +'use strict'; + +require('dotenv').config(); + +const path = require('path'); +const express = require('express'); +const helmet = require('helmet'); +const session = require('express-session'); +const SqliteStore = require('better-sqlite3-session-store')(session); + +const db = require('./db'); // applies schema + first-run seed + +const app = express(); +const PORT = Number(process.env.PORT) || 3000; +const PROD = process.env.NODE_ENV === 'production'; + +app.set('trust proxy', 1); + +// Security headers. CSP is relaxed for inline bootstrapping but blocks remote +// scripts; all libraries are served from our own /vendor. +app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", 'data:'], + fontSrc: ["'self'", 'data:'], + connectSrc: ["'self'"], + }, + }, + }) +); + +app.use(express.json({ limit: '256kb' })); + +app.use( + session({ + store: new SqliteStore({ client: db, expired: { clear: true, intervalMs: 900000 } }), + secret: process.env.SESSION_SECRET || 'dev-insecure-secret-change-me', + resave: false, + saveUninitialized: false, + cookie: { + httpOnly: true, + sameSite: 'lax', + secure: PROD, + maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days + }, + }) +); + +// API routes +app.use('/api', require('./src/routes/auth.routes')); +app.use('/api/users', require('./src/routes/users.routes')); +app.use('/api/schemes', require('./src/routes/schemes.routes')); +app.use('/api/chart', require('./src/routes/chart.routes')); +app.use('/api/projects', require('./src/routes/projects.routes')); +app.use('/api/kpis', require('./src/routes/kpis.routes')); +app.use('/api/entries', require('./src/routes/entries.routes')); + +// Static frontend +app.use(express.static(path.join(__dirname, 'public'))); + +// JSON 404 for unmatched API routes +app.use('/api', (req, res) => res.status(404).json({ error: 'Not found' })); + +// Centralized error handler +// eslint-disable-next-line no-unused-vars +app.use((err, req, res, next) => { + console.error(err); + res.status(500).json({ error: 'Internal server error' }); +}); + +app.listen(PORT, () => { + console.log(`Bowler Chart running at http://localhost:${PORT}`); +}); diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..77e87bb --- /dev/null +++ b/src/auth.js @@ -0,0 +1,50 @@ +'use strict'; + +const crypto = require('crypto'); +const bcrypt = require('bcryptjs'); +const db = require('../db'); + +const getByUsername = db.prepare('SELECT * FROM users WHERE username = ?'); +const getById = db.prepare('SELECT * FROM users WHERE id = ?'); + +/** Strip secret fields before sending a user to the client. */ +function publicUser(row) { + if (!row) return null; + return { + id: row.id, + username: row.username, + email: row.email, + role: row.role, + preferred_scheme_id: row.preferred_scheme_id, + active: row.active, + created_at: row.created_at, + }; +} + +/** Verify credentials; returns the user row on success, else null. */ +function verifyCredentials(username, password) { + const user = getByUsername.get(username); + if (!user || !user.active) return null; + if (!bcrypt.compareSync(password, user.password_hash)) return null; + return user; +} + +function hashPassword(plain) { + return bcrypt.hashSync(plain, 10); +} + +/** Issue (once per session) a CSRF token used for double-submit verification. */ +function ensureCsrfToken(req) { + if (!req.session.csrfToken) { + req.session.csrfToken = crypto.randomBytes(24).toString('hex'); + } + return req.session.csrfToken; +} + +module.exports = { + publicUser, + verifyCredentials, + hashPassword, + ensureCsrfToken, + getUserById: (id) => getById.get(id), +}; diff --git a/src/middleware/csrf.js b/src/middleware/csrf.js new file mode 100644 index 0000000..1bd85f2 --- /dev/null +++ b/src/middleware/csrf.js @@ -0,0 +1,15 @@ +'use strict'; + +/** + * Double-submit CSRF check for state-changing requests. The token is issued in + * the session (see auth.ensureCsrfToken) and surfaced to the client via + * GET /api/me; the client echoes it back in the X-CSRF-Token header. + */ +module.exports = function verifyCsrf(req, res, next) { + const sent = req.get('X-CSRF-Token'); + const expected = req.session && req.session.csrfToken; + if (!expected || !sent || sent !== expected) { + return res.status(403).json({ error: 'Invalid or missing CSRF token' }); + } + next(); +}; diff --git a/src/middleware/requireAdmin.js b/src/middleware/requireAdmin.js new file mode 100644 index 0000000..4ff5cc1 --- /dev/null +++ b/src/middleware/requireAdmin.js @@ -0,0 +1,9 @@ +'use strict'; + +/** Allow only the administrator through. Assumes requireAuth ran first. */ +module.exports = function requireAdmin(req, res, next) { + if (!req.session || req.session.role !== 'admin') { + return res.status(403).json({ error: 'Administrator access required' }); + } + next(); +}; diff --git a/src/middleware/requireAuth.js b/src/middleware/requireAuth.js new file mode 100644 index 0000000..02863cc --- /dev/null +++ b/src/middleware/requireAuth.js @@ -0,0 +1,9 @@ +'use strict'; + +/** Reject requests without a logged-in session. */ +module.exports = function requireAuth(req, res, next) { + if (!req.session || !req.session.userId) { + return res.status(401).json({ error: 'Not authenticated' }); + } + next(); +}; diff --git a/src/middleware/requireOwnerOrAdmin.js b/src/middleware/requireOwnerOrAdmin.js new file mode 100644 index 0000000..7edba4e --- /dev/null +++ b/src/middleware/requireOwnerOrAdmin.js @@ -0,0 +1,37 @@ +'use strict'; + +const db = require('../../db'); + +const projectById = db.prepare('SELECT * FROM projects WHERE id = ?'); +const kpiById = db.prepare('SELECT * FROM kpis WHERE id = ?'); + +/** + * Factory producing middleware that loads a resource, resolves its owning + * project, and ensures the session user owns it (or is admin). The resolved + * project is attached to req.project for the handler. + * + * @param {(req) => number|undefined} getProjectId - resolve a project id from req. + */ +function ownerOfProject(getProjectId) { + return function (req, res, next) { + const projectId = getProjectId(req); + if (!projectId) return res.status(400).json({ error: 'Missing project id' }); + + const project = projectById.get(projectId); + if (!project) return res.status(404).json({ error: 'Project not found' }); + + if (req.session.role !== 'admin' && project.owner_id !== req.session.userId) { + return res.status(403).json({ error: 'You can only modify your own projects' }); + } + req.project = project; + next(); + }; +} + +/** Resolve the owning project of a KPI referenced by req (returns project id). */ +function projectIdOfKpi(kpiId) { + const kpi = kpiById.get(kpiId); + return kpi ? kpi.project_id : undefined; +} + +module.exports = { ownerOfProject, projectIdOfKpi }; diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js new file mode 100644 index 0000000..445798b --- /dev/null +++ b/src/routes/auth.routes.js @@ -0,0 +1,61 @@ +'use strict'; + +const express = require('express'); +const db = require('../../db'); +const auth = require('../auth'); +const requireAuth = require('../middleware/requireAuth'); +const verifyCsrf = require('../middleware/csrf'); + +const router = express.Router(); + +const schemeExists = db.prepare('SELECT id FROM color_schemes WHERE id = ?'); +const setScheme = db.prepare('UPDATE users SET preferred_scheme_id = ? WHERE id = ?'); + +// POST /api/login +router.post('/login', (req, res) => { + const { username, password } = req.body || {}; + if (!username || !password) { + return res.status(400).json({ error: 'Username and password are required' }); + } + const user = auth.verifyCredentials(username, password); + if (!user) return res.status(401).json({ error: 'Invalid credentials' }); + + // Prevent session fixation: regenerate before storing identity. + req.session.regenerate((err) => { + if (err) return res.status(500).json({ error: 'Login failed' }); + req.session.userId = user.id; + req.session.role = user.role; + auth.ensureCsrfToken(req); + res.json({ user: auth.publicUser(user), csrfToken: req.session.csrfToken }); + }); +}); + +// POST /api/logout +router.post('/logout', requireAuth, verifyCsrf, (req, res) => { + req.session.destroy(() => { + res.clearCookie('connect.sid'); + res.json({ ok: true }); + }); +}); + +// GET /api/me — current user + a fresh CSRF token for the client. +router.get('/me', requireAuth, (req, res) => { + const user = auth.getUserById(req.session.userId); + if (!user) { + return req.session.destroy(() => res.status(401).json({ error: 'Not authenticated' })); + } + const token = auth.ensureCsrfToken(req); + res.json({ user: auth.publicUser(user), csrfToken: token }); +}); + +// PATCH /api/me/scheme — set the current user's preferred color scheme. +router.patch('/me/scheme', requireAuth, verifyCsrf, (req, res) => { + const { scheme_id } = req.body || {}; + if (scheme_id !== null && !schemeExists.get(scheme_id)) { + return res.status(400).json({ error: 'Unknown color scheme' }); + } + setScheme.run(scheme_id ?? null, req.session.userId); + res.json({ ok: true, preferred_scheme_id: scheme_id ?? null }); +}); + +module.exports = router; diff --git a/src/routes/chart.routes.js b/src/routes/chart.routes.js new file mode 100644 index 0000000..e371cc6 --- /dev/null +++ b/src/routes/chart.routes.js @@ -0,0 +1,61 @@ +'use strict'; + +const express = require('express'); +const db = require('../../db'); +const requireAuth = require('../middleware/requireAuth'); + +const router = express.Router(); + +const allProjects = db.prepare( + `SELECT p.*, u.username AS owner_name + FROM projects p JOIN users u ON u.id = p.owner_id + ORDER BY p.sort_order, p.name COLLATE NOCASE` +); +const kpisForProject = db.prepare( + 'SELECT * FROM kpis WHERE project_id = ? ORDER BY sort_order, name COLLATE NOCASE' +); +const entriesForKpiYear = db.prepare( + 'SELECT month, value, status, note FROM entries WHERE kpi_id = ? AND year = ?' +); + +// GET /api/chart?year=YYYY — full nested tree, visible to any logged-in user. +router.get('/', requireAuth, (req, res) => { + const year = Number(req.query.year) || new Date().getFullYear(); + + const projects = allProjects.all().map((p) => { + const kpis = kpisForProject.all(p.id).map((k) => { + const byMonth = {}; + for (const e of entriesForKpiYear.all(k.id, year)) byMonth[e.month] = e; + const months = Array.from({ length: 12 }, (_, i) => { + const m = byMonth[i + 1]; + return { + month: i + 1, + value: m ? m.value : null, + status: m ? m.status : 'none', + note: m ? m.note : null, + }; + }); + return { + id: k.id, + name: k.name, + description: k.description, + unit: k.unit, + goal_text: k.goal_text, + months, + }; + }); + return { + id: p.id, + name: p.name, + description: p.description, + owner_id: p.owner_id, + owner_name: p.owner_name, + can_edit: req.session.role === 'admin' || p.owner_id === req.session.userId, + kpis, + }; + }); + + res.json({ year, projects }); +}); + +module.exports = router; diff --git a/src/routes/entries.routes.js b/src/routes/entries.routes.js new file mode 100644 index 0000000..fe8919d --- /dev/null +++ b/src/routes/entries.routes.js @@ -0,0 +1,54 @@ +'use strict'; + +const express = require('express'); +const db = require('../../db'); +const requireAuth = require('../middleware/requireAuth'); +const verifyCsrf = require('../middleware/csrf'); +const { ownerOfProject, projectIdOfKpi } = require('../middleware/requireOwnerOrAdmin'); + +const router = express.Router(); +router.use(requireAuth); + +const STATUSES = new Set(['green', 'amber', 'red', 'none']); + +const upsert = db.prepare( + `INSERT INTO entries (kpi_id, year, month, value, status, note, updated_by, updated_at) + VALUES (@kpi_id, @year, @month, @value, @status, @note, @updated_by, datetime('now')) + ON CONFLICT (kpi_id, year, month) DO UPDATE SET + value = excluded.value, + status = excluded.status, + note = excluded.note, + updated_by = excluded.updated_by, + updated_at = datetime('now')` +); +const getEntry = db.prepare( + 'SELECT month, value, status, note FROM entries WHERE kpi_id = ? AND year = ? AND month = ?' +); + +// PUT /api/entries — upsert one monthly cell; authorized via the KPI's project. +router.put( + '/', + verifyCsrf, + ownerOfProject((req) => projectIdOfKpi(Number(req.body && req.body.kpi_id))), + (req, res) => { + const { kpi_id, year, month, value, status, note } = req.body || {}; + const m = Number(month); + const y = Number(year); + if (!(m >= 1 && m <= 12) || !y) { + return res.status(400).json({ error: 'Valid year and month (1-12) are required' }); + } + const safeStatus = STATUSES.has(status) ? status : 'none'; + upsert.run({ + kpi_id: Number(kpi_id), + year: y, + month: m, + value: value === '' || value === undefined ? null : String(value), + status: safeStatus, + note: note ? String(note) : null, + updated_by: req.session.userId, + }); + res.json({ entry: getEntry.get(Number(kpi_id), y, m) }); + } +); + +module.exports = router; diff --git a/src/routes/kpis.routes.js b/src/routes/kpis.routes.js new file mode 100644 index 0000000..e24bed0 --- /dev/null +++ b/src/routes/kpis.routes.js @@ -0,0 +1,70 @@ +'use strict'; + +const express = require('express'); +const db = require('../../db'); +const requireAuth = require('../middleware/requireAuth'); +const verifyCsrf = require('../middleware/csrf'); +const { ownerOfProject, projectIdOfKpi } = require('../middleware/requireOwnerOrAdmin'); + +const router = express.Router(); +router.use(requireAuth); + +const getKpi = db.prepare('SELECT * FROM kpis WHERE id = ?'); +const insertKpi = db.prepare( + `INSERT INTO kpis (project_id, name, description, unit, goal_text, sort_order) + VALUES (@project_id, @name, @description, @unit, @goal_text, @sort_order)` +); +const nextSort = db.prepare( + 'SELECT COALESCE(MAX(sort_order), -1) + 1 AS n FROM kpis WHERE project_id = ?' +); + +// POST /api/kpis — body.project_id authorizes against the owning project. +router.post( + '/', + verifyCsrf, + ownerOfProject((req) => Number(req.body && req.body.project_id)), + (req, res) => { + const name = (req.body.name || '').trim(); + if (!name) return res.status(400).json({ error: 'KPI name is required' }); + const info = insertKpi.run({ + project_id: req.project.id, + name, + description: (req.body.description || '').trim() || null, + unit: (req.body.unit || '').trim() || null, + goal_text: (req.body.goal_text || '').trim() || null, + sort_order: nextSort.get(req.project.id).n, + }); + res.status(201).json({ kpi: getKpi.get(info.lastInsertRowid) }); + } +); + +// Authorize :id routes via the KPI's owning project. +const kpiProject = (req) => projectIdOfKpi(Number(req.params.id)); + +// PATCH /api/kpis/:id +router.patch('/:id', verifyCsrf, ownerOfProject(kpiProject), (req, res) => { + const id = Number(req.params.id); + const kpi = getKpi.get(id); + const { name, description, unit, goal_text, sort_order } = req.body || {}; + const next = { + name: name !== undefined ? (name.trim() || kpi.name) : kpi.name, + description: description !== undefined ? (description || null) : kpi.description, + unit: unit !== undefined ? (unit || null) : kpi.unit, + goal_text: goal_text !== undefined ? (goal_text || null) : kpi.goal_text, + sort_order: sort_order !== undefined ? Number(sort_order) : kpi.sort_order, + id, + }; + db.prepare( + `UPDATE kpis SET name=@name, description=@description, unit=@unit, + goal_text=@goal_text, sort_order=@sort_order WHERE id=@id` + ).run(next); + res.json({ kpi: getKpi.get(id) }); +}); + +// DELETE /api/kpis/:id (cascades to entries) +router.delete('/:id', verifyCsrf, ownerOfProject(kpiProject), (req, res) => { + db.prepare('DELETE FROM kpis WHERE id = ?').run(Number(req.params.id)); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/src/routes/projects.routes.js b/src/routes/projects.routes.js new file mode 100644 index 0000000..0b1e13f --- /dev/null +++ b/src/routes/projects.routes.js @@ -0,0 +1,57 @@ +'use strict'; + +const express = require('express'); +const db = require('../../db'); +const requireAuth = require('../middleware/requireAuth'); +const verifyCsrf = require('../middleware/csrf'); +const { ownerOfProject } = require('../middleware/requireOwnerOrAdmin'); + +const router = express.Router(); +router.use(requireAuth); + +const getProject = db.prepare('SELECT * FROM projects WHERE id = ?'); +const insertProject = db.prepare( + `INSERT INTO projects (name, description, owner_id, sort_order) + VALUES (@name, @description, @owner_id, @sort_order)` +); +const nextSort = db.prepare( + 'SELECT COALESCE(MAX(sort_order), -1) + 1 AS n FROM projects WHERE owner_id = ?' +); + +// POST /api/projects — create a project owned by the current user. +router.post('/', verifyCsrf, (req, res) => { + const name = (req.body && req.body.name || '').trim(); + if (!name) return res.status(400).json({ error: 'Project name is required' }); + const info = insertProject.run({ + name, + description: (req.body.description || '').trim() || null, + owner_id: req.session.userId, + sort_order: nextSort.get(req.session.userId).n, + }); + res.status(201).json({ project: getProject.get(info.lastInsertRowid) }); +}); + +const fromParam = (req) => Number(req.params.id); + +// PATCH /api/projects/:id +router.patch('/:id', verifyCsrf, ownerOfProject(fromParam), (req, res) => { + const { name, description, sort_order } = req.body || {}; + const next = { + name: name !== undefined ? (name.trim() || req.project.name) : req.project.name, + description: description !== undefined ? (description || null) : req.project.description, + sort_order: sort_order !== undefined ? Number(sort_order) : req.project.sort_order, + id: req.project.id, + }; + db.prepare( + 'UPDATE projects SET name=@name, description=@description, sort_order=@sort_order WHERE id=@id' + ).run(next); + res.json({ project: getProject.get(req.project.id) }); +}); + +// DELETE /api/projects/:id (cascades to kpis + entries) +router.delete('/:id', verifyCsrf, ownerOfProject(fromParam), (req, res) => { + db.prepare('DELETE FROM projects WHERE id = ?').run(req.project.id); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/src/routes/schemes.routes.js b/src/routes/schemes.routes.js new file mode 100644 index 0000000..1ce6348 --- /dev/null +++ b/src/routes/schemes.routes.js @@ -0,0 +1,99 @@ +'use strict'; + +const express = require('express'); +const db = require('../../db'); +const requireAuth = require('../middleware/requireAuth'); +const requireAdmin = require('../middleware/requireAdmin'); +const verifyCsrf = require('../middleware/csrf'); + +const router = express.Router(); + +const listSchemes = db.prepare('SELECT * FROM color_schemes ORDER BY is_default DESC, name COLLATE NOCASE'); +const getScheme = db.prepare('SELECT * FROM color_schemes WHERE id = ?'); +const clearDefault = db.prepare('UPDATE color_schemes SET is_default = 0'); +const insertScheme = db.prepare( + `INSERT INTO color_schemes (name, seed_color, mode, is_default) + VALUES (@name, @seed_color, @mode, @is_default)` +); +const countSchemes = db.prepare('SELECT COUNT(*) AS n FROM color_schemes'); + +const HEX = /^#[0-9a-fA-F]{6}$/; + +// GET /api/schemes is readable by any logged-in user (the picker needs it); +// writes are admin-only. +router.get('/', requireAuth, (req, res) => res.json({ schemes: listSchemes.all() })); + +router.use(requireAuth, requireAdmin); + +function validate(body) { + const name = (body.name || '').trim(); + const seed = (body.seed_color || '').trim(); + const mode = body.mode === 'dark' ? 'dark' : 'light'; + if (!name) return { error: 'Name is required' }; + if (!HEX.test(seed)) return { error: 'Seed color must be a #rrggbb hex value' }; + return { value: { name, seed_color: seed, mode } }; +} + +// POST /api/schemes +router.post('/', verifyCsrf, (req, res) => { + const { error, value } = validate(req.body || {}); + if (error) return res.status(400).json({ error }); + + const makeDefault = req.body.is_default ? 1 : countSchemes.get().n === 0 ? 1 : 0; + const tx = db.transaction(() => { + if (makeDefault) clearDefault.run(); + const info = insertScheme.run({ ...value, is_default: makeDefault }); + return getScheme.get(info.lastInsertRowid); + }); + try { + res.status(201).json({ scheme: tx() }); + } catch (e) { + if (String(e.message).includes('UNIQUE')) { + return res.status(409).json({ error: 'A scheme with that name already exists' }); + } + throw e; + } +}); + +// PATCH /api/schemes/:id +router.patch('/:id', verifyCsrf, (req, res) => { + const id = Number(req.params.id); + const scheme = getScheme.get(id); + if (!scheme) return res.status(404).json({ error: 'Scheme not found' }); + + const { error, value } = validate({ ...scheme, ...req.body }); + if (error) return res.status(400).json({ error }); + const makeDefault = req.body.is_default ? 1 : scheme.is_default; + + const tx = db.transaction(() => { + if (req.body.is_default) clearDefault.run(); + db.prepare( + `UPDATE color_schemes SET name=@name, seed_color=@seed_color, mode=@mode, is_default=@is_default + WHERE id=@id` + ).run({ ...value, is_default: makeDefault, id }); + return getScheme.get(id); + }); + try { + res.json({ scheme: tx() }); + } catch (e) { + if (String(e.message).includes('UNIQUE')) { + return res.status(409).json({ error: 'A scheme with that name already exists' }); + } + throw e; + } +}); + +// DELETE /api/schemes/:id +router.delete('/:id', verifyCsrf, (req, res) => { + const id = Number(req.params.id); + const scheme = getScheme.get(id); + if (!scheme) return res.status(404).json({ error: 'Scheme not found' }); + if (scheme.is_default) { + return res.status(409).json({ error: 'Cannot delete the default scheme; set another default first' }); + } + // Users referencing it fall back to default via ON DELETE SET NULL. + db.prepare('DELETE FROM color_schemes WHERE id = ?').run(id); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/src/routes/users.routes.js b/src/routes/users.routes.js new file mode 100644 index 0000000..b0d9cde --- /dev/null +++ b/src/routes/users.routes.js @@ -0,0 +1,96 @@ +'use strict'; + +const express = require('express'); +const db = require('../../db'); +const auth = require('../auth'); +const requireAuth = require('../middleware/requireAuth'); +const requireAdmin = require('../middleware/requireAdmin'); +const verifyCsrf = require('../middleware/csrf'); + +const router = express.Router(); + +// Every route here is admin-only and state-changing routes require CSRF. +router.use(requireAuth, requireAdmin); + +const listUsers = db.prepare( + `SELECT id, username, email, role, preferred_scheme_id, active, created_at + FROM users ORDER BY username COLLATE NOCASE` +); +const getUser = db.prepare('SELECT * FROM users WHERE id = ?'); +const insertUser = db.prepare( + `INSERT INTO users (username, email, password_hash, role, active) + VALUES (@username, @email, @hash, @role, @active)` +); +const countAdmins = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND active = 1"); + +// GET /api/users +router.get('/', (req, res) => res.json({ users: listUsers.all() })); + +// POST /api/users +router.post('/', verifyCsrf, (req, res) => { + const { username, email, password, role, active } = req.body || {}; + if (!username || !password) { + return res.status(400).json({ error: 'Username and password are required' }); + } + const safeRole = role === 'admin' ? 'admin' : 'user'; + try { + const info = insertUser.run({ + username: username.trim(), + email: email || null, + hash: auth.hashPassword(password), + role: safeRole, + active: active === false ? 0 : 1, + }); + res.status(201).json({ user: auth.publicUser(getUser.get(info.lastInsertRowid)) }); + } catch (e) { + if (String(e.message).includes('UNIQUE')) { + return res.status(409).json({ error: 'Username already exists' }); + } + throw e; + } +}); + +// PATCH /api/users/:id — update email/role/active and optionally reset password. +router.patch('/:id', verifyCsrf, (req, res) => { + const id = Number(req.params.id); + const user = getUser.get(id); + if (!user) return res.status(404).json({ error: 'User not found' }); + + const { email, role, active, password } = req.body || {}; + const next = { + email: email !== undefined ? email || null : user.email, + role: role === 'admin' || role === 'user' ? role : user.role, + active: active !== undefined ? (active ? 1 : 0) : user.active, + hash: password ? auth.hashPassword(password) : user.password_hash, + }; + + // Never let the last active admin demote or deactivate themselves out of access. + const losingAdmin = + user.role === 'admin' && user.active && (next.role !== 'admin' || next.active === 0); + if (losingAdmin && countAdmins.get().n <= 1) { + return res.status(409).json({ error: 'Cannot remove the last active administrator' }); + } + + db.prepare( + `UPDATE users SET email = @email, role = @role, active = @active, password_hash = @hash + WHERE id = @id` + ).run({ ...next, id }); + res.json({ user: auth.publicUser(getUser.get(id)) }); +}); + +// DELETE /api/users/:id +router.delete('/:id', verifyCsrf, (req, res) => { + const id = Number(req.params.id); + const user = getUser.get(id); + if (!user) return res.status(404).json({ error: 'User not found' }); + if (id === req.session.userId) { + return res.status(409).json({ error: 'You cannot delete your own account' }); + } + if (user.role === 'admin' && user.active && countAdmins.get().n <= 1) { + return res.status(409).json({ error: 'Cannot delete the last active administrator' }); + } + db.prepare('DELETE FROM users WHERE id = ?').run(id); + res.json({ ok: true }); +}); + +module.exports = router;