server for auth, more goodies

This commit is contained in:
polwex 2026-04-19 00:54:39 +07:00
parent f83bce8e2b
commit 7cebe39a14
7 changed files with 80 additions and 24 deletions

View file

@ -1,4 +1,5 @@
import { serve } from "bun" import { serve } from "bun"
import { runtimeConfig } from "./server/config"
import { initDb } from "./server/db" import { initDb } from "./server/db"
import { import {
registerOptions, registerOptions,
@ -13,8 +14,10 @@ import index from "./index.html"
await initDb() await initDb()
const server = serve({ const server = serve({
port: 5174, hostname: runtimeConfig.host,
port: runtimeConfig.port,
routes: { routes: {
"/healthz": () => Response.json({ ok: true }),
"/*": index, "/*": index,
"/api/auth/register/options": { POST: registerOptions }, "/api/auth/register/options": { POST: registerOptions },

View file

@ -4,6 +4,7 @@ import {
generateAuthenticationOptions, generateAuthenticationOptions,
verifyAuthenticationResponse, verifyAuthenticationResponse,
} from "@simplewebauthn/server"; } from "@simplewebauthn/server";
import { runtimeConfig, resolveOrigin, resolveRpId } from "./config";
import { isoBase64URL } from "@simplewebauthn/server/helpers"; import { isoBase64URL } from "@simplewebauthn/server/helpers";
import type { import type {
RegistrationResponseJSON, RegistrationResponseJSON,
@ -37,16 +38,6 @@ setInterval(() => {
} }
}, 60_000); }, 60_000);
function getRpId(req: Request): string {
const url = new URL(req.url);
return url.hostname;
}
function getOrigin(req: Request): string {
const url = new URL(req.url);
return url.origin;
}
function getCookie(req: Request, name: string): string | undefined { function getCookie(req: Request, name: string): string | undefined {
const cookies = req.headers.get("cookie"); const cookies = req.headers.get("cookie");
if (!cookies) return undefined; if (!cookies) return undefined;
@ -55,11 +46,11 @@ function getCookie(req: Request, name: string): string | undefined {
} }
function sessionCookie(token: string, maxAge = 7 * 24 * 60 * 60): string { function sessionCookie(token: string, maxAge = 7 * 24 * 60 * 60): string {
return `session=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}`; return `session=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}${runtimeConfig.sessionCookieSecure ? "; Secure" : ""}`;
} }
function challengeCookie(key: string): string { function challengeCookie(key: string): string {
return `challenge_key=${key}; HttpOnly; SameSite=Strict; Path=/; Max-Age=120`; return `challenge_key=${key}; HttpOnly; SameSite=Strict; Path=/; Max-Age=120${runtimeConfig.sessionCookieSecure ? "; Secure" : ""}`;
} }
function clearChallengeCookie(): string { function clearChallengeCookie(): string {
@ -86,7 +77,7 @@ export async function registerOptions(req: Request): Promise<Response> {
// Create user // Create user
const user = await createUser(username); const user = await createUser(username);
const rpID = getRpId(req); const rpID = resolveRpId(req);
const options = await generateRegistrationOptions({ const options = await generateRegistrationOptions({
rpName: RP_NAME, rpName: RP_NAME,
rpID, rpID,
@ -131,8 +122,8 @@ export async function registerVerify(req: Request): Promise<Response> {
} }
challenges.delete(challengeKey); challenges.delete(challengeKey);
const rpID = getRpId(req); const rpID = resolveRpId(req);
const origin = getOrigin(req); const origin = resolveOrigin(req);
let verification; let verification;
try { try {
@ -178,7 +169,7 @@ export async function registerVerify(req: Request): Promise<Response> {
} }
export async function loginOptions(req: Request): Promise<Response> { export async function loginOptions(req: Request): Promise<Response> {
const rpID = getRpId(req); const rpID = resolveRpId(req);
const options = await generateAuthenticationOptions({ const options = await generateAuthenticationOptions({
rpID, rpID,
@ -221,8 +212,8 @@ export async function loginVerify(req: Request): Promise<Response> {
return Response.json({ error: "Unknown credential" }, { status: 400 }); return Response.json({ error: "Unknown credential" }, { status: 400 });
} }
const rpID = getRpId(req); const rpID = resolveRpId(req);
const origin = getOrigin(req); const origin = resolveOrigin(req);
let verification; let verification;
try { try {

53
server/config.ts Normal file
View file

@ -0,0 +1,53 @@
const DEFAULT_PORT = 5174
const DEFAULT_SQLITE_PATH = './leo-typing.db'
function parsePort(value: string | undefined): number {
if (!value) return DEFAULT_PORT
const port = Number.parseInt(value, 10)
if (Number.isNaN(port) || port < 1 || port > 65535) {
throw new Error(`Invalid PORT value: ${value}`)
}
return port
}
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
if (!value) return fallback
switch (value.toLowerCase()) {
case '1':
case 'true':
case 'yes':
case 'on':
return true
case '0':
case 'false':
case 'no':
case 'off':
return false
default:
throw new Error(`Invalid boolean value: ${value}`)
}
}
const appOrigin = process.env.APP_ORIGIN ? new URL(process.env.APP_ORIGIN) : null
export const runtimeConfig = {
host: process.env.HOST || '127.0.0.1',
port: parsePort(process.env.PORT),
sqlitePath: process.env.SQLITE_PATH || DEFAULT_SQLITE_PATH,
appOrigin,
sessionCookieSecure: parseBoolean(
process.env.SESSION_COOKIE_SECURE,
appOrigin?.protocol === 'https:',
),
}
export function resolveOrigin(req: Request): string {
return runtimeConfig.appOrigin?.origin || new URL(req.url).origin
}
export function resolveRpId(req: Request): string {
return runtimeConfig.appOrigin?.hostname || new URL(req.url).hostname
}

View file

@ -1,8 +1,9 @@
import { SQL } from "bun" import { SQL } from "bun"
import { runtimeConfig } from "./config"
const db = new SQL({ const db = new SQL({
adapter: "sqlite", adapter: "sqlite",
filename: "./leo-typing.db", filename: runtimeConfig.sqlitePath,
create: true, create: true,
strict: true, strict: true,
}) })

View file

@ -89,8 +89,13 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
const livesRef = useRef(5) const livesRef = useRef(5)
const gameOverRef = useRef(false) const gameOverRef = useRef(false)
useEffect(() => {
livesRef.current = lives livesRef.current = lives
}, [lives])
useEffect(() => {
gameOverRef.current = gameOver gameOverRef.current = gameOver
}, [gameOver])
const spawnWord = useCallback(() => { const spawnWord = useCallback(() => {
const difficulty = difficultyRef.current const difficulty = difficultyRef.current

View file

@ -811,7 +811,7 @@ export function MissileGame({ highScore, onGameOver }: Props) {
} }
} }
// Wrong key: do nothing (no penalty for wrong chars, just no progress) // Wrong key: do nothing (no penalty for wrong chars, just no progress)
}, [started, gameOver, startGame, triggerShake]) }, [started, gameOver, startGame])
return ( return (
<div className="fade-in"> <div className="fade-in">

View file

@ -26,7 +26,10 @@ export function useTypingEngine(
const startTimeRef = useRef<number | null>(null) const startTimeRef = useRef<number | null>(null)
const [wpm, setWpm] = useState(0) const [wpm, setWpm] = useState(0)
const onCompleteRef = useRef(onComplete) const onCompleteRef = useRef(onComplete)
useEffect(() => {
onCompleteRef.current = onComplete onCompleteRef.current = onComplete
}, [onComplete])
const accuracy = totalKeystrokes > 0 ? Math.round((correctKeystrokes / totalKeystrokes) * 100) : 100 const accuracy = totalKeystrokes > 0 ? Math.round((correctKeystrokes / totalKeystrokes) * 100) : 100