Compare commits
10 Commits
d40b76e8f2
...
2a4a1fb61d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a4a1fb61d | ||
|
|
4d2d7c77d3 | ||
|
|
231eacc27c | ||
|
|
7239ca6d5a | ||
|
|
f10e4a6cbe | ||
|
|
ea08258ad4 | ||
|
|
ae7ad0073b | ||
|
|
258b0f5065 | ||
|
|
d4b8a9a646 | ||
|
|
5ec1222720 |
13
.env.example
13
.env.example
@@ -1,13 +1,18 @@
|
||||
# Copy this to .env and fill in values for local development.
|
||||
# In production, set these as environment variables / Kubernetes secrets.
|
||||
#
|
||||
# NOTE: Values containing $ must be quoted with single quotes + backslashes:
|
||||
# PASSWORD_HASH='$2b$12$...'
|
||||
|
||||
# Comma-separated list of additional allowed CORS origins.
|
||||
# Add your dev machine's IP here if accessing via IP address.
|
||||
# e.g. ALLOWED_ORIGINS=http://192.168.0.94:5173
|
||||
ALLOWED_ORIGINS=
|
||||
# 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=
|
||||
|
||||
# Comma-separated list of additional allowed origins for CSRF.
|
||||
# Add your dev machine's IP here if accessing via IP address.
|
||||
# e.g. ALLOWED_ORIGINS=http://192.168.0.94:5173
|
||||
ALLOWED_ORIGINS=
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"adminPath": "admin",
|
||||
"adminPath": "boss",
|
||||
"yoloLabel": "YOLO mode",
|
||||
"yoloDescription": "",
|
||||
"yoloVisibility": "auto"
|
||||
|
||||
@@ -1,9 +1,61 @@
|
||||
import { type Handle } from '@sveltejs/kit';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { type Handle, type Reroute } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
// ── Admin path rerouting ────────────────────────────────────────────────────
|
||||
|
||||
const CONFIG_PATH = path.resolve('data', 'config.json');
|
||||
|
||||
// Cache adminPath with a 5-second TTL so changes take effect quickly
|
||||
// but we don't hit disk on every request.
|
||||
let cachedAdminPath = 'admin';
|
||||
let cacheExpiry = 0;
|
||||
|
||||
function getAdminPath(): string {
|
||||
const now = Date.now();
|
||||
if (now < cacheExpiry) return cachedAdminPath;
|
||||
|
||||
try {
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
||||
cachedAdminPath = (config.adminPath ?? 'admin').replace(/^\/|\/$/g, '') || 'admin';
|
||||
} catch {
|
||||
// config.json missing or unreadable — keep current cached value
|
||||
}
|
||||
|
||||
cacheExpiry = now + 5000;
|
||||
return cachedAdminPath;
|
||||
}
|
||||
|
||||
export { getAdminPath as adminPath };
|
||||
|
||||
/**
|
||||
* Rewrites /<adminPath>/... to /admin/... before SvelteKit resolves the route.
|
||||
* When adminPath is not 'admin', direct access to /admin is rewritten to /404
|
||||
* so attackers cannot enumerate the admin URL.
|
||||
*/
|
||||
export const reroute: Reroute = ({ url }) => {
|
||||
const adminPath = getAdminPath();
|
||||
const prefix = `/${adminPath}`;
|
||||
|
||||
// Public path → internal /admin
|
||||
if (url.pathname === prefix || url.pathname.startsWith(prefix + '/')) {
|
||||
return '/admin' + url.pathname.slice(prefix.length);
|
||||
}
|
||||
|
||||
// Block direct /admin access when a custom path is configured
|
||||
if (adminPath !== 'admin') {
|
||||
if (url.pathname === '/admin' || url.pathname.startsWith('/admin/')) {
|
||||
return '/404';
|
||||
}
|
||||
}
|
||||
|
||||
return url.pathname;
|
||||
};
|
||||
|
||||
// ── CSRF origin allowlist ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* CSRF origin allowlist.
|
||||
*
|
||||
* Production: set ALLOWED_ORIGINS to a comma-separated list of trusted origins.
|
||||
* e.g. ALLOWED_ORIGINS=https://humorunits.com,https://dickloads.com
|
||||
*
|
||||
|
||||
37
src/hooks.ts
37
src/hooks.ts
@@ -1,37 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import type { Reroute } from '@sveltejs/kit';
|
||||
|
||||
// Read adminPath once at startup. Changes require a server restart.
|
||||
let adminPath = 'admin';
|
||||
try {
|
||||
const config = JSON.parse(fs.readFileSync(path.resolve('data', 'config.json'), 'utf8'));
|
||||
adminPath = (config.adminPath ?? 'admin').replace(/^\/|\/$/g, '') || 'admin';
|
||||
} catch {
|
||||
// config.json missing or unreadable — fall back to 'admin'
|
||||
}
|
||||
|
||||
export { adminPath };
|
||||
|
||||
/**
|
||||
* Rewrites /<adminPath>/... to /admin/... before SvelteKit resolves the route.
|
||||
* When adminPath is not 'admin', direct access to /admin is rewritten to /404
|
||||
* so attackers cannot enumerate the admin URL.
|
||||
*/
|
||||
export const reroute: Reroute = ({ url }) => {
|
||||
const prefix = `/${adminPath}`;
|
||||
|
||||
// Public path → internal /admin
|
||||
if (url.pathname === prefix || url.pathname.startsWith(prefix + '/')) {
|
||||
return '/admin' + url.pathname.slice(prefix.length);
|
||||
}
|
||||
|
||||
// Block direct /admin access when a custom path is configured
|
||||
if (adminPath !== 'admin') {
|
||||
if (url.pathname === '/admin' || url.pathname.startsWith('/admin/')) {
|
||||
return '/404';
|
||||
}
|
||||
}
|
||||
|
||||
return url.pathname;
|
||||
};
|
||||
@@ -15,8 +15,8 @@ const BRANDS: Record<string, Brand> = {
|
||||
footer: 'Humor Units — invented conversions for ridiculous people.',
|
||||
},
|
||||
'dickloads.com': {
|
||||
name: 'Dick Loads',
|
||||
footer: 'Dick Loads — serious units for serious people.',
|
||||
name: 'Dickloads',
|
||||
footer: 'Dickloads — serious units for serious people.',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@ import { requireAuth } from '$lib/server/auth';
|
||||
import type { Request } from '@sveltejs/kit';
|
||||
|
||||
export function authRequest(request: Request): boolean {
|
||||
return requireAuth(request, env.SESSION_SECRET ?? '');
|
||||
return requireAuth(request, (env.SESSION_SECRET ?? '').trim());
|
||||
}
|
||||
|
||||
@@ -31,10 +31,14 @@
|
||||
let highlightedUnitId = $state<string | null>(null);
|
||||
let yoloMode = $state<boolean>(false);
|
||||
|
||||
// Initialize fromUnitId to first unit once data arrives
|
||||
// Initialize fromUnitId to the base unit of the first visible group.
|
||||
// Falls back to units[0] if no groups or the base unit isn't in the visible list.
|
||||
$effect(() => {
|
||||
if (fromUnitId === '' && units.length > 0) {
|
||||
fromUnitId = units[0].id;
|
||||
const firstGroupBaseId = groups.length > 0 ? groups[0].baseUnitId : null;
|
||||
fromUnitId = (firstGroupBaseId && units.some((u) => u.id === firstGroupBaseId))
|
||||
? firstGroupBaseId
|
||||
: units[0].id;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -11,8 +11,12 @@ export const POST: RequestHandler = async ({ request, url }) => {
|
||||
return json({ error: 'Password required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const passwordHash = env.PASSWORD_HASH ?? '';
|
||||
const sessionSecret = env.SESSION_SECRET ?? '';
|
||||
const passwordHash = (env.PASSWORD_HASH ?? '').trim();
|
||||
const sessionSecret = (env.SESSION_SECRET ?? '').trim();
|
||||
|
||||
console.log('[login] passwordHash length:', passwordHash.length, 'first4:', passwordHash.slice(0, 4));
|
||||
console.log('[login] sessionSecret length:', sessionSecret.length);
|
||||
console.log('[login] password length:', password.length);
|
||||
|
||||
if (!passwordHash || !sessionSecret) {
|
||||
console.warn('[humor-units] WARNING: PASSWORD_HASH or SESSION_SECRET env var not set.');
|
||||
|
||||
Reference in New Issue
Block a user