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:
11
.env.example
11
.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=
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ FROM node:22-alpine
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/build ./build
|
COPY --from=builder /app/build ./build
|
||||||
COPY --from=builder /app/package*.json ./
|
COPY --from=builder /app/package*.json ./
|
||||||
|
COPY --from=builder /app/defaults ./defaults
|
||||||
RUN npm ci --omit=dev --ignore-scripts
|
RUN npm ci --omit=dev --ignore-scripts
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", "build/index.js"]
|
CMD ["node", "build/index.js"]
|
||||||
|
|||||||
6
defaults/config.json
Normal file
6
defaults/config.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"adminPath": "admin",
|
||||||
|
"yoloLabel": "YOLO mode",
|
||||||
|
"yoloDescription": "",
|
||||||
|
"yoloVisibility": "auto"
|
||||||
|
}
|
||||||
280
defaults/units.json
Normal file
280
defaults/units.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
@@ -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
15
package-lock.json
generated
@@ -11,7 +11,8 @@
|
|||||||
"@picocss/pico": "^2.1.1",
|
"@picocss/pico": "^2.1.1",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"big.js": "^7.0.1",
|
"big.js": "^7.0.1",
|
||||||
"cookie": "^1.1.1"
|
"cookie": "^1.1.1",
|
||||||
|
"dotenv": "^16.4.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.5.4",
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
@@ -1430,6 +1431,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:watch": "vite build --watch",
|
"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",
|
"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 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",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
"@picocss/pico": "^2.1.1",
|
"@picocss/pico": "^2.1.1",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"big.js": "^7.0.1",
|
"big.js": "^7.0.1",
|
||||||
"cookie": "^1.1.1"
|
"cookie": "^1.1.1",
|
||||||
|
"dotenv": "^16.4.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { createHmac, timingSafeEqual } from 'crypto';
|
import { createHmac, timingSafeEqual } from 'crypto';
|
||||||
import { parse, serialize } from 'cookie';
|
import { parse, serialize } from 'cookie';
|
||||||
import type { AppConfig } from './data';
|
|
||||||
import { loadConfig } from './data';
|
|
||||||
|
|
||||||
const BCRYPT_COST = 12;
|
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.
|
* 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 cookieHeader = request.headers.get('cookie') ?? '';
|
||||||
const cookies = parse(cookieHeader);
|
const cookies = parse(cookieHeader);
|
||||||
const token = cookies[SESSION_COOKIE];
|
const token = cookies[SESSION_COOKIE];
|
||||||
if (!token) return false;
|
if (!token) return false;
|
||||||
return verifySession(token, config.sessionSecret);
|
return verifySession(token, secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convenience: check auth using the live config from disk.
|
* Convenience: check auth using SESSION_SECRET from env.
|
||||||
* Avoids repeating `requireAuth(request, loadConfig())` in every route.
|
* Avoids repeating requireAuth boilerplate in every route.
|
||||||
*/
|
*/
|
||||||
export function authRequest(request: Request): boolean {
|
export function authRequest(request: Request): boolean {
|
||||||
return requireAuth(request, loadConfig());
|
return requireAuth(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'fs';
|
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, copyFileSync } from 'fs';
|
||||||
import { randomBytes } from 'crypto';
|
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
import type { UnitsData } from '$lib/types';
|
import type { UnitsData } from '$lib/types';
|
||||||
|
|
||||||
// Path is relative to project root (where the server runs from)
|
// Path is relative to project root (where the server runs from)
|
||||||
const DATA_DIR = resolve('data');
|
const DATA_DIR = resolve('data');
|
||||||
|
const DEFAULTS_DIR = resolve('defaults');
|
||||||
const UNITS_PATH = resolve(DATA_DIR, 'units.json');
|
const UNITS_PATH = resolve(DATA_DIR, 'units.json');
|
||||||
const CONFIG_PATH = resolve(DATA_DIR, 'config.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 {
|
export interface AppConfig {
|
||||||
adminPath: string;
|
adminPath: string;
|
||||||
passwordHash: string;
|
|
||||||
sessionSecret: string;
|
|
||||||
/** Label shown next to the YOLO mode toggle. Defaults to "YOLO mode". */
|
/** Label shown next to the YOLO mode toggle. Defaults to "YOLO mode". */
|
||||||
yoloLabel?: string;
|
yoloLabel?: string;
|
||||||
/** Description shown inline after the label. Defaults to "(cross-group conversions)". */
|
/** Description shown inline after the label. Defaults to "(cross-group conversions)". */
|
||||||
@@ -24,10 +24,24 @@ export interface AppConfig {
|
|||||||
yoloVisibility?: 'auto' | 'never';
|
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 {
|
export function loadData(): UnitsData {
|
||||||
|
ensureDataDir();
|
||||||
if (!existsSync(UNITS_PATH)) {
|
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');
|
const raw = readFileSync(UNITS_PATH, 'utf8');
|
||||||
return JSON.parse(raw) as UnitsData;
|
return JSON.parse(raw) as UnitsData;
|
||||||
@@ -40,19 +54,11 @@ export function saveData(data: UnitsData): void {
|
|||||||
renameSync(tmp, UNITS_PATH);
|
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 {
|
export function loadConfig(): AppConfig {
|
||||||
|
ensureDataDir();
|
||||||
if (!existsSync(CONFIG_PATH)) {
|
if (!existsSync(CONFIG_PATH)) {
|
||||||
mkdirSync(DATA_DIR, { recursive: true });
|
throw new Error(`[humor-units] config.json not found at ${CONFIG_PATH} and no default available.`);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
const raw = readFileSync(CONFIG_PATH, 'utf8');
|
const raw = readFileSync(CONFIG_PATH, 'utf8');
|
||||||
return JSON.parse(raw) as AppConfig;
|
return JSON.parse(raw) as AppConfig;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { loadConfig } from '$lib/server/data';
|
|
||||||
import { verifyPassword, createSession, sessionResponse, COOKIE_MAX_AGE } from '$lib/server/auth';
|
import { verifyPassword, createSession, sessionResponse, COOKIE_MAX_AGE } from '$lib/server/auth';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
@@ -11,16 +10,17 @@ export const POST: RequestHandler = async ({ request, url }) => {
|
|||||||
return json({ error: 'Password required' }, { status: 400 });
|
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) {
|
if (!passwordHash || !sessionSecret) {
|
||||||
console.warn('[humor-units] WARNING: No password hash set. Run: node scripts/set-password.js <password>');
|
console.warn('[humor-units] WARNING: PASSWORD_HASH or SESSION_SECRET env var not set.');
|
||||||
return json({ error: 'Admin not configured' }, { status: 503 });
|
return json({ error: 'Admin not configured' }, { status: 503 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timing attack mitigation: ensure response takes at least 500ms on failure
|
// Timing attack mitigation: ensure response takes at least 500ms on failure
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const valid = await verifyPassword(password, config.passwordHash);
|
const valid = await verifyPassword(password, passwordHash);
|
||||||
const elapsed = Date.now() - start;
|
const elapsed = Date.now() - start;
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
@@ -29,7 +29,7 @@ export const POST: RequestHandler = async ({ request, url }) => {
|
|||||||
return json({ error: 'Incorrect password' }, { status: 401 });
|
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.
|
// 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.
|
// 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(':', '');
|
const proto = request.headers.get('x-forwarded-proto') ?? url.protocol.replace(':', '');
|
||||||
|
|||||||
Reference in New Issue
Block a user