From a04b54ce996bd460c51e40a624a5f623244263e5 Mon Sep 17 00:00:00 2001 From: Niki Wix Skaarup <niki@skaarup.dev> Date: Wed, 2 Apr 2025 01:56:28 +0200 Subject: [PATCH] table done and finished shitty auth --- src/components/input-email.svelte | 33 ++++++++ src/components/input-password.svelte | 32 ++++++++ src/components/login.svelte | 48 +++++++++-- src/components/main.svelte | 2 +- src/components/progress-table.svelte | 100 +++++++++++++++++++++++ src/index.css | 6 ++ src/index.svelte | 6 +- src/index.ts | 1 - src/server.ts | 102 +++++++++++++++++++++++ src/server/pt-api.ts | 118 +++++++++++++++++++++++++++ src/shared.svelte.ts | 28 +++++++ src/util.ts | 47 +++++++++++ 12 files changed, 513 insertions(+), 10 deletions(-) create mode 100644 src/components/input-email.svelte create mode 100644 src/components/input-password.svelte create mode 100644 src/components/progress-table.svelte create mode 100644 src/server/pt-api.ts create mode 100644 src/shared.svelte.ts create mode 100644 src/util.ts diff --git a/src/components/input-email.svelte b/src/components/input-email.svelte new file mode 100644 index 0000000..a412661 --- /dev/null +++ b/src/components/input-email.svelte @@ -0,0 +1,33 @@ +<script lang="ts"> + import type { ClassValue } from 'svelte/elements'; + + const { + class: className = undefined, + id, + name, + label = 'Email', + required = false, + }: { + class?: ClassValue; + id: string; + name: string; + required?: boolean; + label?: string; + } = $props(); +</script> + +<div class="relative {className ?? ''}"> + <input + {id} + type="text" + {name} + class="peer w-full min-w-0 rounded bg-gray-200 px-2 py-1.5 text-black" + inputmode="email" + placeholder="" + {required} + /> + <label + class="pointer-events-none absolute -top-6 left-0 text-sm transition-[translate,color,font-size] peer-placeholder-shown:translate-x-2 peer-placeholder-shown:translate-y-7.5 peer-placeholder-shown:text-base peer-placeholder-shown:text-black/50 peer-focus:translate-x-0 peer-focus:translate-y-0 peer-focus:text-sm peer-focus:text-white" + for={id}>{label}</label + > +</div> diff --git a/src/components/input-password.svelte b/src/components/input-password.svelte new file mode 100644 index 0000000..98352d3 --- /dev/null +++ b/src/components/input-password.svelte @@ -0,0 +1,32 @@ +<script lang="ts"> + import type { ClassValue } from 'svelte/elements'; + + const { + class: className = undefined, + id, + name, + label = 'Password', + required = false, + }: { + class?: ClassValue; + id: string; + name: string; + required?: boolean; + label?: string; + } = $props(); +</script> + +<div class="relative {className ?? ''}"> + <input + {id} + type="password" + {name} + class="peer w-full min-w-0 rounded bg-gray-200 px-2 py-1.5 text-black" + placeholder="" + {required} + /> + <label + class="pointer-events-none absolute -top-6 left-0 text-sm transition-[translate,color,font-size] peer-placeholder-shown:translate-x-2 peer-placeholder-shown:translate-y-7.5 peer-placeholder-shown:text-base peer-placeholder-shown:text-black/50 peer-focus:translate-x-0 peer-focus:translate-y-0 peer-focus:text-sm peer-focus:text-white" + for={id}>{label}</label + > +</div> diff --git a/src/components/login.svelte b/src/components/login.svelte index b1e9bed..5c171a7 100644 --- a/src/components/login.svelte +++ b/src/components/login.svelte @@ -1,10 +1,46 @@ <script> - function login() { - globalThis.user = { name: 'John Doe' }; - console.log('User logged in:', globalThis.user); + import { userstate } from '../shared.svelte'; + import InputEmail from './input-email.svelte'; + import InputPassword from './input-password.svelte'; + + async function handleSubmit(event) { + event.preventDefault(); + const form = event.target; + + const formData = new FormData(form); + const data = Object.fromEntries(formData.entries()); + + try { + const response = await fetch('/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + credentials: 'same-origin', + }); + + if (response.ok) { + // window.location.reload(); + + userstate.checkIsLoggedIn(); + } else { + form.reset(); + } + } catch (error) { + console.error('Error:', error); + } } </script> -<button onclick={login}> Login </button> - -<button type="button" onclick={(e) => console.log(e)}> login2 </button> +<div class="col-[content] flex grow items-center justify-center"> + <form onsubmit={handleSubmit} class="flex w-full max-w-xs flex-col gap-6 px-2"> + <InputEmail label="Email" id="login-email" name="email" required></InputEmail> + <InputPassword class="mt-2" label="Password" id="login-password" name="password" required + ></InputPassword> + <div> + <button + class="flex cursor-pointer items-center justify-center rounded bg-pink-700 px-2.5 py-1.5 text-white transition-[background-color] hover:bg-pink-600 active:outline-1 active:outline-white" + type="submit">Login</button + > + </div> + </form> +</div> diff --git a/src/components/main.svelte b/src/components/main.svelte index 237d15f..bc64388 100644 --- a/src/components/main.svelte +++ b/src/components/main.svelte @@ -4,6 +4,6 @@ const { children }: { children?: Snippet } = $props(); </script> -<main id="main-content"> +<main id="main-content" class="content grid min-h-(--main-min-height) pt-4 pb-20"> {@render children?.()} </main> diff --git a/src/components/progress-table.svelte b/src/components/progress-table.svelte new file mode 100644 index 0000000..618f2d2 --- /dev/null +++ b/src/components/progress-table.svelte @@ -0,0 +1,100 @@ +<script lang="ts"> + import { fetchEntries, formatter } from '../util'; + + let entriesPromise = $state(fetchEntries()); + + let interval: Parameters<typeof clearInterval>[0] = undefined; + $effect(() => { + clearInterval(interval); + + // Pooling interval to fetch entries every 31 seconds + interval = setInterval(async () => { + const promise = fetchEntries(); + // wait for the promise to resolve before assigning to prevent flash + await promise; + entriesPromise = promise; + }, 1000 * 31); + + return () => clearInterval(interval); + }); +</script> + +<div class="col-[content]"> + {#await entriesPromise} + <p class="text-center">Loading entries...</p> + {:then entries} + {#if entries.length === 0} + <p class="text-center">No entries.</p> + {:else} + {@render table(entries)} + {/if} + {:catch error} + <p class="mx-auto max-w-md text-center"> + Something went wrong fetching entries: {error.message} + </p> + {/await} +</div> + +{#snippet table(entries: Awaited<ReturnType<typeof fetchEntries>>)} + <div + class="@container grid w-full grid-cols-[auto,1fr,auto,auto,auto] gap-1 font-light @lg:gap-3" + > + <div + class="sticky top-(--header-height) col-span-5 grid grid-cols-[subgrid] gap-1 bg-gray-950 py-2 text-gray-400 capitalize" + > + <div>id</div> + <div class="px-1">name</div> + <div class="hidden px-1 @3xl:block">done</div> + <div class="hidden px-1 @5xl:block">created</div> + <div class="hidden px-1 @xl:block">updated</div> + </div> + {#each entries as entry (entry.id)} + <div class="col-span-5 grid grid-cols-[subgrid] gap-1"> + <div class="py-2 text-xs text-gray-400/50">{entry.id}</div> + <a + href={entry.href} + class="block px-2 py-1 text-gray-200 capitalize transition-colors selection:bg-pink-600 selection:text-gray-200 hover:text-pink-600 hover:underline" + target="_blank" + referrerpolicy="no-referrer">{entry.name}</a + > + {@render done(entry.finished)} + <div class="hidden max-w-24 px-1 py-1 text-xs text-gray-200 @5xl:block"> + {formatter.format(entry.created_at)} + </div> + <div class="hidden max-w-24 px-1 py-1 text-xs text-gray-200 @xl:block"> + {formatter.format(entry.updated_at)} + </div> + </div> + {/each} + </div> +{/snippet} + +{#snippet done(finished: boolean)} + {#if finished} + <svg + class="hidden text-green-500 @3xl:block" + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></svg + > + {:else} + <svg + class="hidden text-red-500 @3xl:block" + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg + > + {/if} +{/snippet} diff --git a/src/index.css b/src/index.css index 5c13b75..1c0ea34 100644 --- a/src/index.css +++ b/src/index.css @@ -1,4 +1,5 @@ @import 'tailwindcss' source('.'); +@source './components/main.svelte'; @theme { --breakout-size: calc((var(--breakpoint-xl) - var(--breakpoint-lg)) / 2); @@ -7,6 +8,11 @@ --content-val: min(100% - calc(var(--spacing) * 8), var(--breakpoint-lg)); } +:root { + --header-height: calc(var(--spacing) * 16); + --main-min-height: calc(100dvh - var(--header-height)); +} + @layer base { * { min-width: 0; diff --git a/src/index.svelte b/src/index.svelte index 84e0bf2..75842cd 100644 --- a/src/index.svelte +++ b/src/index.svelte @@ -3,13 +3,15 @@ import Header from './components/header.svelte'; import Main from './components/main.svelte'; import Login from './components/login.svelte'; + import ProgressTable from './components/progress-table.svelte'; + import { userstate } from './shared.svelte'; </script> <Header></Header> <Main> - {#if !globalThis.user} + {#if !userstate.isLoggedIn} <Login></Login> {:else} - {globalThis.user.name} + <ProgressTable></ProgressTable> {/if} </Main> diff --git a/src/index.ts b/src/index.ts index 6cc2355..3a852b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,6 @@ import './index.css'; declare global { var didMount: boolean | undefined; - var user: { name: string } | undefined; } let app: Record<string, any> | undefined; diff --git a/src/server.ts b/src/server.ts index 5e78869..7b235d5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,6 +6,18 @@ import sitemapTxt from './static/sitemap.txt'; import icon from './static/favicon.png' with { type: 'file' }; const favicon = await Bun.file(icon).bytes(); const development = env.NODE_ENV !== 'production'; +import { randomUUIDv7 } from 'bun'; +import ptApi from './server/pt-api'; + +declare global { + var loginTokens: Set<string>; +} + +if (!globalThis.loginTokens) { + globalThis.loginTokens = new Set<string>(); +} + +const authCookie = 'pt-auth'; Bun.serve({ routes: { @@ -20,9 +32,99 @@ Bun.serve({ headers: { 'Content-Type': 'image/png' }, }), '/health': new Response('OK'), + '/api/entries': { + async GET(req) { + if (!isLoggedIn(req)) return unauthorizedResp(); + + const data = await ptApi.query(); + return new Response(JSON.stringify(data), { + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=30' }, + }); + }, + }, + '/auth/logout': { + async POST() { + return new Response('Logout successful', { headers: logoutHeaders() }); + }, + }, + '/auth/login': { + async POST(req) { + const data = await req.json(); + const email = data.email; + const password = data.password; + + if ( + typeof email !== 'string' || + typeof password !== 'string' || + email.length < 3 || + password.length === 0 + ) { + return new Response('Missing email or password', { status: 400 }); + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + + if (email === env.EMAIL && password === env.PASSWORD) { + let token = randomUUIDv7('base64url'); + while (globalThis.loginTokens.has(token)) { + // generate a new token if it already exists + // this is unlikely to happen, but just in case + token = randomUUIDv7(); + } + + const cookie = new Bun.Cookie({ + name: authCookie, + value: token, + path: '/', + maxAge: 60 * 60 * 24 * 31, + secure: true, + sameSite: 'strict', + }); + + const headers = new Headers({ 'Set-Cookie': cookie.toString() }); + + globalThis.loginTokens.add(token); + + return new Response('Login successful', { headers }); + } + + await new Promise((resolve) => setTimeout(resolve, 900)); + + return new Response('Incorrect email or password', { + status: 400, + }); + }, + }, }, development, // async fetch(req, server) { // return new Response("Not found", { status: 404 }); // }, }); + +function isLoggedIn(req: Request) { + const cookie = new Bun.CookieMap(req.headers.get('cookie') || ''); + const token = cookie.get(authCookie); + + if (!token) { + return false; + } + + return globalThis.loginTokens.has(token); +} + +function logoutHeaders() { + return new Headers({ + 'Set-Cookie': new Bun.Cookie({ + name: authCookie, + path: '/', + maxAge: -1, + secure: true, + sameSite: 'strict', + }).toString(), + }); +} + +function unauthorizedResp() { + return new Response('Unauthorized', { status: 401, headers: logoutHeaders() }); +} diff --git a/src/server/pt-api.ts b/src/server/pt-api.ts new file mode 100644 index 0000000..7e34c4e --- /dev/null +++ b/src/server/pt-api.ts @@ -0,0 +1,118 @@ +import { env } from 'bun'; + +type SelectBookmark = { + id: number; + name: string; + href: string; + finished: boolean; + created_at: number; + updated_at: number; +}; + +const baseUrl = env.API_SERVICE_URL; +const bookmarksUrl = `${baseUrl}/bookmarks`; + +const noBodyHeaders = new Headers({ + Authorization: `Bearer ${env.BEARER_TOKEN}`, +}); + +const bodyHeaders = new Headers({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${env.BEARER_TOKEN}`, +}); + +async function query(): Promise<Array<SelectBookmark>> { + try { + const response = await fetch(bookmarksUrl, { + method: 'GET', + signal: AbortSignal.timeout(50000), + headers: noBodyHeaders, + }); + + if (!response.ok) { + throw new Error('Failed to fetch bookmarks'); + } + + const data = await response.json(); + if (!Array.isArray(data)) { + throw new Error('Invalid bookmarks data'); + } + + return data; + } catch (err) { + console.error(err); + throw new Error('Failed to fetch bookmarks'); + } +} + +async function remove(id: number) { + try { + const response = await fetch(`${bookmarksUrl}/${id}`, { + method: 'DELETE', + signal: AbortSignal.timeout(5000), + headers: noBodyHeaders, + }); + + if (!response.ok) { + throw new Error('Failed to remove bookmark'); + } + } catch (err) { + console.error(err); + throw new Error('Failed to remove bookmark'); + } +} + +async function update(id: number, body: { name: string; href: string }) { + try { + const response = await fetch(`${bookmarksUrl}/${id}`, { + method: 'PUT', + body: JSON.stringify(body), + signal: AbortSignal.timeout(5000), + headers: bodyHeaders, + }); + + if (!response.ok) { + throw new Error('Failed to update bookmark'); + } + } catch (err) { + console.error(err); + throw new Error('Failed to update bookmark'); + } +} + +async function check(id: number, finished: boolean) { + try { + const response = await fetch(`${bookmarksUrl}/${id}/check/${finished}`, { + method: 'PUT', + signal: AbortSignal.timeout(5000), + headers: noBodyHeaders, + }); + if (!response.ok) { + throw new Error('Failed to check bookmark'); + } + } catch (err) { + console.error(err); + throw new Error('Failed to check bookmark'); + } +} + +async function create(body: { name: string; href: string }) { + try { + const response = await fetch(`${bookmarksUrl}`, { + method: 'POST', + body: JSON.stringify(body), + signal: AbortSignal.timeout(5000), + headers: bodyHeaders, + }); + if (!response.ok) { + throw new Error('Failed to create bookmark'); + } + } catch (err) { + if (err) { + console.error(err); + throw new Error('Failed to create bookmark'); + } + } +} + +export default { query, remove, update, check, create }; diff --git a/src/shared.svelte.ts b/src/shared.svelte.ts new file mode 100644 index 0000000..9721734 --- /dev/null +++ b/src/shared.svelte.ts @@ -0,0 +1,28 @@ +function getCookieMap() { + const cookies = document.cookie.split('; '); + const cookieMap = new Map<string, string>(); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i]; + if (!cookie) continue; + let [key, value] = cookie.split('='); + if (!key || !value) continue; + cookieMap.set(key, value); + } + + return cookieMap; +} + +class UserState { + isLoggedIn = $state(false); + + constructor() { + this.checkIsLoggedIn(); + } + + checkIsLoggedIn() { + const cookieVal = getCookieMap().get('pt-auth'); + this.isLoggedIn = cookieVal !== undefined; + } +} + +export const userstate = new UserState(); diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..5b32ca1 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,47 @@ +import { userstate } from './shared.svelte'; + +type SelectBookmark = { + id: number; + name: string; + href: string; + finished: boolean; + created_at: number; + updated_at: number; +}; + +export async function fetchEntries(): Promise<SelectBookmark[]> { + try { + const response = await fetch('/api/entries', { + method: 'GET', + credentials: 'include', + mode: 'same-origin', + }); + + if (!response.ok) { + if (response.status === 401) { + userstate.checkIsLoggedIn(); + } + throw new Error('Network response was not ok'); + } + + const data = await response.json(); + + if (!Array.isArray(data)) { + throw new Error('Invalid data format'); + } + + return data; + } catch (error) { + console.error('Error fetching entries:', error); + } + + return []; +} + +export const formatter = new Intl.DateTimeFormat(undefined, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', +});