Merge pull request #169 from blacksigkill/feature/global-auth

Feature: global auth
This commit is contained in:
Eduard Prigoana 2026-02-09 22:36:15 +02:00 committed by GitHub
commit ff907452c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 842 additions and 25 deletions

View file

@ -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

55
AUTH_GATE.md Normal file
View file

@ -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`

View file

@ -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

View file

@ -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=="],

View file

@ -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

View file

@ -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');

View file

@ -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 {

View file

@ -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();

View file

@ -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');

95
package-lock.json generated
View file

@ -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",

View file

@ -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",

343
public/login.html Normal file
View file

@ -0,0 +1,343 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Monochrome — Login</title>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
'Inter',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
background: #0a0a0a;
color: #e0e0e0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-container {
width: 100%;
max-width: 380px;
padding: 2rem;
}
.logo {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-bottom: 2.5rem;
}
.logo svg {
width: 40px;
height: 40px;
}
.logo span {
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.02em;
color: #fff;
}
.btn {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #2a2a2a;
border-radius: 8px;
background: #151515;
color: #e0e0e0;
font-size: 0.925rem;
font-family: inherit;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn:hover {
background: #1e1e1e;
border-color: #3a3a3a;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: #fff;
color: #0a0a0a;
border-color: #fff;
font-weight: 600;
}
.btn-primary:hover {
background: #e0e0e0;
border-color: #e0e0e0;
}
.divider {
display: flex;
align-items: center;
gap: 1rem;
margin: 1.5rem 0;
color: #555;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
height: 1px;
background: #2a2a2a;
}
.form-group {
margin-bottom: 1rem;
}
.form-group input {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #2a2a2a;
border-radius: 8px;
background: #151515;
color: #e0e0e0;
font-size: 0.925rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.form-group input:focus {
border-color: #555;
}
.error-msg {
background: #1a0000;
border: 1px solid #3a1111;
color: #ff6b6b;
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.85rem;
margin-bottom: 1rem;
display: none;
}
.spinner {
width: 18px;
height: 18px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.6s linear infinite;
display: none;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.google-icon {
width: 18px;
height: 18px;
}
</style>
</head>
<body>
<div class="login-container">
<div class="logo">
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="14.75 14.75 70.5 70.5">
<g fill="white">
<path
d="M38.25 14.75H85.25V61.75H61.75V38.25H38.25ZM14.75 38.25H38.25V61.75H61.75V85.25H14.75Z"
/>
</g>
</svg>
<span>Monochrome</span>
</div>
<div id="error" class="error-msg"></div>
<button id="google-btn" class="btn" onclick="googleSignIn()">
<svg class="google-icon" viewBox="0 0 24 24">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Sign in with Google
</button>
<div class="divider">or</div>
<form id="email-form" onsubmit="emailAuth(event)">
<div class="form-group">
<input type="email" id="email" placeholder="Email" required autocomplete="email" />
</div>
<div class="form-group">
<input
type="password"
id="password"
placeholder="Password"
required
autocomplete="current-password"
/>
</div>
<button type="submit" id="email-btn" class="btn btn-primary">
<span id="email-btn-text">Sign In</span>
<span id="email-btn-spinner" class="spinner"></span>
</button>
</form>
</div>
<script type="module">
import { initializeApp } from 'https://www.gstatic.com/firebasejs/10.7.1/firebase-app.js';
import {
getAuth,
GoogleAuthProvider,
signInWithPopup,
signInWithEmailAndPassword,
} from 'https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js';
const STORAGE_KEY = 'monochrome-firebase-config';
const DEFAULT_CONFIG = {
apiKey: 'AIzaSyDPU-unAjuLtQJt4IkGS5faG50UCF7lYyA',
authDomain: 'monochrome-database.firebaseapp.com',
projectId: 'monochrome-database',
storageBucket: 'monochrome-database.firebasestorage.app',
messagingSenderId: '895657412760',
appId: '1:895657412760:web:e81c5044c7f4e9b799e8ed',
};
// Check for setup_firebase hash import
const hash = window.location.hash;
if (hash.startsWith('#setup_firebase=')) {
try {
const encoded = hash.split('#setup_firebase=')[1];
const config = JSON.parse(atob(encoded));
if (config.apiKey && config.authDomain) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
window.history.replaceState(null, null, '/login');
}
} catch (e) {
console.warn('Failed to import Firebase config from hash:', e);
}
}
let storedConfig = null;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) storedConfig = JSON.parse(raw);
} catch (_) {}
// Priority: server-injected env > localStorage > hash-imported > default
const firebaseConfig = window.__FIREBASE_CONFIG__ || storedConfig || DEFAULT_CONFIG;
// Sync to localStorage so the app's config.js picks up the same project
if (window.__FIREBASE_CONFIG__ && !storedConfig) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(firebaseConfig));
}
const fbApp = initializeApp(firebaseConfig);
const auth = getAuth(fbApp);
const provider = new GoogleAuthProvider();
function showError(msg) {
const el = document.getElementById('error');
el.textContent = msg;
el.style.display = 'block';
}
function hideError() {
document.getElementById('error').style.display = 'none';
}
function setLoading(loading) {
document.getElementById('google-btn').disabled = loading;
document.getElementById('email-btn').disabled = loading;
document.getElementById('email-btn-text').style.display = loading ? 'none' : 'inline';
document.getElementById('email-btn-spinner').style.display = loading ? 'inline-block' : 'none';
}
async function sendTokenToServer(user) {
const token = await user.getIdToken();
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Server authentication failed');
}
window.location.href = '/';
}
window.googleSignIn = async function () {
hideError();
setLoading(true);
try {
const result = await signInWithPopup(auth, provider);
await sendTokenToServer(result.user);
} catch (err) {
if (err.code !== 'auth/popup-closed-by-user') {
showError(err.message);
}
setLoading(false);
}
};
window.emailAuth = async function (e) {
e.preventDefault();
hideError();
setLoading(true);
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
try {
const result = await signInWithEmailAndPassword(auth, email, password);
await sendTokenToServer(result.user);
} catch (err) {
showError(err.message);
setLoading(false);
}
};
</script>
</body>
</html>

208
vite-plugin-auth-gate.js Normal file
View file

@ -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 ? `<script>${flags.join(';')};</script>` : 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('</head>', `${configScript}\n</head>`);
}
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('</head>', `${configScript}\n</head>`);
}
}
// --- /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();
});
}
},
};
}

View file

@ -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: {