Compare commits
10 Commits
d40b76e8f2
...
master
| 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.
|
# Copy this to .env and fill in values for local development.
|
||||||
# In production, set these as environment variables / Kubernetes secrets.
|
# 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.
|
# bcrypt hash of the admin password. Generate with:
|
||||||
# Add your dev machine's IP here if accessing via IP address.
|
|
||||||
# e.g. ALLOWED_ORIGINS=http://192.168.0.94:5173
|
|
||||||
ALLOWED_ORIGINS=
|
|
||||||
# python3 -c "import bcrypt; print(bcrypt.hashpw(b'yourpassword', bcrypt.gensalt(rounds=12)).decode())"
|
# python3 -c "import bcrypt; print(bcrypt.hashpw(b'yourpassword', bcrypt.gensalt(rounds=12)).decode())"
|
||||||
PASSWORD_HASH=
|
PASSWORD_HASH=
|
||||||
|
|
||||||
# Random secret for signing session tokens. Generate with:
|
# Random secret for signing session tokens. Generate with:
|
||||||
# openssl rand -hex 32
|
# openssl rand -hex 32
|
||||||
SESSION_SECRET=
|
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",
|
"yoloLabel": "YOLO mode",
|
||||||
"yoloDescription": "",
|
"yoloDescription": "",
|
||||||
"yoloVisibility": "auto"
|
"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';
|
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.
|
* Production: set ALLOWED_ORIGINS to a comma-separated list of trusted origins.
|
||||||
* e.g. ALLOWED_ORIGINS=https://humorunits.com,https://dickloads.com
|
* 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.',
|
footer: 'Humor Units — invented conversions for ridiculous people.',
|
||||||
},
|
},
|
||||||
'dickloads.com': {
|
'dickloads.com': {
|
||||||
name: 'Dick Loads',
|
name: 'Dickloads',
|
||||||
footer: 'Dick Loads — serious units for serious people.',
|
footer: 'Dickloads — serious units for serious people.',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ import { requireAuth } from '$lib/server/auth';
|
|||||||
import type { Request } from '@sveltejs/kit';
|
import type { Request } from '@sveltejs/kit';
|
||||||
|
|
||||||
export function authRequest(request: Request): boolean {
|
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 highlightedUnitId = $state<string | null>(null);
|
||||||
let yoloMode = $state<boolean>(false);
|
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(() => {
|
$effect(() => {
|
||||||
if (fromUnitId === '' && units.length > 0) {
|
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 });
|
return json({ error: 'Password required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = env.PASSWORD_HASH ?? '';
|
const passwordHash = (env.PASSWORD_HASH ?? '').trim();
|
||||||
const sessionSecret = env.SESSION_SECRET ?? '';
|
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) {
|
if (!passwordHash || !sessionSecret) {
|
||||||
console.warn('[humor-units] WARNING: PASSWORD_HASH or SESSION_SECRET env var not set.');
|
console.warn('[humor-units] WARNING: PASSWORD_HASH or SESSION_SECRET env var not set.');
|
||||||
|
|||||||
Reference in New Issue
Block a user