server for auth, more goodies
This commit is contained in:
parent
f83bce8e2b
commit
7cebe39a14
7 changed files with 80 additions and 24 deletions
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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
53
server/config.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
livesRef.current = lives
|
useEffect(() => {
|
||||||
gameOverRef.current = gameOver
|
livesRef.current = lives
|
||||||
|
}, [lives])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
gameOverRef.current = gameOver
|
||||||
|
}, [gameOver])
|
||||||
|
|
||||||
const spawnWord = useCallback(() => {
|
const spawnWord = useCallback(() => {
|
||||||
const difficulty = difficultyRef.current
|
const difficulty = difficultyRef.current
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
onCompleteRef.current = onComplete
|
|
||||||
|
useEffect(() => {
|
||||||
|
onCompleteRef.current = onComplete
|
||||||
|
}, [onComplete])
|
||||||
|
|
||||||
const accuracy = totalKeystrokes > 0 ? Math.round((correctKeystrokes / totalKeystrokes) * 100) : 100
|
const accuracy = totalKeystrokes > 0 ? Math.round((correctKeystrokes / totalKeystrokes) * 100) : 100
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue