refactor: simplify admin code — extract shared helpers, fix quality issues

- Extract KEBAB_RE, SESSION_COOKIE, SESSION_MAX_AGE_MS, COOKIE_MAX_AGE,
  authRequest(), and sessionResponse() to auth.ts — eliminating 4 copies
  of the auth() wrapper and 3 copies of KEBAB_RE across admin API routes
- Remove timing side-channel: drop a.length !== b.length early-return
  before timingSafeEqual in verifySession (SHA-256 HMAC always 64 chars)
- Fix YOLO localStorage write-before-read race using initialization guard
- Fix admin layout /api/ bypass to return {} instead of {authenticated:true}
- Extract extractError() helper in admin page — removes 4 inline patterns
- Parallel Promise.all in admin loadData() — halves round-trip latency
- Parallel Promise.all in moveCheckedUnits() + surface per-request errors
- Hoist Big(inputValue) above .map() in converter — one alloc per recompute
- O(n) single-pass orderedResults using Map instead of O(n*groups) filters
- Fix expandedGroups.add() mutation — use Set assignment for consistency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Falkan
2026-03-17 14:04:33 -04:00
parent d1f51c141e
commit aeeae1e7ce
10 changed files with 144 additions and 162 deletions

View File

@@ -1,10 +1,23 @@
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { createHmac, timingSafeEqual } from 'crypto'; import { createHmac, timingSafeEqual } from 'crypto';
import { parse } from 'cookie'; import { parse, serialize } from 'cookie';
import type { AppConfig } from './data'; import type { AppConfig } from './data';
import { loadConfig } from './data';
const BCRYPT_COST = 12; const BCRYPT_COST = 12;
/** Session cookie name — single source of truth. */
export const SESSION_COOKIE = 'hu_session';
/** Session lifetime in milliseconds (7 days). Used by both HMAC age-check and cookie max-age. */
export const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
/** Cookie max-age in seconds (derived from SESSION_MAX_AGE_MS). */
export const COOKIE_MAX_AGE = SESSION_MAX_AGE_MS / 1000;
/** Kebab-case ID validation regex — shared by all admin API routes. */
export const KEBAB_RE = /^[a-z][a-z0-9-]*$/;
export async function hashPassword(plain: string): Promise<string> { export async function hashPassword(plain: string): Promise<string> {
return bcrypt.hash(plain, BCRYPT_COST); return bcrypt.hash(plain, BCRYPT_COST);
} }
@@ -26,25 +39,23 @@ export function createSession(secret: string): string {
/** /**
* Verifies an HMAC-signed session token. * Verifies an HMAC-signed session token.
* Returns false if format is wrong, HMAC doesn't match, or token is >7 days old. * Returns false if format is wrong, HMAC doesn't match, or token is expired.
*/ */
export function verifySession(token: string, secret: string): boolean { export function verifySession(token: string, secret: string): boolean {
const parts = token.split('.'); const parts = token.split('.');
if (parts.length !== 2) return false; if (parts.length !== 2) return false;
const [timestamp, providedHmac] = parts; const [timestamp, providedHmac] = parts;
// Check age (7 days) // Check age
const ts = parseInt(timestamp, 10); const ts = parseInt(timestamp, 10);
if (isNaN(ts)) return false; if (isNaN(ts)) return false;
if (Date.now() - ts > 7 * 24 * 60 * 60 * 1000) return false; if (Date.now() - ts > SESSION_MAX_AGE_MS) return false;
// Verify HMAC using timing-safe comparison // Verify HMAC using timing-safe comparison — no early-return on length to
// avoid leaking timing information through a branch before timingSafeEqual.
const expectedHmac = createHmac('sha256', secret).update(timestamp).digest('hex'); const expectedHmac = createHmac('sha256', secret).update(timestamp).digest('hex');
try { try {
const a = Buffer.from(providedHmac, 'hex'); return timingSafeEqual(Buffer.from(providedHmac, 'hex'), Buffer.from(expectedHmac, 'hex'));
const b = Buffer.from(expectedHmac, 'hex');
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
} catch { } catch {
return false; return false;
} }
@@ -57,7 +68,33 @@ export function verifySession(token: string, secret: string): boolean {
export function requireAuth(request: Request, config: AppConfig): boolean { export function requireAuth(request: Request, config: AppConfig): boolean {
const cookieHeader = request.headers.get('cookie') ?? ''; const cookieHeader = request.headers.get('cookie') ?? '';
const cookies = parse(cookieHeader); const cookies = parse(cookieHeader);
const token = cookies['hu_session']; const token = cookies[SESSION_COOKIE];
if (!token) return false; if (!token) return false;
return verifySession(token, config.sessionSecret); return verifySession(token, config.sessionSecret);
} }
/**
* Convenience: check auth using the live config from disk.
* Avoids repeating `requireAuth(request, loadConfig())` in every route.
*/
export function authRequest(request: Request): boolean {
return requireAuth(request, loadConfig());
}
/**
* Build a Set-Cookie Response for session operations.
* Pass token='' and maxAge=0 to clear the cookie (logout).
*/
export function sessionResponse(token: string, secure: boolean, maxAge: number): Response {
const cookie = serialize(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: 'strict',
secure,
path: '/',
maxAge
});
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Set-Cookie': cookie }
});
}

View File

@@ -33,14 +33,19 @@
}); });
// ── Load YOLO preference from localStorage ────────────────────────────────── // ── Load YOLO preference from localStorage ──────────────────────────────────
// Single effect: read on mount, then write on every change.
// The `initialized` guard prevents the write from firing before the read on mount.
let yoloInitialized = $state(false);
$effect(() => { $effect(() => {
if (!browser) return; if (!browser) return;
if (!yoloInitialized) {
// First run: read stored value
const stored = localStorage.getItem('yolo-mode'); const stored = localStorage.getItem('yolo-mode');
if (stored === 'true') yoloMode = true; if (stored === 'true') yoloMode = true;
}); yoloInitialized = true;
return;
$effect(() => { }
if (!browser) return; // Subsequent runs: persist change
localStorage.setItem('yolo-mode', String(yoloMode)); localStorage.setItem('yolo-mode', String(yoloMode));
}); });
@@ -66,32 +71,20 @@
// ── Derived: results for ALL units ───────────────────────────────────────── // ── Derived: results for ALL units ─────────────────────────────────────────
let fromUnit = $derived(units.find((u) => u.id === fromUnitId) ?? units[0]); let fromUnit = $derived(units.find((u) => u.id === fromUnitId) ?? units[0]);
let results: ResultItem[] = $derived( let results: ResultItem[] = $derived.by(() => {
fromUnit if (!fromUnit) return [];
? units.map((u) => { const bigInput = Big(inputValue); // hoist — one allocation per recompute, not per unit
return units.map((u) => {
const sameGroup = canConvert(fromUnit, u); const sameGroup = canConvert(fromUnit, u);
if (sameGroup) { if (sameGroup) {
return { return { unit: u, convertedValue: convert(bigInput, fromUnit, u), isYolo: false };
unit: u,
convertedValue: convert(Big(inputValue), fromUnit, u),
isYolo: false
};
} else if (yoloMode) { } else if (yoloMode) {
return { return { unit: u, convertedValue: convertYolo(bigInput, fromUnit, u, groups), isYolo: true };
unit: u,
convertedValue: convertYolo(Big(inputValue), fromUnit, u, groups),
isYolo: true
};
} else { } else {
return { return { unit: u, convertedValue: null, isYolo: false };
unit: u,
convertedValue: null,
isYolo: false
};
} }
}) });
: [] });
);
// ── Derived: highlighted result ───────────────────────────────────────────── // ── Derived: highlighted result ─────────────────────────────────────────────
let highlightedResult = $derived( let highlightedResult = $derived(
@@ -136,19 +129,24 @@
fromUnitId = id; fromUnitId = id;
} }
// ── Group units for display ───────────────────────────────────────────────── // ── Group units for display — single O(n) pass ──────────────────────────────
let orderedResults: { groupLabel: string | null; items: ResultItem[] }[] = $derived.by(() => { let orderedResults: { groupLabel: string | null; items: ResultItem[] }[] = $derived.by(() => {
// Build a Map of groupId → items in a single pass over results
const byGroup = new Map<string | null, ResultItem[]>();
for (const r of results) {
const key = r.unit.group ?? null;
let bucket = byGroup.get(key);
if (!bucket) { bucket = []; byGroup.set(key, bucket); }
bucket.push(r);
}
// Emit sections in group-definition order, then orphaned at end
const sections: { groupLabel: string | null; items: ResultItem[] }[] = []; const sections: { groupLabel: string | null; items: ResultItem[] }[] = [];
for (const group of groups) { for (const group of groups) {
const items = results.filter((r) => r.unit.group === group.id); const items = byGroup.get(group.id);
if (items.length > 0) { if (items?.length) sections.push({ groupLabel: group.label, items });
sections.push({ groupLabel: group.label, items });
}
}
const orphaned = results.filter((r) => r.unit.group === null);
if (orphaned.length > 0) {
sections.push({ groupLabel: 'Ungrouped', items: orphaned });
} }
const orphaned = byGroup.get(null);
if (orphaned?.length) sections.push({ groupLabel: 'Ungrouped', items: orphaned });
return sections; return sections;
}); });
</script> </script>

View File

@@ -1,6 +1,5 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { loadConfig } from '$lib/server/data'; import { authRequest } from '$lib/server/auth';
import { requireAuth } from '$lib/server/auth';
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ request, url }) => { export const load: LayoutServerLoad = ({ request, url }) => {
@@ -9,15 +8,12 @@ export const load: LayoutServerLoad = ({ request, url }) => {
return { authenticated: false }; return { authenticated: false };
} }
// Skip auth check for API routes (they handle their own auth) // API routes handle their own auth — layout data isn't consumed by +server.ts
if (url.pathname.includes('/api/')) { if (url.pathname.includes('/api/')) {
return { authenticated: true }; return {};
} }
const config = loadConfig(); if (!authRequest(request)) {
const authed = requireAuth(request, config);
if (!authed) {
throw redirect(302, '/admin/login'); throw redirect(302, '/admin/login');
} }

View File

@@ -24,23 +24,29 @@
// ── Load data on mount ─────────────────────────────────────────────────────── // ── Load data on mount ───────────────────────────────────────────────────────
async function loadData() { async function loadData() {
const res = await fetch('/admin/api/units'); const [res, gres] = await Promise.all([
fetch('/admin/api/units'),
fetch('/admin/api/groups')
]);
if (!res.ok) return; if (!res.ok) return;
const units: Unit[] = await res.json(); const units: Unit[] = await res.json();
const gres = await fetch('/admin/api/groups');
const groups: Group[] = gres.ok ? await gres.json() : []; const groups: Group[] = gres.ok ? await gres.json() : [];
unitsData = { units, groups }; unitsData = { units, groups };
// Initialize expanded state // Expand all groups — use assignment for consistent reactive update
for (const g of groups) { expandedGroups = new Set([...expandedGroups, ...groups.map((g) => g.id)]);
expandedGroups.add(g.id);
}
} }
// Load on mount // ── Load on mount ────────────────────────────────────────────────────────────
$effect(() => { $effect(() => {
loadData(); loadData();
}); });
/** Extract a server error message from a non-ok response. */
async function extractError(res: Response, fallback: string): Promise<string> {
const body = await res.json().catch(() => ({}));
return (body as Record<string, string>).error ?? fallback;
}
// ── Derived ────────────────────────────────────────────────────────────────── // ── Derived ──────────────────────────────────────────────────────────────────
let unitsByGroup = $derived.by(() => { let unitsByGroup = $derived.by(() => {
const map = new Map<string | null, Unit[]>(); const map = new Map<string | null, Unit[]>();
@@ -113,8 +119,7 @@
}); });
} }
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); formError = await extractError(res, 'Save failed');
formError = (err as Record<string, string>).error ?? 'Save failed';
return; return;
} }
formSuccess = unitFormNew ? 'Unit created.' : 'Unit saved.'; formSuccess = unitFormNew ? 'Unit created.' : 'Unit saved.';
@@ -135,8 +140,7 @@
try { try {
const res = await fetch(`/admin/api/units/${selectedItem.id}`, { method: 'DELETE' }); const res = await fetch(`/admin/api/units/${selectedItem.id}`, { method: 'DELETE' });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); formError = await extractError(res, 'Delete failed');
formError = (err as Record<string, string>).error ?? 'Delete failed';
return; return;
} }
selectedItem = null; selectedItem = null;
@@ -167,8 +171,7 @@
}); });
} }
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); formError = await extractError(res, 'Save failed');
formError = (err as Record<string, string>).error ?? 'Save failed';
return; return;
} }
formSuccess = groupFormNew ? 'Group created.' : 'Group saved.'; formSuccess = groupFormNew ? 'Group created.' : 'Group saved.';
@@ -220,8 +223,7 @@
body: JSON.stringify(body) body: JSON.stringify(body)
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); formError = await extractError(res, 'Delete failed');
formError = (err as Record<string, string>).error ?? 'Delete failed';
return; return;
} }
deleteGroupTarget = null; deleteGroupTarget = null;
@@ -236,12 +238,19 @@
// ── Multi-select move ──────────────────────────────────────────────────────── // ── Multi-select move ────────────────────────────────────────────────────────
async function moveCheckedUnits() { async function moveCheckedUnits() {
if (!moveTargetGroupId || checkedUnitIds.size === 0) return; if (!moveTargetGroupId || checkedUnitIds.size === 0) return;
for (const unitId of checkedUnitIds) { formError = null;
await fetch(`/admin/api/units/${unitId}`, { const results = await Promise.all(
[...checkedUnitIds].map((unitId) =>
fetch(`/admin/api/units/${unitId}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ group: moveTargetGroupId }) body: JSON.stringify({ group: moveTargetGroupId })
}); })
)
);
const failed = results.filter((r) => !r.ok);
if (failed.length) {
formError = `${failed.length} unit(s) failed to move`;
} }
checkedUnitIds = new Set(); checkedUnitIds = new Set();
moveTargetGroupId = ''; moveTargetGroupId = '';

View File

@@ -1,11 +1,8 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { loadConfig } from '$lib/server/data'; import { loadConfig } from '$lib/server/data';
import { verifyPassword, createSession } from '$lib/server/auth'; import { verifyPassword, createSession, sessionResponse, COOKIE_MAX_AGE } from '$lib/server/auth';
import { serialize } from 'cookie';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
const COOKIE_MAX_AGE = 86400 * 7; // 7 days
export const POST: RequestHandler = async ({ request, url }) => { export const POST: RequestHandler = async ({ request, url }) => {
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const { password } = body as { password?: string }; const { password } = body as { password?: string };
@@ -33,19 +30,5 @@ export const POST: RequestHandler = async ({ request, url }) => {
} }
const token = createSession(config.sessionSecret); const token = createSession(config.sessionSecret);
const sessionCookie = serialize('hu_session', token, { return sessionResponse(token, url.protocol === 'https:', COOKIE_MAX_AGE);
httpOnly: true,
sameSite: 'strict',
secure: url.protocol === 'https:',
path: '/',
maxAge: COOKIE_MAX_AGE
});
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Set-Cookie': sessionCookie
}
});
}; };

View File

@@ -1,20 +1,6 @@
import { json } from '@sveltejs/kit'; import { sessionResponse } from '$lib/server/auth';
import { serialize } from 'cookie';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
export const POST: RequestHandler = ({ url }) => { export const POST: RequestHandler = ({ url }) => {
const clearCookie = serialize('hu_session', '', { return sessionResponse('', url.protocol === 'https:', 0);
httpOnly: true,
sameSite: 'strict',
secure: url.protocol === 'https:',
path: '/',
maxAge: 0
});
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Set-Cookie': clearCookie
}
});
}; };

View File

@@ -1,23 +1,17 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { loadData, saveData, loadConfig } from '$lib/server/data'; import { loadData, saveData } from '$lib/server/data';
import { requireAuth } from '$lib/server/auth'; import { authRequest, KEBAB_RE } from '$lib/server/auth';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import type { Group } from '$lib/types'; import type { Group } from '$lib/types';
function auth(request: Request) {
return requireAuth(request, loadConfig());
}
const KEBAB_RE = /^[a-z][a-z0-9-]*$/;
export const GET: RequestHandler = ({ request }) => { export const GET: RequestHandler = ({ request }) => {
if (!auth(request)) return json({ error: 'Unauthorized' }, { status: 401 }); if (!authRequest(request)) return json({ error: 'Unauthorized' }, { status: 401 });
const data = loadData(); const data = loadData();
return json(data.groups); return json(data.groups);
}; };
export const POST: RequestHandler = async ({ request }) => { export const POST: RequestHandler = async ({ request }) => {
if (!auth(request)) return json({ error: 'Unauthorized' }, { status: 401 }); if (!authRequest(request)) return json({ error: 'Unauthorized' }, { status: 401 });
const body = await request.json().catch(() => null); const body = await request.json().catch(() => null);
if (!body) return json({ error: 'Invalid JSON' }, { status: 400 }); if (!body) return json({ error: 'Invalid JSON' }, { status: 400 });

View File

@@ -1,15 +1,11 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { loadData, saveData, loadConfig } from '$lib/server/data'; import { loadData, saveData } from '$lib/server/data';
import { requireAuth } from '$lib/server/auth'; import { authRequest } from '$lib/server/auth';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import type { Group } from '$lib/types'; import type { Group } from '$lib/types';
function auth(request: Request) {
return requireAuth(request, loadConfig());
}
export const PUT: RequestHandler = async ({ request, params }) => { export const PUT: RequestHandler = async ({ request, params }) => {
if (!auth(request)) return json({ error: 'Unauthorized' }, { status: 401 }); if (!authRequest(request)) return json({ error: 'Unauthorized' }, { status: 401 });
const { id } = params; const { id } = params;
const body = await request.json().catch(() => null); const body = await request.json().catch(() => null);
@@ -35,11 +31,7 @@ export const PUT: RequestHandler = async ({ request, params }) => {
// Then set new base unit toBase = 1.0 // Then set new base unit toBase = 1.0
for (const unit of data.units) { for (const unit of data.units) {
if (unit.group === id) { if (unit.group === id) {
if (unit.id === newBaseId) { unit.toBase = unit.id === newBaseId ? 1.0 : unit.toBase / X;
unit.toBase = 1.0;
} else {
unit.toBase = unit.toBase / X;
}
} }
} }
} }
@@ -52,7 +44,7 @@ export const PUT: RequestHandler = async ({ request, params }) => {
}; };
export const DELETE: RequestHandler = async ({ request, params }) => { export const DELETE: RequestHandler = async ({ request, params }) => {
if (!auth(request)) return json({ error: 'Unauthorized' }, { status: 401 }); if (!authRequest(request)) return json({ error: 'Unauthorized' }, { status: 401 });
const { id } = params; const { id } = params;
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
@@ -96,11 +88,10 @@ export const DELETE: RequestHandler = async ({ request, params }) => {
unit.group = targetGroupId; unit.group = targetGroupId;
} }
} }
} else if (action === 'orphan') { } else {
// orphan
for (const unit of data.units) { for (const unit of data.units) {
if (unit.group === id) { if (unit.group === id) unit.group = null;
unit.group = null;
}
} }
} }

View File

@@ -1,23 +1,17 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { loadData, saveData, loadConfig } from '$lib/server/data'; import { loadData, saveData } from '$lib/server/data';
import { requireAuth } from '$lib/server/auth'; import { authRequest, KEBAB_RE } from '$lib/server/auth';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import type { Unit } from '$lib/types'; import type { Unit } from '$lib/types';
function auth(request: Request) {
return requireAuth(request, loadConfig());
}
const KEBAB_RE = /^[a-z][a-z0-9-]*$/;
export const GET: RequestHandler = ({ request }) => { export const GET: RequestHandler = ({ request }) => {
if (!auth(request)) return json({ error: 'Unauthorized' }, { status: 401 }); if (!authRequest(request)) return json({ error: 'Unauthorized' }, { status: 401 });
const data = loadData(); const data = loadData();
return json(data.units); return json(data.units);
}; };
export const POST: RequestHandler = async ({ request }) => { export const POST: RequestHandler = async ({ request }) => {
if (!auth(request)) return json({ error: 'Unauthorized' }, { status: 401 }); if (!authRequest(request)) return json({ error: 'Unauthorized' }, { status: 401 });
const body = await request.json().catch(() => null); const body = await request.json().catch(() => null);
if (!body) return json({ error: 'Invalid JSON' }, { status: 400 }); if (!body) return json({ error: 'Invalid JSON' }, { status: 400 });

View File

@@ -1,17 +1,11 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { loadData, saveData, loadConfig } from '$lib/server/data'; import { loadData, saveData } from '$lib/server/data';
import { requireAuth } from '$lib/server/auth'; import { authRequest, KEBAB_RE } from '$lib/server/auth';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import type { Unit } from '$lib/types'; import type { Unit } from '$lib/types';
function auth(request: Request) {
return requireAuth(request, loadConfig());
}
const KEBAB_RE = /^[a-z][a-z0-9-]*$/;
export const PUT: RequestHandler = async ({ request, params }) => { export const PUT: RequestHandler = async ({ request, params }) => {
if (!auth(request)) return json({ error: 'Unauthorized' }, { status: 401 }); if (!authRequest(request)) return json({ error: 'Unauthorized' }, { status: 401 });
const { id } = params; const { id } = params;
const body = await request.json().catch(() => null); const body = await request.json().catch(() => null);
@@ -47,7 +41,7 @@ export const PUT: RequestHandler = async ({ request, params }) => {
}; };
export const DELETE: RequestHandler = ({ request, params }) => { export const DELETE: RequestHandler = ({ request, params }) => {
if (!auth(request)) return json({ error: 'Unauthorized' }, { status: 401 }); if (!authRequest(request)) return json({ error: 'Unauthorized' }, { status: 401 });
const { id } = params; const { id } = params;
const data = loadData(); const data = loadData();