Initial Wave of Updates
This commit is contained in:
parent
7e9406a8ae
commit
53b8f497cb
|
|
@ -19,7 +19,8 @@
|
|||
"devDependencies": {
|
||||
"alpinejs": "^3.14.1",
|
||||
"beercss": "^3.7.0",
|
||||
"material-dynamic-colors": "^1.1.2"
|
||||
"material-dynamic-colors": "^1.1.2",
|
||||
"playwright": "^1.61.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@material/material-color-utilities": {
|
||||
|
|
@ -577,6 +578,21 @@
|
|||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
|
|
@ -949,6 +965,38 @@
|
|||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
|
||||
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
|
||||
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"devDependencies": {
|
||||
"alpinejs": "^3.14.1",
|
||||
"beercss": "^3.7.0",
|
||||
"material-dynamic-colors": "^1.1.2"
|
||||
"material-dynamic-colors": "^1.1.2",
|
||||
"playwright": "^1.61.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@
|
|||
<link rel="stylesheet" href="/vendor/beer.min.css" />
|
||||
<link rel="stylesheet" href="/css/app.css" />
|
||||
<script src="/vendor/material-dynamic-colors.min.js"></script>
|
||||
<script src="/vendor/beer.min.js"></script>
|
||||
<!-- Beer CSS is an ES module that only exports `ui`; expose it as the global our code calls. -->
|
||||
<script type="module">
|
||||
import { ui } from '/vendor/beer.min.js';
|
||||
window.ui = ui;
|
||||
</script>
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/theme.js"></script>
|
||||
<script src="/js/admin.js" defer></script>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@
|
|||
<link rel="stylesheet" href="/vendor/beer.min.css" />
|
||||
<link rel="stylesheet" href="/css/app.css" />
|
||||
<script src="/vendor/material-dynamic-colors.min.js"></script>
|
||||
<script src="/vendor/beer.min.js"></script>
|
||||
<!-- Beer CSS is an ES module that only exports `ui`; expose it as the global our code calls. -->
|
||||
<script type="module">
|
||||
import { ui } from '/vendor/beer.min.js';
|
||||
window.ui = ui;
|
||||
</script>
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/theme.js"></script>
|
||||
<script src="/js/app.js" defer></script>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,14 @@ window.API = (function () {
|
|||
try { data = JSON.parse(text); } catch (_) { data = { raw: text }; }
|
||||
}
|
||||
if (!res.ok) {
|
||||
// An expired/invalid session (401) on any call other than the login POST
|
||||
// means we should send the user back to sign in, rather than leaving the
|
||||
// page in a broken half-loaded state with only a console error.
|
||||
const onLoginPage = window.location.pathname.endsWith('/login.html');
|
||||
const isLoginCall = url.endsWith('/api/login');
|
||||
if (res.status === 401 && !onLoginPage && !isLoginCall) {
|
||||
window.location.href = '/login.html';
|
||||
}
|
||||
const err = new Error((data && data.error) || `Request failed (${res.status})`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@
|
|||
<link rel="stylesheet" href="/vendor/beer.min.css" />
|
||||
<link rel="stylesheet" href="/css/app.css" />
|
||||
<script src="/vendor/material-dynamic-colors.min.js"></script>
|
||||
<script src="/vendor/beer.min.js"></script>
|
||||
<!-- Beer CSS is an ES module that only exports `ui`; expose it as the global our code calls. -->
|
||||
<script type="module">
|
||||
import { ui } from '/vendor/beer.min.js';
|
||||
window.ui = ui;
|
||||
</script>
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/theme.js"></script>
|
||||
</head>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,12 @@ 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 Symbols icon fonts. beer.min.css references these by relative URL
|
||||
// (with a CDN fallback our CSP blocks), so they must sit beside it in /vendor.
|
||||
['beercss/dist/cdn/material-symbols-outlined.woff2', 'material-symbols-outlined.woff2'],
|
||||
['beercss/dist/cdn/material-symbols-rounded.woff2', 'material-symbols-rounded.woff2'],
|
||||
['beercss/dist/cdn/material-symbols-sharp.woff2', 'material-symbols-sharp.woff2'],
|
||||
['beercss/dist/cdn/material-symbols-subset.woff2', 'material-symbols-subset.woff2'],
|
||||
['material-dynamic-colors/dist/cdn/material-dynamic-colors.min.js', 'material-dynamic-colors.min.js'],
|
||||
['alpinejs/dist/cdn.min.js', 'alpine.min.js'],
|
||||
];
|
||||
|
|
@ -29,5 +35,35 @@ for (const [src, dest] of FILES) {
|
|||
console.error(`FAILED to vendor ${src}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
// Strip Beer CSS's remote @font-face fallbacks (jsdelivr CDN) so the icon font
|
||||
// resolves only to our local /vendor copy — no CDN load, no CSP violation.
|
||||
const beerCss = path.join(OUT, 'beer.min.css');
|
||||
try {
|
||||
const before = fs.readFileSync(beerCss, 'utf8');
|
||||
const after = before.replace(/,url\(https:\/\/cdn\.jsdelivr\.net[^)]*\)\s*format\("woff2"\)/g, '');
|
||||
if (after !== before) {
|
||||
fs.writeFileSync(beerCss, after);
|
||||
console.log('stripped remote CDN font fallbacks from beer.min.css');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`WARN: could not post-process beer.min.css: ${e.message}`);
|
||||
}
|
||||
|
||||
// material-dynamic-colors ships as an ES module: it sets the
|
||||
// globalThis.materialDynamicColors global (which Beer CSS's ui("theme") needs)
|
||||
// but ALSO ends with `export default ...`, which breaks classic <script> loading
|
||||
// ("Unexpected token 'export'"). Strip the export so it loads as a plain script.
|
||||
const mdc = path.join(OUT, 'material-dynamic-colors.min.js');
|
||||
try {
|
||||
const before = fs.readFileSync(mdc, 'utf8');
|
||||
const after = before.replace(/\s*export\s+default[^;]*;\s*$/, '\n');
|
||||
if (after !== before) {
|
||||
fs.writeFileSync(mdc, after);
|
||||
console.log('stripped ES export from material-dynamic-colors.min.js');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`WARN: could not post-process material-dynamic-colors.min.js: ${e.message}`);
|
||||
}
|
||||
|
||||
console.log(`\n${ok}/${FILES.length} files vendored into public/vendor`);
|
||||
if (ok < FILES.length) process.exitCode = 1;
|
||||
|
|
|
|||
29
server.js
29
server.js
|
|
@ -23,7 +23,10 @@ app.use(
|
|||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||
// Alpine.js compiles its x-/@/: expressions with new Function(), which
|
||||
// requires 'unsafe-eval'. All scripts are self-hosted/vendored (no
|
||||
// remote or user-injected scripts), so the added risk is minimal.
|
||||
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:'],
|
||||
fontSrc: ["'self'", 'data:'],
|
||||
|
|
@ -50,6 +53,14 @@ app.use(
|
|||
})
|
||||
);
|
||||
|
||||
// Never let the browser cache API responses. Without this, a GET like /api/me
|
||||
// can be served stale from cache (e.g. a 200 from a prior session) while other
|
||||
// calls hit the network and 401 on an expired session — confusing and buggy.
|
||||
app.use('/api', (req, res, next) => {
|
||||
res.set('Cache-Control', 'no-store');
|
||||
next();
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.use('/api', require('./src/routes/auth.routes'));
|
||||
app.use('/api/users', require('./src/routes/users.routes'));
|
||||
|
|
@ -59,8 +70,20 @@ 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')));
|
||||
// Silence the browser's automatic /favicon.ico request (otherwise a 404 shows
|
||||
// up in the console as a scary-looking "Failed to load resource").
|
||||
app.get('/favicon.ico', (req, res) => res.status(204).end());
|
||||
|
||||
// Static frontend. Force revalidation of HTML/JS so the browser never runs a
|
||||
// stale cached build after an update (a frequent source of "I fixed it but it
|
||||
// still breaks" confusion). Fonts/CSS may still be cached via ETag/Last-Modified.
|
||||
app.use(
|
||||
express.static(path.join(__dirname, 'public'), {
|
||||
setHeaders(res, filePath) {
|
||||
if (/\.(html|js)$/i.test(filePath)) res.set('Cache-Control', 'no-cache');
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// JSON 404 for unmatched API routes
|
||||
app.use('/api', (req, res) => res.status(404).json({ error: 'Not found' }));
|
||||
|
|
|
|||
|
|
@ -19,9 +19,10 @@ 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() }));
|
||||
// GET /api/schemes is public: schemes are just non-sensitive color definitions,
|
||||
// and the login page themes itself from them before anyone authenticates.
|
||||
// Writes (below) are admin-only.
|
||||
router.get('/', (req, res) => res.json({ schemes: listSchemes.all() }));
|
||||
|
||||
router.use(requireAuth, requireAdmin);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
'use strict';
|
||||
|
||||
// Drives the app in a real browser (Edge) and reports console errors, page
|
||||
// errors, and all network responses so we can see exactly what the browser
|
||||
// experiences that curl-style tests don't.
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = 'http://localhost:3000';
|
||||
const USER = process.env.ADMIN_USERNAME || 'admin';
|
||||
const PASS = process.env.ADMIN_PASSWORD || 'BowlerAdmin!2026';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
const log = [];
|
||||
page.on('console', (m) => log.push(` [console.${m.type()}] ${m.text()}`));
|
||||
page.on('pageerror', (e) => log.push(` [pageerror] ${e.message}`));
|
||||
page.on('requestfailed', (r) =>
|
||||
log.push(` [requestfailed] ${r.method()} ${r.url()} :: ${r.failure() && r.failure().errorText}`)
|
||||
);
|
||||
page.on('response', (r) => {
|
||||
const u = r.url();
|
||||
if (u.includes('/api/')) log.push(` [response] ${r.status()} ${r.request().method()} ${u}`);
|
||||
});
|
||||
|
||||
function section(t) { log.push(`\n=== ${t} ===`); }
|
||||
|
||||
section('1) GET / (fresh, no cookies)');
|
||||
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' });
|
||||
log.push(` landed on: ${page.url()}`);
|
||||
|
||||
section('2) Go to /login.html and sign in');
|
||||
if (!page.url().includes('login.html')) await page.goto(`${BASE}/login.html`, { waitUntil: 'networkidle' });
|
||||
await page.fill('#username', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('#login-form button[type="submit"]');
|
||||
await page.waitForLoadState('networkidle');
|
||||
log.push(` after login, url: ${page.url()}`);
|
||||
|
||||
// Inspect cookies the browser is holding.
|
||||
const cookies = await context.cookies();
|
||||
log.push(` cookies: ${cookies.map((c) => `${c.name}(sameSite=${c.sameSite},secure=${c.secure},httpOnly=${c.httpOnly})`).join(', ') || 'NONE'}`);
|
||||
|
||||
section('3) On index — wait for chart fetch');
|
||||
await page.waitForTimeout(1200);
|
||||
log.push(` url: ${page.url()}`);
|
||||
|
||||
section('4) Click Admin button');
|
||||
const adminBtn = page.locator('button[title="Admin"]');
|
||||
if (await adminBtn.count()) {
|
||||
await adminBtn.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
log.push(` url after admin click: ${page.url()}`);
|
||||
// Did users/schemes render?
|
||||
const userRows = await page.locator('table tbody tr').count().catch(() => -1);
|
||||
log.push(` admin table rows visible: ${userRows}`);
|
||||
} else {
|
||||
log.push(' Admin button NOT found (not admin, or page not loaded)');
|
||||
}
|
||||
|
||||
console.log(log.join('\n'));
|
||||
await browser.close();
|
||||
})().catch((e) => {
|
||||
console.error('SCRIPT ERROR:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
'use strict';
|
||||
|
||||
// Reproduces a user with a STALE/invalid session cookie and confirms the app
|
||||
// now redirects to login gracefully instead of dead-ending on 401 errors.
|
||||
const { chromium } = require('playwright');
|
||||
const BASE = 'http://localhost:3000';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
const log = [];
|
||||
|
||||
async function scenario(name, urlPath, setBadCookie) {
|
||||
const context = await browser.newContext();
|
||||
if (setBadCookie) {
|
||||
await context.addCookies([
|
||||
{ name: 'connect.sid', value: 's%3Abogus-stale-session-id.signature', url: BASE },
|
||||
]);
|
||||
}
|
||||
const page = await context.newPage();
|
||||
const errs = [];
|
||||
page.on('console', (m) => { if (m.type() === 'error') errs.push(m.text()); });
|
||||
page.on('pageerror', (e) => errs.push('pageerror: ' + e.message));
|
||||
await page.goto(`${BASE}${urlPath}`, { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(800);
|
||||
log.push(`\n=== ${name} ===`);
|
||||
log.push(` start: ${urlPath} (badCookie=${!!setBadCookie})`);
|
||||
log.push(` ended on: ${page.url()}`);
|
||||
log.push(` console errors: ${errs.length ? errs.join(' | ') : 'none'}`);
|
||||
await context.close();
|
||||
}
|
||||
|
||||
await scenario('Stale cookie visits /', '/', true);
|
||||
await scenario('Stale cookie opens /admin.html directly', '/admin.html', true);
|
||||
await scenario('No session opens /admin.html directly', '/admin.html', false);
|
||||
|
||||
console.log(log.join('\n'));
|
||||
await browser.close();
|
||||
})().catch((e) => { console.error('SCRIPT ERROR:', e); process.exit(1); });
|
||||
Loading…
Reference in New Issue