mirror of
https://github.com/SrIzan10/lofi.git
synced 2026-06-05 16:46:55 +00:00
feat: proxy chillhop requests and add turnstile to account creation
This commit is contained in:
@@ -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=""
|
||||
|
||||
5
bun.lock
5
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
58
src/routes/api/chstream/[fileId]/+server.ts
Normal file
58
src/routes/api/chstream/[fileId]/+server.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
</script>
|
||||
|
||||
<a href={resolve('/demo/better-auth')}>better-auth</a>
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user