diff --git a/.env.example b/.env.example index 9dddc97..0d1fd65 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,17 @@ MONOCHROME_PORT=3000 MONOCHROME_DEV_PORT=5173 +# --- Auth Gate (server-side authentication) --- +# Set AUTH_ENABLED=true to enable the auth gate entirely (login required) +AUTH_ENABLED=false +AUTH_SECRET=change-me-to-a-random-string +FIREBASE_PROJECT_ID=monochrome-database +# Optional: override the Firebase config for the login page (JSON string) +# FIREBASE_CONFIG={"apiKey":"...","authDomain":"...","projectId":"...","storageBucket":"...","messagingSenderId":"...","appId":"..."} +# Optional: set PocketBase URL (hides the field in settings when set) +# POCKETBASE_URL=https://monodb.samidy.com +# SESSION_MAX_AGE=604800000 # 7 days in ms (default) + # --- PocketBase (only used with --profile pocketbase) --- POCKETBASE_PORT=8090 PB_ADMIN_EMAIL=admin@example.com diff --git a/AUTH_GATE.md b/AUTH_GATE.md new file mode 100644 index 0000000..51464f8 --- /dev/null +++ b/AUTH_GATE.md @@ -0,0 +1,55 @@ +# Global Auth Gate + +This document explains the optional server-side login gate and what it implies for your site. + +## Overview + +- When enabled, all HTML routes require login. +- Login uses Firebase Auth (Google or email) and exchanges a Firebase ID token for a server session. +- The session is stored in a signed cookie and checked on every request. + +## Where it runs + +- The gate runs only in `vite preview` (production-like server). +- The Vite dev server (`vite dev`) does not enable the gate. +- Static hosting cannot enforce the gate, because there is no server to verify tokens or set cookies. + +## Flow + +1. User requests `/` or any HTML route. +2. Server checks the `mono_session` cookie. +3. If missing, redirect to `/login`. +4. Login page signs in with Firebase and POSTs to `/api/auth/login`. +5. Server verifies the ID token and sets a session cookie. +6. User is redirected back to `/`. + +## Configuration + +- `AUTH_ENABLED=true` enables the gate (default is false). +- `AUTH_SECRET` is required when the gate is enabled. It signs the session cookie. +- `FIREBASE_PROJECT_ID` sets the Firebase project used to verify tokens. +- `FIREBASE_CONFIG` (JSON) injects config into the login page. +- `POCKETBASE_URL` hides the custom DB setting field. +- `SESSION_MAX_AGE` sets cookie lifetime in ms (default 7 days). + +## Implications for the site + +- Requires a server runtime. Pure static hosting will not force login. +- Unauthenticated requests to non-HTML assets return 401. +- `/login` and `/login.html` remain accessible to start the flow. +- Logging out clears the session and redirects to `/login`. +- Authenticated visits to `/login` redirect back to `/`. + +## Enable (Docker) + +1. `cp .env.example .env` +2. Set `AUTH_ENABLED=true` and `AUTH_SECRET=...` +3. Optionally set `FIREBASE_CONFIG` and `FIREBASE_PROJECT_ID` +4. `docker compose up -d` +5. Visit `http://localhost:3000` + +## Enable (local preview) + +1. `npm run build` +2. Set env vars in your shell or `.env` +3. `npm run preview` diff --git a/Dockerfile b/Dockerfile index 2f9995d..16b7d76 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ WORKDIR /app RUN apk add --no-cache wget # Copy package files first for caching -COPY package.json ./ +COPY package.json package-lock.json ./ # Install dependencies RUN npm install diff --git a/bun.lock b/bun.lock index a83bd63..84a6635 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,9 @@ "dependencies": { "butterchurn": "^2.6.7", "butterchurn-presets": "^2.4.7", + "cookie-session": "^2.1.0", "dashjs": "^5.1.1", + "jose": "^6.0.11", "pocketbase": "^0.26.5", }, "devDependencies": { @@ -513,6 +515,10 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cookie-session": ["cookie-session@2.1.1", "", { "dependencies": { "cookies": "0.9.1", "debug": "3.2.7", "on-headers": "~1.1.0", "safe-buffer": "5.2.1" } }, "sha512-ji3kym/XZaFVew1+tIZk5ZLp9Z/fLv9rK1aZmpug0FsgE7Cu3ZDrUdRo7FT9vFjMYfNimrrUHJzywDwT7XEFlg=="], + + "cookies": ["cookies@0.9.1", "", { "dependencies": { "depd": "~2.0.0", "keygrip": "~1.1.0" } }, "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw=="], + "core-js": ["core-js@2.6.12", "", {}, "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ=="], "core-js-compat": ["core-js-compat@3.47.0", "", { "dependencies": { "browserslist": "^4.28.0" } }, "sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ=="], @@ -547,6 +553,8 @@ "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -797,6 +805,8 @@ "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": "bin/cli.js" }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -819,6 +829,8 @@ "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], + "keygrip": ["keygrip@1.1.0", "", { "dependencies": { "tsscmp": "1.0.6" } }, "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], @@ -883,6 +895,8 @@ "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + "on-headers": ["on-headers@1.1.0", "", {}, "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], @@ -1077,6 +1091,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "tsscmp": ["tsscmp@1.0.6", "", {}, "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "type-fest": ["type-fest@0.16.0", "", {}, "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg=="], @@ -1207,6 +1223,8 @@ "cacheable/keyv": ["keyv@5.5.5", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ=="], + "cookie-session/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "filelist/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], diff --git a/docker-compose.yml b/docker-compose.yml index 3c39b58..f4602cc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,11 +7,18 @@ services: container_name: monochrome ports: - '${MONOCHROME_PORT:-3000}:4173' + environment: + AUTH_ENABLED: ${AUTH_ENABLED:-false} + AUTH_SECRET: ${AUTH_SECRET:-} + FIREBASE_PROJECT_ID: ${FIREBASE_PROJECT_ID:-monochrome-database} + FIREBASE_CONFIG: ${FIREBASE_CONFIG:-} + POCKETBASE_URL: ${POCKETBASE_URL:-} + SESSION_MAX_AGE: ${SESSION_MAX_AGE:-604800000} restart: unless-stopped networks: - monochrome-network healthcheck: - test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:4173/'] + test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:4173/health'] interval: 30s timeout: 3s retries: 3 diff --git a/js/accounts/auth.js b/js/accounts/auth.js index 865e31d..2d09d06 100644 --- a/js/accounts/auth.js +++ b/js/accounts/auth.js @@ -103,7 +103,14 @@ export class AuthManager { try { await firebaseSignOut(auth); - // The onAuthStateChanged listener will handle the rest + if (window.__AUTH_GATE__) { + try { + await fetch('/api/auth/logout', { method: 'POST' }); + } catch { + // Server endpoint may not exist in dev mode + } + window.location.href = '/login'; + } } catch (error) { console.error('Logout failed:', error); throw error; @@ -119,6 +126,43 @@ export class AuthManager { if (!connectBtn) return; // UI might not be rendered yet + // Auth gate active: strip down to status + sign out only + if (window.__AUTH_GATE__) { + connectBtn.textContent = 'Sign Out'; + connectBtn.classList.add('danger'); + connectBtn.onclick = () => this.signOut(); + if (clearDataBtn) clearDataBtn.style.display = 'none'; + if (emailContainer) emailContainer.style.display = 'none'; + if (emailToggleBtn) emailToggleBtn.style.display = 'none'; + if (statusText) statusText.textContent = user ? `Signed in as ${user.email}` : 'Signed in'; + + // Account page: clean up unnecessary text + const accountPage = document.getElementById('page-account'); + if (accountPage) { + const title = accountPage.querySelector('.section-title'); + if (title) title.textContent = 'Account'; + // Hide description + privacy paragraphs, keep only status + accountPage.querySelectorAll('.account-content > p, .account-content > div').forEach((el) => { + if (el.id !== 'firebase-status' && el.id !== 'auth-buttons-container') { + el.style.display = 'none'; + } + }); + } + + // Settings page: hide custom DB/Auth config when fully server-configured + const customDbBtn = document.getElementById('custom-db-btn'); + if (customDbBtn) { + const fbFromEnv = !!window.__FIREBASE_CONFIG__; + const pbFromEnv = !!window.__POCKETBASE_URL__; + if (fbFromEnv && pbFromEnv) { + const settingItem = customDbBtn.closest('.setting-item'); + if (settingItem) settingItem.style.display = 'none'; + } + } + + return; + } + if (user) { connectBtn.textContent = 'Sign Out'; connectBtn.classList.add('danger'); diff --git a/js/accounts/config.js b/js/accounts/config.js index a0b6aaa..a7956da 100644 --- a/js/accounts/config.js +++ b/js/accounts/config.js @@ -30,8 +30,9 @@ function getStoredConfig() { } // Attempt to initialize on load +// Priority: server-injected env (auth gate) > localStorage > default const storedConfig = getStoredConfig(); -const config = storedConfig || DEFAULT_CONFIG; +const config = window.__FIREBASE_CONFIG__ || storedConfig || DEFAULT_CONFIG; if (config) { try { diff --git a/js/app.js b/js/app.js index 5d8601f..47d7c79 100644 --- a/js/app.js +++ b/js/app.js @@ -208,6 +208,26 @@ function hideOfflineNotification() { } } +async function disablePwaForAuthGate() { + if (!('serviceWorker' in navigator)) return; + + try { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map((registration) => registration.unregister())); + } catch (error) { + console.warn('Failed to unregister service workers:', error); + } + + if ('caches' in window) { + try { + const cacheKeys = await caches.keys(); + await Promise.all(cacheKeys.map((key) => caches.delete(key))); + } catch (error) { + console.warn('Failed to clear caches:', error); + } + } +} + document.addEventListener('DOMContentLoaded', async () => { const api = new LosslessAPI(apiSettings); @@ -1424,14 +1444,18 @@ document.addEventListener('DOMContentLoaded', async () => { }); // PWA Update Logic - const updateSW = registerSW({ - onNeedRefresh() { - showUpdateNotification(() => updateSW(true)); - }, - onOfflineReady() { - console.log('App ready to work offline'); - }, - }); + if (window.__AUTH_GATE__) { + disablePwaForAuthGate(); + } else { + const updateSW = registerSW({ + onNeedRefresh() { + showUpdateNotification(() => updateSW(true)); + }, + onOfflineReady() { + console.log('App ready to work offline'); + }, + }); + } document.getElementById('show-shortcuts-btn')?.addEventListener('click', () => { showKeyboardShortcuts(); diff --git a/js/settings.js b/js/settings.js index aeba237..491a20c 100644 --- a/js/settings.js +++ b/js/settings.js @@ -1700,19 +1700,35 @@ export function initializeSettings(scrobbler, player, api, ui) { const customDbCancelBtn = document.getElementById('custom-db-cancel'); if (customDbBtn && customDbModal) { + const fbFromEnv = !!window.__FIREBASE_CONFIG__; + const pbFromEnv = !!window.__POCKETBASE_URL__; + + // Hide entire setting if both are server-configured + if (fbFromEnv && pbFromEnv) { + const settingItem = customDbBtn.closest('.setting-item'); + if (settingItem) settingItem.style.display = 'none'; + } + + // Hide individual fields in the modal + if (pbFromEnv && customPbUrlInput) customPbUrlInput.closest('div[style]').style.display = 'none'; + if (fbFromEnv && customFirebaseConfigInput) + customFirebaseConfigInput.closest('div[style]').style.display = 'none'; + customDbBtn.addEventListener('click', () => { const pbUrl = localStorage.getItem('monochrome-pocketbase-url') || ''; const fbConfig = localStorage.getItem('monochrome-firebase-config'); - customPbUrlInput.value = pbUrl; - if (fbConfig) { - try { - customFirebaseConfigInput.value = JSON.stringify(JSON.parse(fbConfig), null, 2); - } catch { - customFirebaseConfigInput.value = fbConfig; + if (!pbFromEnv) customPbUrlInput.value = pbUrl; + if (!fbFromEnv) { + if (fbConfig) { + try { + customFirebaseConfigInput.value = JSON.stringify(JSON.parse(fbConfig), null, 2); + } catch { + customFirebaseConfigInput.value = fbConfig; + } + } else { + customFirebaseConfigInput.value = ''; } - } else { - customFirebaseConfigInput.value = ''; } customDbModal.classList.add('active'); diff --git a/package-lock.json b/package-lock.json index 6e376e8..5534c1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,9 @@ "dependencies": { "butterchurn": "^2.6.7", "butterchurn-presets": "^2.4.7", + "cookie-session": "^2.1.0", "dashjs": "^5.1.1", + "jose": "^6.0.11", "pocketbase": "^0.26.5" }, "devDependencies": { @@ -2467,9 +2469,9 @@ } }, "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3751,6 +3753,43 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie-session": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/cookie-session/-/cookie-session-2.1.1.tgz", + "integrity": "sha512-ji3kym/XZaFVew1+tIZk5ZLp9Z/fLv9rK1aZmpug0FsgE7Cu3ZDrUdRo7FT9vFjMYfNimrrUHJzywDwT7XEFlg==", + "license": "MIT", + "dependencies": { + "cookies": "0.9.1", + "debug": "3.2.7", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cookie-session/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/cookies": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/core-js": { "version": "2.6.12", "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", @@ -4011,6 +4050,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -5970,6 +6018,15 @@ "node": ">=10" } }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6074,6 +6131,18 @@ "node": ">=0.10.0" } }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "license": "MIT", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6329,7 +6398,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -6448,6 +6516,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -7139,7 +7216,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -8176,6 +8252,15 @@ "node": ">=8.0" } }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index d358f9d..705726f 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", + "start": "vite preview", "lint:js": "eslint .", "lint:css": "stylelint \"**/*.css\"", "lint:html": "htmlhint \"**/*.html\" --ignore=\"dist/**,legacy/**,node_modules/**\"", @@ -43,6 +44,8 @@ "source-map": "^0.7.4" }, "dependencies": { + "cookie-session": "^2.1.0", + "jose": "^6.0.11", "butterchurn": "^2.6.7", "butterchurn-presets": "^2.4.7", "dashjs": "^5.1.1", diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..e3a1ccd --- /dev/null +++ b/public/login.html @@ -0,0 +1,343 @@ + + + + + + Monochrome — Login + + + +
+ + +
+ + + +
or
+ +
+
+ +
+
+ +
+ +
+
+ + + + diff --git a/vite-plugin-auth-gate.js b/vite-plugin-auth-gate.js new file mode 100644 index 0000000..910e6cc --- /dev/null +++ b/vite-plugin-auth-gate.js @@ -0,0 +1,208 @@ +import { loadEnv } from 'vite'; +import cookieSession from 'cookie-session'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; +import { readFileSync, existsSync } from 'fs'; +import { join, extname } from 'path'; + +function parseBody(req) { + return new Promise((resolve, reject) => { + let body = ''; + req.on('data', (chunk) => (body += chunk)); + req.on('end', () => { + try { + resolve(JSON.parse(body)); + } catch { + reject(new Error('Invalid JSON')); + } + }); + req.on('error', reject); + }); +} + +export default function authGatePlugin() { + let env = {}; + + return { + name: 'auth-gate', + + config(_, { mode }) { + env = loadEnv(mode, process.cwd(), ''); + }, + + configurePreviewServer(server) { + const AUTH_ENABLED = (env.AUTH_ENABLED ?? 'false') !== 'false'; + const FIREBASE_CONFIG = env.FIREBASE_CONFIG; + const POCKETBASE_URL = env.POCKETBASE_URL; + + // Parse Firebase config once (used for injection + auth verification) + let parsedFirebaseConfig = null; + let PROJECT_ID = env.FIREBASE_PROJECT_ID || 'monochrome-database'; + if (FIREBASE_CONFIG) { + try { + parsedFirebaseConfig = JSON.parse(FIREBASE_CONFIG); + if (parsedFirebaseConfig.projectId) PROJECT_ID = parsedFirebaseConfig.projectId; + } catch (e) { + console.error('Invalid FIREBASE_CONFIG JSON:', e.message); + process.exit(1); + } + } + + // --- Build injection script (always, for both auth gate and env config) --- + + const flags = []; + if (AUTH_ENABLED) flags.push('window.__AUTH_GATE__=true'); + if (parsedFirebaseConfig) flags.push(`window.__FIREBASE_CONFIG__=${JSON.stringify(parsedFirebaseConfig)}`); + if (POCKETBASE_URL) flags.push(`window.__POCKETBASE_URL__=${JSON.stringify(POCKETBASE_URL)}`); + const configScript = flags.length > 0 ? `` : null; + + // --- Pre-build injected HTML pages --- + + const distDir = join(process.cwd(), 'dist'); + + let indexHtml = null; + const indexPath = join(distDir, 'index.html'); + if (configScript && existsSync(indexPath)) { + indexHtml = readFileSync(indexPath, 'utf-8'); + indexHtml = indexHtml.replace('', `${configScript}\n`); + } + + let loginHtml = null; + if (AUTH_ENABLED) { + const loginPath = join(distDir, 'login.html'); + if (existsSync(loginPath)) { + loginHtml = readFileSync(loginPath, 'utf-8'); + if (configScript) loginHtml = loginHtml.replace('', `${configScript}\n`); + } + } + + // --- /health (always available) --- + + server.middlewares.use((req, res, next) => { + if (req.url.split('?')[0] === '/health') { + res.end('OK'); + return; + } + next(); + }); + + // --- Auth gate (only when enabled) --- + + if (AUTH_ENABLED) { + const AUTH_SECRET = env.AUTH_SECRET; + const SESSION_MAX_AGE = Number(env.SESSION_MAX_AGE) || 7 * 24 * 60 * 60 * 1000; + + if (!AUTH_SECRET) { + console.error('AUTH_SECRET is required when AUTH_ENABLED=true'); + process.exit(1); + } + + console.log(`Auth gate enabled (Firebase project: ${PROJECT_ID})`); + + const JWKS = createRemoteJWKSet( + new URL('https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com') + ); + + server.middlewares.use( + cookieSession({ + name: 'mono_session', + keys: [AUTH_SECRET], + maxAge: SESSION_MAX_AGE, + httpOnly: true, + sameSite: 'lax', + }) + ); + + server.middlewares.use(async (req, res, next) => { + const url = req.url.split('?')[0]; + + if (url === '/login' || url === '/login.html') { + if (req.session && req.session.uid) { + res.writeHead(302, { Location: '/' }); + res.end(); + return; + } + if (loginHtml) { + res.setHeader('Content-Type', 'text/html'); + res.setHeader('Cache-Control', 'no-store'); + res.end(loginHtml); + } else { + res.statusCode = 404; + res.end('Login page not found'); + } + return; + } + + if (url === '/api/auth/login' && req.method === 'POST') { + try { + const body = await parseBody(req); + if (!body.token) { + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'Missing token' })); + return; + } + const { payload } = await jwtVerify(body.token, JWKS, { + issuer: `https://securetoken.google.com/${PROJECT_ID}`, + audience: PROJECT_ID, + }); + req.session.uid = payload.sub; + req.session.email = payload.email; + req.session.iat = Date.now(); + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ ok: true })); + } catch (err) { + console.error('Token verification failed:', err.message); + res.statusCode = 401; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'Invalid token' })); + } + return; + } + + if (url === '/api/auth/logout' && req.method === 'POST') { + req.session = null; + res.setHeader('Clear-Site-Data', '"cache", "storage"'); + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ ok: true })); + return; + } + + if (!req.session || !req.session.uid) { + const ext = extname(url); + if (ext && ext !== '.html') { + res.statusCode = 401; + res.end('Unauthorized'); + } else { + res.writeHead(302, { Location: '/login' }); + res.end(); + } + return; + } + + // Authenticated: serve injected index.html for HTML routes + const ext = extname(url); + if ((!ext || ext === '.html') && indexHtml) { + res.setHeader('Content-Type', 'text/html'); + res.setHeader('Cache-Control', 'no-store'); + res.end(indexHtml); + return; + } + + next(); + }); + } else if (indexHtml) { + // No auth gate, but env config needs injection into HTML + server.middlewares.use((req, res, next) => { + const url = req.url.split('?')[0]; + const ext = extname(url); + if (!ext || ext === '.html') { + res.setHeader('Content-Type', 'text/html'); + res.end(indexHtml); + return; + } + next(); + }); + } + }, + }; +} diff --git a/vite.config.js b/vite.config.js index 08ef0e2..53b6e30 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,5 +1,6 @@ import { defineConfig } from 'vite'; import { VitePWA } from 'vite-plugin-pwa'; +import authGatePlugin from './vite-plugin-auth-gate.js'; export default defineConfig({ base: './', @@ -8,6 +9,7 @@ export default defineConfig({ emptyOutDir: true, }, plugins: [ + authGatePlugin(), VitePWA({ registerType: 'prompt', workbox: {