feat: proxy chillhop requests and add turnstile to account creation

This commit is contained in:
2026-04-04 23:45:55 +02:00
parent 32a65fa642
commit 425f95af23
13 changed files with 181 additions and 169 deletions

View File

@@ -9,3 +9,7 @@ ORIGIN=""
# For production use 32 characters and generated with high entropy
# https://www.better-auth.com/docs/installation
BETTER_AUTH_SECRET=""
# Cloudflare Turnstile
PUBLIC_TURNSTILE_SITE_KEY=""
TURNSTILE_SECRET_KEY=""

View File

@@ -25,6 +25,7 @@
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-sonner": "^0.3.28",
"svelte-turnstile": "^0.11.0",
"tailwind-merge": "^3.2.0",
"tailwind-variants": "^1.0.0",
"tailwindcss": "^3.4.17",
@@ -810,6 +811,8 @@
"svelte-toolbelt": ["svelte-toolbelt@0.7.1", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.23.2", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ=="],
"svelte-turnstile": ["svelte-turnstile@0.11.0", "", { "dependencies": { "turnstile-types": "^1.2.3" }, "peerDependencies": { "svelte": "^3.58.0 || ^4.0.0 || ^5.0.0" } }, "sha512-2LFklx9JVsR3fJ7e3fGG1HEAWWEqRq1WfNaVrKgZJ+pzfY2NColiH+wH0kK2yX3DrcGLiJ9vBeTyiLFWotKpLA=="],
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
"tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
@@ -844,6 +847,8 @@
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
"turnstile-types": ["turnstile-types@1.2.3", "", {}, "sha512-EDjhDB9TDwda2JRbhzO/kButPio3JgrC3gXMVAMotxldybTCJQVMvPNJ89rcAiN9vIrCb2i1E+VNBCqB8wue0A=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="],

View File

@@ -44,6 +44,7 @@
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-sonner": "^0.3.28",
"svelte-turnstile": "^0.11.0",
"tailwind-merge": "^3.2.0",
"tailwind-variants": "^1.0.0",
"tailwindcss": "^3.4.17",

View File

@@ -7,11 +7,12 @@ import type { auth } from '$lib/server/auth';
const accountNumberClient = {
id: 'account-number',
getActions: ($fetch) => ({
createAccount: async (name?: string) =>
createAccount: async (name?: string, turnstileToken?: string) =>
$fetch('/create-account', {
method: 'POST',
body: {
name,
turnstileToken,
},
}),
signInAccountNumber: async (accountNumber: string) =>

View File

@@ -13,6 +13,9 @@
import Plus from '@lucide/svelte/icons/plus';
import Loader2 from '@lucide/svelte/icons/loader-2';
import type { Passkey } from '@better-auth/passkey';
import { Turnstile } from 'svelte-turnstile';
import { dev } from '$app/environment';
import { env as publicEnv } from '$env/dynamic/public';
const session = authClient.useSession();
@@ -27,6 +30,11 @@
let loadedPasskeysForUserId = $state<string | null>(null);
let authScreen = $state<'login' | 'create'>('login');
let passkeyName = $state('');
let turnstileToken = $state('');
let resetTurnstile = $state<(() => void) | undefined>();
const turnstileSiteKey = $derived(
dev ? '1x00000000000000000000AA' : (publicEnv.PUBLIC_TURNSTILE_SITE_KEY ?? '')
);
const loadPasskeys = async () => {
if (!user) {
@@ -84,18 +92,51 @@
}
);
const createAccount = () =>
runAuthAction(
const clearTurnstile = () => {
turnstileToken = '';
resetTurnstile?.();
};
const handleTurnstileCallback = (
event: CustomEvent<{ token: string; preClearanceObtained: boolean }>
) => {
turnstileToken = event.detail.token;
authMessage = '';
};
const handleTurnstileError = () => {
clearTurnstile();
authMessage = 'Turnstile verification failed. Please try again.';
};
const handleTurnstileExpired = () => {
clearTurnstile();
authMessage = 'Turnstile check expired. Please try again.';
};
const createAccount = async () => {
if (!turnstileToken) {
authMessage = 'Please complete the Turnstile check before creating an account.';
return;
}
await runAuthAction(
'create-account',
() => authClient.createAccount(name),
() => authClient.createAccount(name, turnstileToken),
'Account creation failed',
async () => {
await session.get().refetch();
name = '';
authScreen = 'login';
clearTurnstile();
}
);
if (busyAction !== 'create-account') {
clearTurnstile();
}
};
const signInWithPasskey = () =>
runAuthAction(
'passkey-sign-in',
@@ -382,10 +423,18 @@
/>
</div>
<Turnstile
siteKey={turnstileSiteKey}
bind:reset={resetTurnstile}
on:callback={handleTurnstileCallback}
on:error={handleTurnstileError}
on:expired={handleTurnstileExpired}
/>
<Button
type="button"
onclick={createAccount}
disabled={busyAction === 'create-account' || !name}
disabled={busyAction === 'create-account' || !name || !turnstileToken}
class="w-full disabled:opacity-50"
>
{#if busyAction === 'create-account'}

View File

@@ -21,6 +21,58 @@ const getAnonymousDisplayName = (name?: string | null) => {
return trimmedName ? trimmedName : 'Chillhop listener';
};
type TurnstileVerifyResult = {
success: boolean;
hostname?: string;
['error-codes']?: string[];
};
const getClientIpAddress = () => {
const headers = getRequestEvent().request.headers;
return headers.get('CF-Connecting-IP') ?? headers.get('X-Forwarded-For') ?? undefined;
};
const verifyTurnstileToken = async (token: string) => {
if (!env.TURNSTILE_SECRET_KEY) {
throw new APIError('INTERNAL_SERVER_ERROR', {
message: 'Turnstile secret key is not configured',
});
}
const verificationBody = new FormData();
verificationBody.set('secret', env.TURNSTILE_SECRET_KEY);
verificationBody.set('response', token);
const remoteIp = getClientIpAddress();
if (remoteIp) {
verificationBody.set('remoteip', remoteIp);
}
verificationBody.set('idempotency_key', crypto.randomUUID());
const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: verificationBody,
});
if (!response.ok) {
throw new APIError('BAD_REQUEST', {
message: 'Turnstile verification failed. Please try again.',
});
}
const result = (await response.json()) as TurnstileVerifyResult;
if (!result.success) {
throw new APIError('BAD_REQUEST', {
message:
result['error-codes']?.includes('timeout-or-duplicate')
? 'Turnstile check expired. Please try again.'
: 'Please complete the Turnstile check before creating an account.',
});
}
};
const createAnonymousSession = async (ctx: any, name?: string | null) => {
const user = await ctx.context.internalAdapter.createUser({
email: generateOpaqueIdentifier(),
@@ -65,9 +117,13 @@ const accountNumber = () =>
method: 'POST',
body: z.object({
name: z.string().trim().max(100).optional(),
turnstileToken: z.string().min(1),
}),
},
async (ctx) => ctx.json(await createAnonymousSession(ctx, ctx.body.name)),
async (ctx) => {
await verifyTurnstileToken(ctx.body.turnstileToken);
return ctx.json(await createAnonymousSession(ctx, ctx.body.name));
},
),
signInAccountNumber: createAuthEndpoint(
'/sign-in/account-number',

View File

@@ -7,7 +7,7 @@ export async function getChillhopStation(id: number): Promise<Song[]> {
const finalData = data.map(song => ({
artists: song.artists,
title: song.title,
endpoint: `https://stream.chillhop.com/mp3/${song.fileId}`,
endpoint: `/api/chstream/${song.fileId}`,
image: song.image,
label: 'Chillhop Music',
spotifyId: song.spotifyId,

View File

@@ -0,0 +1,58 @@
// overengineered proxy brought to you by codex.
import type { RequestHandler } from '@sveltejs/kit';
const FILE_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
function isValidFileId(fileId: string) {
return FILE_ID_PATTERN.test(fileId);
}
export const GET: RequestHandler = async ({ params, request, fetch }) => {
const { fileId } = params;
if (!fileId || !isValidFileId(fileId)) {
return new Response('Invalid file ID', { status: 400 });
}
const upstreamHeaders = new Headers();
const range = request.headers.get('range');
if (range) {
upstreamHeaders.set('range', range);
}
const upstreamResponse = await fetch(`https://stream.chillhop.com/mp3/${fileId}`, {
headers: upstreamHeaders,
});
if (!upstreamResponse.ok && upstreamResponse.status !== 206) {
return new Response('File not found', { status: upstreamResponse.status === 404 ? 404 : 502 });
}
const responseHeaders = new Headers();
const headersToForward = [
'content-type',
'content-length',
'content-range',
'accept-ranges',
'etag',
'last-modified',
'cache-control',
];
for (const header of headersToForward) {
const value = upstreamResponse.headers.get(header);
if (value) {
responseHeaders.set(header, value);
}
}
responseHeaders.set('Content-Disposition', `inline; filename="${fileId}.mp3"`);
return new Response(upstreamResponse.body, {
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
headers: responseHeaders,
});
};

View File

@@ -1,5 +0,0 @@
<script lang="ts">
import { resolve } from '$app/paths';
</script>
<a href={resolve('/demo/better-auth')}>better-auth</a>

View File

@@ -1,21 +0,0 @@
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = (event) => {
if (!event.locals.user) {
return redirect(302, '/demo/better-auth/login');
}
return { user: event.locals.user };
};
export const actions: Actions = {
signOut: async (event) => {
const { auth } = event.locals;
await auth.api.signOut({
headers: event.request.headers,
});
return redirect(302, '/demo/better-auth/login');
},
};

View File

@@ -1,35 +0,0 @@
<script lang="ts">
import { authClient } from '$lib';
import { enhance } from '$app/forms';
import type { PageServerData } from './$types';
let { data }: { data: PageServerData } = $props();
let passkeyMessage = $state('');
let addingPasskey = $state(false);
const addPasskey = async () => {
addingPasskey = true;
passkeyMessage = '';
const result = await authClient.passkey.addPasskey({
name: 'Primary passkey',
authenticatorAttachment: 'platform',
});
addingPasskey = false;
passkeyMessage = result.error
? result.error.message || 'Failed to add passkey'
: 'Passkey added to your account.';
};
</script>
<h1>Hi, {data.user.name}!</h1>
<p>Your user ID is {data.user.id}.</p>
<p>Your account number is {data.user.accountNumber}.</p>
<button onclick={addPasskey} disabled={addingPasskey}>
{addingPasskey ? 'Waiting for passkey...' : 'Add a passkey'}
</button>
<p>{passkeyMessage}</p>
<form method="post" action="?/signOut" use:enhance>
<button>Sign out</button>
</form>

View File

@@ -1,58 +0,0 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { APIError } from 'better-auth';
const getErrorMessage = (error: unknown, fallback: string) => {
if (error instanceof APIError) return error.message || fallback;
if (error instanceof Error) return error.message || fallback;
if (typeof error === 'object' && error && 'message' in error && typeof error.message === 'string') {
return error.message || fallback;
}
return fallback;
};
export const load: PageServerLoad = (event) => {
if (event.locals.user) {
return redirect(302, '/demo/better-auth');
}
return {};
};
export const actions: Actions = {
signInAccountNumber: async (event) => {
const { auth } = event.locals;
const formData = await event.request.formData();
const accountNumber = formData.get('accountNumber')?.toString().replace(/\D/g, '') ?? '';
try {
await auth.api.signInAccountNumber({
body: {
accountNumber,
},
});
} catch (error) {
console.error('Demo Better Auth account number sign-in failed', { accountNumber, error });
return fail(error instanceof APIError ? 400 : 500, {
message: getErrorMessage(error, 'Account number sign-in failed'),
});
}
return redirect(302, '/demo/better-auth');
},
createAccount: async (event) => {
const { auth } = event.locals;
try {
await auth.api.signInAnonymous();
} catch (error) {
console.error('Demo Better Auth account creation failed', { error });
return fail(error instanceof APIError ? 400 : 500, {
message: getErrorMessage(error, 'Account creation failed'),
});
}
return redirect(302, '/demo/better-auth');
},
};

View File

@@ -1,43 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { enhance } from '$app/forms';
import { authClient } from '$lib';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
let passkeyError = $state('');
let signingInWithPasskey = $state(false);
const signInWithPasskey = async () => {
signingInWithPasskey = true;
passkeyError = '';
const result = await authClient.signIn.passkey({
autoFill: true,
});
signingInWithPasskey = false;
if (result.error) {
passkeyError = result.error.message || 'Passkey sign-in failed';
return;
}
await goto('/demo/better-auth');
};
</script>
<h1>Account Login</h1>
<form method="post" action="?/signInAccountNumber" use:enhance>
<label>
Account number
<input name="accountNumber" inputmode="numeric" maxlength="16" autocomplete="one-time-code" />
</label>
<button>Sign in with account number</button>
<button formaction="?/createAccount">Create account number</button>
</form>
<p style="color: red">{form?.message ?? ''}</p>
<button onclick={signInWithPasskey} disabled={signingInWithPasskey}>
{signingInWithPasskey ? 'Waiting for passkey...' : 'Sign in with passkey'}
</button>
<p style="color: red">{passkeyError}</p>