From 5480bb246c2469705f03f68478cedc37fc86b058 Mon Sep 17 00:00:00 2001 From: Falkan Date: Thu, 19 Mar 2026 23:54:37 -0400 Subject: [PATCH] refactor: move sessionSecret+passwordHash to env vars, seed data/ from defaults/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SESSION_SECRET and PASSWORD_HASH moved out of config.json into env vars - data.ts: AppConfig no longer holds secrets; loadConfig/loadData seed from defaults/ on first run if data/ files are missing - auth.ts: requireAuth/authRequest read SESSION_SECRET from process.env directly - login/+server.ts: reads PASSWORD_HASH and SESSION_SECRET from process.env - defaults/config.json: ships with image (no secrets) - defaults/units.json: ships with image as initial unit data - package.json: add dotenv dep; start/serve load .env via -r dotenv/config - Dockerfile: copy defaults/ into image; data/ is PVC-only - .env.example: documents required env vars for local dev - Remove k8s/ — managed externally --- .env.example | 11 +- Dockerfile | 1 + defaults/config.json | 6 + defaults/units.json | 280 +++++++++++++++++++++ k8s/deployment.yaml | 70 ------ k8s/secrets.yaml | 16 -- package-lock.json | 15 +- package.json | 7 +- src/lib/server/auth.ts | 16 +- src/lib/server/data.ts | 40 +-- src/routes/admin/api/auth/login/+server.ts | 12 +- 11 files changed, 352 insertions(+), 122 deletions(-) create mode 100644 defaults/config.json create mode 100644 defaults/units.json delete mode 100644 k8s/deployment.yaml delete mode 100644 k8s/secrets.yaml diff --git a/.env.example b/.env.example index 232347e..daf3a5f 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,10 @@ -ORIGIN=http://localhost:3000 +# Copy this to .env and fill in values for local development. +# In production, set these as environment variables / Kubernetes secrets. + +# bcrypt hash of the admin password. Generate with: +# python3 -c "import bcrypt; print(bcrypt.hashpw(b'yourpassword', bcrypt.gensalt(rounds=12)).decode())" +PASSWORD_HASH= + +# Random secret for signing session tokens. Generate with: +# openssl rand -hex 32 +SESSION_SECRET= diff --git a/Dockerfile b/Dockerfile index dcf4fbd..9e45ed0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,7 @@ FROM node:22-alpine WORKDIR /app COPY --from=builder /app/build ./build COPY --from=builder /app/package*.json ./ +COPY --from=builder /app/defaults ./defaults RUN npm ci --omit=dev --ignore-scripts EXPOSE 3000 CMD ["node", "build/index.js"] diff --git a/defaults/config.json b/defaults/config.json new file mode 100644 index 0000000..34234fa --- /dev/null +++ b/defaults/config.json @@ -0,0 +1,6 @@ +{ + "adminPath": "admin", + "yoloLabel": "YOLO mode", + "yoloDescription": "", + "yoloVisibility": "auto" +} diff --git a/defaults/units.json b/defaults/units.json new file mode 100644 index 0000000..3de94f6 --- /dev/null +++ b/defaults/units.json @@ -0,0 +1,280 @@ +{ + "units": [ + { + "id": "assload", + "label": "assload", + "labelPlural": "assloads", + "symbol": "assl", + "group": "dickloads", + "toBase": 2 + }, + { + "id": "imperial-barrel", + "label": "barrel", + "labelPlural": "barrels", + "symbol": "bbl imperial", + "group": "dickloads", + "toBase": 13.710316648, + "hidden": false + }, + { + "id": "us-dry-bbl", + "label": "barrel", + "labelPlural": "barrels", + "symbol": "bbl us dry", + "group": "dickloads", + "toBase": 9.6864954123 + }, + { + "id": "us-fluid-bbl", + "label": "barrel", + "labelPlural": "barrels", + "symbol": "bbl us fluid", + "group": "dickloads", + "toBase": 9.9894438 + }, + { + "id": "us-oil-bbl", + "label": "barrel", + "labelPlural": "barrels", + "symbol": "bbl us oil", + "group": "dickloads", + "toBase": 13.3192584, + "description": "Weight is based on pure water at 4 degrees celsius.", + "hidden": false + }, + { + "id": "dickload", + "label": "dickload", + "labelPlural": "dickloads", + "symbol": "dl", + "group": "dickloads", + "toBase": 1, + "description": "The basic unit of measurement for all weights and masses worldwide." + }, + { + "id": "fuckton", + "label": "fuck ton", + "labelPlural": "fuck tons", + "symbol": "fcktn", + "group": "dickloads", + "toBase": 152 + }, + { + "id": "imperial-hogshead", + "label": "hogshead", + "labelPlural": "hogsheads", + "symbol": "hhd imperial", + "group": "dickloads", + "toBase": 27.420633296, + "description": "The hogshead as understood by residents of Old Blighty." + }, + { + "id": "us-hogshead", + "label": "hogshead", + "labelPlural": "hogsheads", + "symbol": "hhd us", + "group": "dickloads", + "toBase": 19.9788876, + "description": "This is the superior Trumpistanian hogshead. It's biglier. (It's actually smaller.)" + }, + { + "id": "long-ton", + "label": "long ton", + "labelPlural": "long tons", + "symbol": "long tn", + "group": "dickloads", + "toBase": 85.1199999999, + "hidden": false + }, + { + "id": "metric-fuckton", + "label": "metric fuckton", + "labelPlural": "metric fucktons", + "symbol": "mfcktn", + "group": "dickloads", + "toBase": 170.2399999998, + "description": "Trivia: although the name is \"metric fuckton,\" it is actually equal to two {long-ton:plural}!", + "hidden": false + }, + { + "id": "mm", + "label": "millimeter", + "labelPlural": "millimeters", + "symbol": "mm", + "group": "lengths-and-distance", + "toBase": 1, + "hidden": false, + "description": "It's so tiny and kawaii (♡‿♡)." + }, + { + "id": "pound", + "label": "pound", + "labelPlural": "pounds", + "symbol": "lb", + "group": "dickloads", + "toBase": 0.038, + "description": "Avoirdupois pounds, as used in the USA." + }, + { + "id": "shit-ton", + "label": "shit ton", + "labelPlural": "shit tons", + "symbol": "shttn", + "group": "dickloads", + "toBase": 38 + }, + { + "id": "shitload", + "label": "shitload", + "labelPlural": "shitloads", + "symbol": "shtld", + "group": "dickloads", + "toBase": 12.6666666667 + }, + { + "id": "short-ton", + "label": "short ton", + "labelPlural": "short tons", + "symbol": "t", + "group": "dickloads", + "toBase": 76 + }, + { + "id": "cm", + "label": "centimeter", + "labelPlural": "centimeters", + "symbol": "cm", + "group": "lengths-and-distance", + "toBase": 10 + }, + { + "id": "meter", + "label": "meter", + "labelPlural": "meters", + "symbol": "m", + "group": "lengths-and-distance", + "toBase": 1000 + }, + { + "id": "dm", + "label": "decimeter", + "labelPlural": "decimeters", + "symbol": "dm", + "group": "lengths-and-distance", + "toBase": 100 + }, + { + "id": "dam", + "label": "decameter", + "labelPlural": "decameters", + "symbol": "dam", + "group": "lengths-and-distance", + "toBase": 10000 + }, + { + "id": "km", + "label": "kilometer", + "labelPlural": "kilometers", + "symbol": "km", + "group": "lengths-and-distance", + "toBase": 1000000 + }, + { + "id": "in", + "label": "inch", + "labelPlural": "inches", + "symbol": "in", + "group": "lengths-and-distance", + "toBase": 25.4 + }, + { + "id": "ft", + "label": "foot", + "labelPlural": "feet", + "symbol": "ft", + "group": "lengths-and-distance", + "toBase": 304.8 + }, + { + "id": "yd", + "label": "yard", + "labelPlural": "yards", + "symbol": "yd", + "group": "lengths-and-distance", + "toBase": 914.4, + "description": "Freedom meter." + }, + { + "id": "mi", + "label": "mile", + "labelPlural": "miles", + "symbol": "mi", + "group": "lengths-and-distance", + "toBase": 1609344 + }, + { + "id": "fur", + "label": "furlong", + "labelPlural": "furlongs", + "symbol": "fu", + "group": "lengths-and-distance", + "toBase": 201168, + "description": "This matters to generate horse gamblers, and literally no one else." + }, + { + "id": "ch", + "label": "chain", + "labelPlural": "chains", + "symbol": "ch", + "group": "lengths-and-distance", + "toBase": 20116.8 + }, + { + "id": "li", + "label": "link", + "labelPlural": "links", + "symbol": "li", + "group": "lengths-and-distance", + "toBase": 201.168 + }, + { + "id": "rd", + "label": "rod", + "labelPlural": "rods", + "symbol": "rd", + "group": "lengths-and-distance", + "toBase": 5029.2, + "description": "\"Spare the 'this' and spoil the child.\"\n\"What is the rod?\"\n\"Yes! For $500, what is the rod. Play continues. Will our abusive parent please choose another square?\"" + }, + { + "id": "ftm", + "label": "fathom", + "labelPlural": "fathoms", + "symbol": "ftm", + "group": "lengths-and-distance", + "toBase": 1828.8, + "description": "The conversion of dickloads to fathoms is based on a 1' x 1' x 1ftm column of pure water at 4 degrees celsius. Obviously.", + "hidden": false + } + ], + "groups": [ + { + "id": "dickloads", + "label": "Weights and Masses", + "baseUnitId": "dickload", + "toUniversal": 1, + "sortOrder": "alpha", + "hidden": false, + "alwaysShowLabel": true + }, + { + "id": "lengths-and-distance", + "label": "Lengths and Distance", + "baseUnitId": "mm", + "toUniversal": 0.0077828181, + "alwaysShowLabel": false, + "hidden": false + } + ] +} diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml deleted file mode 100644 index 74e857c..0000000 --- a/k8s/deployment.yaml +++ /dev/null @@ -1,70 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: humor-units - labels: - app: humor-units -spec: - replicas: 1 - selector: - matchLabels: - app: humor-units - template: - metadata: - labels: - app: humor-units - spec: - containers: - - name: humor-units - image: registry.nerdrage.cloud/dickloads:latest - imagePullPolicy: Always - ports: - - containerPort: 3000 - env: - - name: PORT - value: "3000" - - name: HOST - value: "0.0.0.0" - - name: NODE_ENV - value: "production" - - name: ORIGIN - value: "https://humorunits.com" - - name: ALLOWED_ORIGINS - value: "https://humorunits.com,https://www.humorunits.com,https://dickloads.com,https://www.dickloads.com" - volumeMounts: - - name: data - mountPath: /app/data - resources: - requests: - cpu: 50m - memory: 128Mi - limits: - cpu: 500m - memory: 256Mi - readinessProbe: - httpGet: - path: / - port: 3000 - initialDelaySeconds: 5 - periodSeconds: 10 - livenessProbe: - httpGet: - path: / - port: 3000 - initialDelaySeconds: 10 - periodSeconds: 30 - volumes: - - name: data - persistentVolumeClaim: - claimName: humor-units-data ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: humor-units-data -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 100Mi diff --git a/k8s/secrets.yaml b/k8s/secrets.yaml deleted file mode 100644 index e35cbcb..0000000 --- a/k8s/secrets.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# humor-units-secrets — do NOT commit real values -# Create with: -# kubectl create secret generic humor-units-secrets \ -# --from-literal=SESSION_SECRET= \ -# --from-literal=PASSWORD_HASH= -# -# Or apply this manifest after filling in base64-encoded values: -# echo -n 'value' | base64 -apiVersion: v1 -kind: Secret -metadata: - name: humor-units-secrets -type: Opaque -data: - SESSION_SECRET: "" # base64-encoded - PASSWORD_HASH: "" # base64-encoded bcrypt hash diff --git a/package-lock.json b/package-lock.json index f2be625..b0c4b01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "@picocss/pico": "^2.1.1", "bcryptjs": "^3.0.3", "big.js": "^7.0.1", - "cookie": "^1.1.1" + "cookie": "^1.1.1", + "dotenv": "^16.4.7" }, "devDependencies": { "@sveltejs/adapter-node": "^5.5.4", @@ -1430,6 +1431,18 @@ "dev": true, "license": "MIT" }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/esbuild": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", diff --git a/package.json b/package.json index 885a6cd..f7297bf 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "dev": "vite dev", "build": "vite build", "build:watch": "vite build --watch", - "serve": "PORT=4078 HOST=192.168.0.94 ORIGIN=http://192.168.0.94:4078 nodemon --watch build/server build/index.js", - "start": "PORT=4078 HOST=192.168.0.94 ORIGIN=http://192.168.0.94:4078 node build/index.js", + "serve": "PORT=4078 HOST=192.168.0.94 ORIGIN=http://192.168.0.94:4078 nodemon --watch build/server -r dotenv/config build/index.js", + "start": "PORT=4078 HOST=192.168.0.94 ORIGIN=http://192.168.0.94:4078 node -r dotenv/config build/index.js", "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", @@ -32,6 +32,7 @@ "@picocss/pico": "^2.1.1", "bcryptjs": "^3.0.3", "big.js": "^7.0.1", - "cookie": "^1.1.1" + "cookie": "^1.1.1", + "dotenv": "^16.4.7" } } diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index e2bb10e..91ed439 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -1,8 +1,6 @@ import bcrypt from 'bcryptjs'; import { createHmac, timingSafeEqual } from 'crypto'; import { parse, serialize } from 'cookie'; -import type { AppConfig } from './data'; -import { loadConfig } from './data'; const BCRYPT_COST = 12; @@ -62,23 +60,25 @@ export function verifySession(token: string, secret: string): boolean { } /** - * Checks the hu_session cookie in the request. + * Checks the hu_session cookie in the request against SESSION_SECRET env var. * Returns true if a valid session is present. */ -export function requireAuth(request: Request, config: AppConfig): boolean { +export function requireAuth(request: Request): boolean { + const secret = process.env.SESSION_SECRET ?? ''; + if (!secret) return false; const cookieHeader = request.headers.get('cookie') ?? ''; const cookies = parse(cookieHeader); const token = cookies[SESSION_COOKIE]; if (!token) return false; - return verifySession(token, config.sessionSecret); + return verifySession(token, secret); } /** - * Convenience: check auth using the live config from disk. - * Avoids repeating `requireAuth(request, loadConfig())` in every route. + * Convenience: check auth using SESSION_SECRET from env. + * Avoids repeating requireAuth boilerplate in every route. */ export function authRequest(request: Request): boolean { - return requireAuth(request, loadConfig()); + return requireAuth(request); } /** diff --git a/src/lib/server/data.ts b/src/lib/server/data.ts index f2f3806..2e27dcb 100644 --- a/src/lib/server/data.ts +++ b/src/lib/server/data.ts @@ -1,17 +1,17 @@ -import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'fs'; -import { randomBytes } from 'crypto'; +import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, copyFileSync } from 'fs'; import { resolve } from 'path'; import type { UnitsData } from '$lib/types'; // Path is relative to project root (where the server runs from) const DATA_DIR = resolve('data'); +const DEFAULTS_DIR = resolve('defaults'); const UNITS_PATH = resolve(DATA_DIR, 'units.json'); const CONFIG_PATH = resolve(DATA_DIR, 'config.json'); +const DEFAULT_UNITS_PATH = resolve(DEFAULTS_DIR, 'units.json'); +const DEFAULT_CONFIG_PATH = resolve(DEFAULTS_DIR, 'config.json'); export interface AppConfig { adminPath: string; - passwordHash: string; - sessionSecret: string; /** Label shown next to the YOLO mode toggle. Defaults to "YOLO mode". */ yoloLabel?: string; /** Description shown inline after the label. Defaults to "(cross-group conversions)". */ @@ -24,10 +24,24 @@ export interface AppConfig { yoloVisibility?: 'auto' | 'never'; } -/** Read units.json synchronously. Returns parsed UnitsData. */ +/** Ensure data/ directory exists and seed missing files from defaults/. */ +function ensureDataDir(): void { + mkdirSync(DATA_DIR, { recursive: true }); + if (!existsSync(UNITS_PATH) && existsSync(DEFAULT_UNITS_PATH)) { + copyFileSync(DEFAULT_UNITS_PATH, UNITS_PATH); + console.log('[humor-units] First run: seeded data/units.json from defaults/units.json'); + } + if (!existsSync(CONFIG_PATH) && existsSync(DEFAULT_CONFIG_PATH)) { + copyFileSync(DEFAULT_CONFIG_PATH, CONFIG_PATH); + console.log('[humor-units] First run: seeded data/config.json from defaults/config.json'); + } +} + +/** Read units.json synchronously. Seeds from defaults on first run. */ export function loadData(): UnitsData { + ensureDataDir(); if (!existsSync(UNITS_PATH)) { - throw new Error(`[humor-units] units.json not found at ${UNITS_PATH}. Mount it via a ConfigMap or PVC.`); + throw new Error(`[humor-units] units.json not found at ${UNITS_PATH} and no default available.`); } const raw = readFileSync(UNITS_PATH, 'utf8'); return JSON.parse(raw) as UnitsData; @@ -40,19 +54,11 @@ export function saveData(data: UnitsData): void { renameSync(tmp, UNITS_PATH); } -/** Read config.json synchronously. Creates a default config on first run if missing. */ +/** Read config.json synchronously. Seeds from defaults on first run. */ export function loadConfig(): AppConfig { + ensureDataDir(); if (!existsSync(CONFIG_PATH)) { - mkdirSync(DATA_DIR, { recursive: true }); - const defaultConfig: AppConfig = { - adminPath: 'admin', - passwordHash: '', - sessionSecret: randomBytes(32).toString('hex'), - }; - writeFileSync(CONFIG_PATH, JSON.stringify(defaultConfig, null, 2) + '\n', 'utf8'); - console.log('[humor-units] First run: generated default config.json with random sessionSecret.'); - console.log('[humor-units] Set an admin password via: node scripts/set-password.js '); - return defaultConfig; + throw new Error(`[humor-units] config.json not found at ${CONFIG_PATH} and no default available.`); } const raw = readFileSync(CONFIG_PATH, 'utf8'); return JSON.parse(raw) as AppConfig; diff --git a/src/routes/admin/api/auth/login/+server.ts b/src/routes/admin/api/auth/login/+server.ts index 1eaa8f5..065b52b 100644 --- a/src/routes/admin/api/auth/login/+server.ts +++ b/src/routes/admin/api/auth/login/+server.ts @@ -1,5 +1,4 @@ import { json } from '@sveltejs/kit'; -import { loadConfig } from '$lib/server/data'; import { verifyPassword, createSession, sessionResponse, COOKIE_MAX_AGE } from '$lib/server/auth'; import type { RequestHandler } from './$types'; @@ -11,16 +10,17 @@ export const POST: RequestHandler = async ({ request, url }) => { return json({ error: 'Password required' }, { status: 400 }); } - const config = loadConfig(); + const passwordHash = process.env.PASSWORD_HASH ?? ''; + const sessionSecret = process.env.SESSION_SECRET ?? ''; - if (!config.passwordHash) { - console.warn('[humor-units] WARNING: No password hash set. Run: node scripts/set-password.js '); + if (!passwordHash || !sessionSecret) { + console.warn('[humor-units] WARNING: PASSWORD_HASH or SESSION_SECRET env var not set.'); return json({ error: 'Admin not configured' }, { status: 503 }); } // Timing attack mitigation: ensure response takes at least 500ms on failure const start = Date.now(); - const valid = await verifyPassword(password, config.passwordHash); + const valid = await verifyPassword(password, passwordHash); const elapsed = Date.now() - start; if (!valid) { @@ -29,7 +29,7 @@ export const POST: RequestHandler = async ({ request, url }) => { return json({ error: 'Incorrect password' }, { status: 401 }); } - const token = createSession(config.sessionSecret); + const token = createSession(sessionSecret); // Behind nginx-ingress, TLS terminates at the proxy and Node sees plain HTTP. // X-Forwarded-Proto carries the original scheme; fall back to url.protocol for dev. const proto = request.headers.get('x-forwarded-proto') ?? url.protocol.replace(':', '');