refactor: move sessionSecret+passwordHash to env vars, seed data/ from defaults/

- 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
This commit is contained in:
Falkan
2026-03-19 23:54:37 -04:00
parent 5b89585505
commit 5480bb246c
11 changed files with 352 additions and 122 deletions

View File

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

View File

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

6
defaults/config.json Normal file
View File

@@ -0,0 +1,6 @@
{
"adminPath": "admin",
"yoloLabel": "YOLO mode",
"yoloDescription": "",
"yoloVisibility": "auto"
}

280
defaults/units.json Normal file
View File

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

View File

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

View File

@@ -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=<your-secret> \
# --from-literal=PASSWORD_HASH=<bcrypt-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

15
package-lock.json generated
View File

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

View File

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

View File

@@ -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);
}
/**

View File

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

View File

@@ -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 <password>');
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(':', '');