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:
@@ -33,14 +33,19 @@
|
||||
});
|
||||
|
||||
// ── 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(() => {
|
||||
if (!browser) return;
|
||||
const stored = localStorage.getItem('yolo-mode');
|
||||
if (stored === 'true') yoloMode = true;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
if (!yoloInitialized) {
|
||||
// First run: read stored value
|
||||
const stored = localStorage.getItem('yolo-mode');
|
||||
if (stored === 'true') yoloMode = true;
|
||||
yoloInitialized = true;
|
||||
return;
|
||||
}
|
||||
// Subsequent runs: persist change
|
||||
localStorage.setItem('yolo-mode', String(yoloMode));
|
||||
});
|
||||
|
||||
@@ -66,32 +71,20 @@
|
||||
// ── Derived: results for ALL units ─────────────────────────────────────────
|
||||
let fromUnit = $derived(units.find((u) => u.id === fromUnitId) ?? units[0]);
|
||||
|
||||
let results: ResultItem[] = $derived(
|
||||
fromUnit
|
||||
? units.map((u) => {
|
||||
const sameGroup = canConvert(fromUnit, u);
|
||||
if (sameGroup) {
|
||||
return {
|
||||
unit: u,
|
||||
convertedValue: convert(Big(inputValue), fromUnit, u),
|
||||
isYolo: false
|
||||
};
|
||||
} else if (yoloMode) {
|
||||
return {
|
||||
unit: u,
|
||||
convertedValue: convertYolo(Big(inputValue), fromUnit, u, groups),
|
||||
isYolo: true
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
unit: u,
|
||||
convertedValue: null,
|
||||
isYolo: false
|
||||
};
|
||||
}
|
||||
})
|
||||
: []
|
||||
);
|
||||
let results: ResultItem[] = $derived.by(() => {
|
||||
if (!fromUnit) return [];
|
||||
const bigInput = Big(inputValue); // hoist — one allocation per recompute, not per unit
|
||||
return units.map((u) => {
|
||||
const sameGroup = canConvert(fromUnit, u);
|
||||
if (sameGroup) {
|
||||
return { unit: u, convertedValue: convert(bigInput, fromUnit, u), isYolo: false };
|
||||
} else if (yoloMode) {
|
||||
return { unit: u, convertedValue: convertYolo(bigInput, fromUnit, u, groups), isYolo: true };
|
||||
} else {
|
||||
return { unit: u, convertedValue: null, isYolo: false };
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Derived: highlighted result ─────────────────────────────────────────────
|
||||
let highlightedResult = $derived(
|
||||
@@ -136,19 +129,24 @@
|
||||
fromUnitId = id;
|
||||
}
|
||||
|
||||
// ── Group units for display ─────────────────────────────────────────────────
|
||||
// ── Group units for display — single O(n) pass ──────────────────────────────
|
||||
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[] }[] = [];
|
||||
for (const group of groups) {
|
||||
const items = results.filter((r) => r.unit.group === group.id);
|
||||
if (items.length > 0) {
|
||||
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 items = byGroup.get(group.id);
|
||||
if (items?.length) sections.push({ groupLabel: group.label, items });
|
||||
}
|
||||
const orphaned = byGroup.get(null);
|
||||
if (orphaned?.length) sections.push({ groupLabel: 'Ungrouped', items: orphaned });
|
||||
return sections;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { loadConfig } from '$lib/server/data';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
import { authRequest } from '$lib/server/auth';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = ({ request, url }) => {
|
||||
@@ -9,15 +8,12 @@ export const load: LayoutServerLoad = ({ request, url }) => {
|
||||
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/')) {
|
||||
return { authenticated: true };
|
||||
return {};
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const authed = requireAuth(request, config);
|
||||
|
||||
if (!authed) {
|
||||
if (!authRequest(request)) {
|
||||
throw redirect(302, '/admin/login');
|
||||
}
|
||||
|
||||
|
||||
@@ -24,23 +24,29 @@
|
||||
|
||||
// ── Load data on mount ───────────────────────────────────────────────────────
|
||||
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;
|
||||
const units: Unit[] = await res.json();
|
||||
const gres = await fetch('/admin/api/groups');
|
||||
const groups: Group[] = gres.ok ? await gres.json() : [];
|
||||
unitsData = { units, groups };
|
||||
// Initialize expanded state
|
||||
for (const g of groups) {
|
||||
expandedGroups.add(g.id);
|
||||
}
|
||||
// Expand all groups — use assignment for consistent reactive update
|
||||
expandedGroups = new Set([...expandedGroups, ...groups.map((g) => g.id)]);
|
||||
}
|
||||
|
||||
// Load on mount
|
||||
// ── Load on mount ────────────────────────────────────────────────────────────
|
||||
$effect(() => {
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
let unitsByGroup = $derived.by(() => {
|
||||
const map = new Map<string | null, Unit[]>();
|
||||
@@ -113,8 +119,7 @@
|
||||
});
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
formError = (err as Record<string, string>).error ?? 'Save failed';
|
||||
formError = await extractError(res, 'Save failed');
|
||||
return;
|
||||
}
|
||||
formSuccess = unitFormNew ? 'Unit created.' : 'Unit saved.';
|
||||
@@ -135,8 +140,7 @@
|
||||
try {
|
||||
const res = await fetch(`/admin/api/units/${selectedItem.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
formError = (err as Record<string, string>).error ?? 'Delete failed';
|
||||
formError = await extractError(res, 'Delete failed');
|
||||
return;
|
||||
}
|
||||
selectedItem = null;
|
||||
@@ -167,8 +171,7 @@
|
||||
});
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
formError = (err as Record<string, string>).error ?? 'Save failed';
|
||||
formError = await extractError(res, 'Save failed');
|
||||
return;
|
||||
}
|
||||
formSuccess = groupFormNew ? 'Group created.' : 'Group saved.';
|
||||
@@ -220,8 +223,7 @@
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
formError = (err as Record<string, string>).error ?? 'Delete failed';
|
||||
formError = await extractError(res, 'Delete failed');
|
||||
return;
|
||||
}
|
||||
deleteGroupTarget = null;
|
||||
@@ -236,12 +238,19 @@
|
||||
// ── Multi-select move ────────────────────────────────────────────────────────
|
||||
async function moveCheckedUnits() {
|
||||
if (!moveTargetGroupId || checkedUnitIds.size === 0) return;
|
||||
for (const unitId of checkedUnitIds) {
|
||||
await fetch(`/admin/api/units/${unitId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ group: moveTargetGroupId })
|
||||
});
|
||||
formError = null;
|
||||
const results = await Promise.all(
|
||||
[...checkedUnitIds].map((unitId) =>
|
||||
fetch(`/admin/api/units/${unitId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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();
|
||||
moveTargetGroupId = '';
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { loadConfig } from '$lib/server/data';
|
||||
import { verifyPassword, createSession } from '$lib/server/auth';
|
||||
import { serialize } from 'cookie';
|
||||
import { verifyPassword, createSession, sessionResponse, COOKIE_MAX_AGE } from '$lib/server/auth';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const COOKIE_MAX_AGE = 86400 * 7; // 7 days
|
||||
|
||||
export const POST: RequestHandler = async ({ request, url }) => {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const { password } = body as { password?: string };
|
||||
@@ -33,19 +30,5 @@ export const POST: RequestHandler = async ({ request, url }) => {
|
||||
}
|
||||
|
||||
const token = createSession(config.sessionSecret);
|
||||
const sessionCookie = serialize('hu_session', token, {
|
||||
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
|
||||
}
|
||||
});
|
||||
return sessionResponse(token, url.protocol === 'https:', COOKIE_MAX_AGE);
|
||||
};
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { serialize } from 'cookie';
|
||||
import { sessionResponse } from '$lib/server/auth';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = ({ url }) => {
|
||||
const clearCookie = serialize('hu_session', '', {
|
||||
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
|
||||
}
|
||||
});
|
||||
return sessionResponse('', url.protocol === 'https:', 0);
|
||||
};
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { loadData, saveData, loadConfig } from '$lib/server/data';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
import { loadData, saveData } from '$lib/server/data';
|
||||
import { authRequest, KEBAB_RE } from '$lib/server/auth';
|
||||
import type { RequestHandler } from './$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 }) => {
|
||||
if (!auth(request)) return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
if (!authRequest(request)) return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const data = loadData();
|
||||
return json(data.groups);
|
||||
};
|
||||
|
||||
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);
|
||||
if (!body) return json({ error: 'Invalid JSON' }, { status: 400 });
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { loadData, saveData, loadConfig } from '$lib/server/data';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
import { loadData, saveData } from '$lib/server/data';
|
||||
import { authRequest } from '$lib/server/auth';
|
||||
import type { RequestHandler } from './$types';
|
||||
import type { Group } from '$lib/types';
|
||||
|
||||
function auth(request: Request) {
|
||||
return requireAuth(request, loadConfig());
|
||||
}
|
||||
|
||||
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 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
|
||||
for (const unit of data.units) {
|
||||
if (unit.group === id) {
|
||||
if (unit.id === newBaseId) {
|
||||
unit.toBase = 1.0;
|
||||
} else {
|
||||
unit.toBase = unit.toBase / X;
|
||||
}
|
||||
unit.toBase = unit.id === newBaseId ? 1.0 : unit.toBase / X;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,7 +44,7 @@ export const PUT: 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 body = await request.json().catch(() => ({}));
|
||||
@@ -96,11 +88,10 @@ export const DELETE: RequestHandler = async ({ request, params }) => {
|
||||
unit.group = targetGroupId;
|
||||
}
|
||||
}
|
||||
} else if (action === 'orphan') {
|
||||
} else {
|
||||
// orphan
|
||||
for (const unit of data.units) {
|
||||
if (unit.group === id) {
|
||||
unit.group = null;
|
||||
}
|
||||
if (unit.group === id) unit.group = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { loadData, saveData, loadConfig } from '$lib/server/data';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
import { loadData, saveData } from '$lib/server/data';
|
||||
import { authRequest, KEBAB_RE } from '$lib/server/auth';
|
||||
import type { RequestHandler } from './$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 }) => {
|
||||
if (!auth(request)) return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
if (!authRequest(request)) return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const data = loadData();
|
||||
return json(data.units);
|
||||
};
|
||||
|
||||
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);
|
||||
if (!body) return json({ error: 'Invalid JSON' }, { status: 400 });
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { loadData, saveData, loadConfig } from '$lib/server/data';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
import { loadData, saveData } from '$lib/server/data';
|
||||
import { authRequest, KEBAB_RE } from '$lib/server/auth';
|
||||
import type { RequestHandler } from './$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 }) => {
|
||||
if (!auth(request)) return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
if (!authRequest(request)) return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { id } = params;
|
||||
const body = await request.json().catch(() => null);
|
||||
@@ -47,7 +41,7 @@ export const PUT: RequestHandler = async ({ 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 data = loadData();
|
||||
|
||||
Reference in New Issue
Block a user