A website inspired by Last.fm that will keep track of your listening statistics
lastfm music statistics
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

make pages for client

oscar345 60e9b938 2465f309

+418 -515
+1 -1
TODOS.md
··· 10 10 11 11 Create a working application that can sync with spotify and gets your recently listened songs. Users can authenticate, have validation during login, and see their spotify stats. 12 12 13 - - [ ] authentication 13 + - [x] authentication 14 14 - [x] validation 15 15 - [ ] settings section 16 16 - [ ] services page in settings
client/better-auth_migrations/2026-01-22T22-06-57.108Z.sql client/better-auth_migrations/2026-01-23T18-48-25.942Z.sql
+13 -7
client/src/hooks.server.ts
··· 5 5 import type { Handle } from "@sveltejs/kit"; 6 6 7 7 const betterAuth: Handle = async ({ event, resolve }) => { 8 - const session = await auth.api.getSession({ 9 - headers: event.request.headers, 10 - }); 8 + try { 9 + const session = await auth.api.getSession({ 10 + headers: event.request.headers, 11 + }); 11 12 12 - if (session) { 13 - event.locals.session = session.session; 14 - event.locals.user = session.user; 13 + if (session) { 14 + event.locals.session = session.session; 15 + event.locals.user = session.user; 16 + } 17 + 18 + console.log("Session", session); 19 + } catch (e) { 20 + console.error("Error in auth hook", e); 15 21 } 16 22 17 23 return svelteKitHandler({ event, resolve, auth, building }); 18 24 }; 19 25 20 26 const protectDashboard: Handle = async ({ event, resolve }) => { 21 - if (event.url.pathname.startsWith("/dashboard")) { 27 + if (event.url.pathname.startsWith("/app/dashboard")) { 22 28 if (!event.locals.session) { 23 29 return new Response("Unauthorized", { status: 401 }); 24 30 }
+53
client/src/lib/components/brand/Avatar.svelte
··· 1 + <script lang="ts"> 2 + import Icon from "$lib/components/content/Icon.svelte"; 3 + 4 + type Props = {}; 5 + 6 + let {}: Props = $props(); 7 + </script> 8 + 9 + <div class="avatar"> 10 + <div class="picture"> 11 + <Icon name="hero-user-solid" /> 12 + </div> 13 + <div class="text"> 14 + <p>Lorem, ipsum.</p> 15 + <span>Lorem ipsum dolor sit.</span> 16 + </div> 17 + </div> 18 + 19 + <style> 20 + .picture { 21 + aspect-ratio: 1 / 1; 22 + width: 100%; 23 + background-color: var(--color-content-100); 24 + border-radius: var(--radius-full); 25 + display: grid; 26 + color: var(--color-base-100); 27 + place-items: center; 28 + } 29 + 30 + .avatar { 31 + display: grid; 32 + grid-template-columns: var(--spacing-10) 1fr; 33 + gap: var(--spacing-2); 34 + align-items: center; 35 + 36 + > .text { 37 + height: max-content; 38 + display: flex; 39 + flex-direction: column; 40 + } 41 + 42 + & p { 43 + font-size: var(--text-sm); 44 + line-height: var(--text-sm--line-height); 45 + } 46 + 47 + & span { 48 + font-size: var(--text-xs); 49 + color: var(--color-content-300); 50 + line-height: var(--leading-tighter); 51 + } 52 + } 53 + </style>
+4 -1
client/src/lib/components/brand/Logo.svelte
··· 8 8 9 9 <style> 10 10 p { 11 - color: var(--color-content-200); 11 + color: var(--color-content-100); 12 + text-transform: uppercase; 13 + font-weight: var(--font-weight-medium); 14 + font-size: var(--text-sm); 12 15 } 13 16 </style>
+1 -1
client/src/lib/components/content/Error.svelte
··· 15 15 16 16 {#if (messages_ ?? []).length > 0} 17 17 <div class="messages"> 18 - {#each messages as message} 18 + {#each messages_ as message} 19 19 <span {...props}> 20 20 <Icon name="hero-exclamation-circle-mini" /> 21 21 {message}
+1 -6
client/src/lib/components/layout/Navigation.svelte
··· 3 3 import Logo from "$lib/components/brand/Logo.svelte"; 4 4 import type { NavigationItemProps } from "$lib/types"; 5 5 6 - let items: NavigationItemProps[] = $state([ 7 - { href: "/", label: "Home", pathname: "Index" }, 8 - { href: "/mixtapes", label: "Mixtapes", pathname: "/mixtapes" }, 9 - { href: "/friends", label: "Friends", pathname: "/friends" }, 10 - { href: "/library", label: "Library", pathname: "/library" }, 11 - ]); 6 + let items = $state([] as NavigationItemProps[]); 12 7 </script> 13 8 14 9 <header>
+113
client/src/lib/components/layout/app/Navigation.svelte
··· 1 + <script lang="ts"> 2 + import { page } from "$app/state"; 3 + import Avatar from "$lib/components/brand/Avatar.svelte"; 4 + import Logo from "$lib/components/brand/Logo.svelte"; 5 + import Icon from "$lib/components/content/Icon.svelte"; 6 + import type { NavigationItemProps } from "$lib/types"; 7 + 8 + type Props = {}; 9 + 10 + let {}: Props = $props(); 11 + 12 + let primary: NavigationItemProps[] = $state([ 13 + { 14 + href: "/app/dashboard", 15 + label: "Dashboard", 16 + pathname: "/app/dashboard", 17 + icon: "hero-rectangle-group-mini", 18 + }, 19 + { 20 + href: "/app/mixtapes", 21 + label: "Mixtapes", 22 + pathname: "/app/mixtapes", 23 + icon: "hero-play-circle-mini", 24 + }, 25 + { 26 + href: "/app/friends", 27 + label: "Friends", 28 + pathname: "/app/friends", 29 + icon: "hero-user-group-mini", 30 + }, 31 + { 32 + href: "/app/library", 33 + label: "Library", 34 + pathname: "/app/library", 35 + icon: "hero-squares-2x2-mini", 36 + }, 37 + ]); 38 + </script> 39 + 40 + <nav> 41 + <a href="/" class="logo"> 42 + <Logo /> 43 + </a> 44 + <ul> 45 + {#each primary as item} 46 + <li> 47 + <a 48 + href={item.href} 49 + aria-current={item.pathname && 50 + page.url.pathname.startsWith(item.pathname) 51 + ? "page" 52 + : undefined} 53 + > 54 + {#if item.icon} 55 + <Icon name={item.icon} /> 56 + {/if} 57 + {item.label} 58 + </a> 59 + </li> 60 + {/each} 61 + </ul> 62 + 63 + <div class="avatar"> 64 + <Avatar /> 65 + </div> 66 + </nav> 67 + 68 + <style> 69 + .logo { 70 + grid-area: logo; 71 + } 72 + 73 + .avatar { 74 + grid-area: avatar; 75 + } 76 + 77 + nav { 78 + height: 100%; 79 + padding-inline: var(--spacing-4); 80 + display: grid; 81 + row-gap: var(--spacing-4); 82 + grid-template-areas: "logo" "primary" "." "avatar"; 83 + padding-block: var(--spacing-4); 84 + grid-template-rows: max-content max-content 1fr max-content; 85 + } 86 + 87 + ul { 88 + grid-area: primary; 89 + display: flex; 90 + gap: var(--spacing-1); 91 + flex-direction: column; 92 + } 93 + 94 + li > a { 95 + color: var(--color-content-100); 96 + padding: var(--spacing-1) var(--spacing-1_5); 97 + font-size: var(--text-sm); 98 + line-height: var(--text-sm--line-height); 99 + font-weight: var(--font-weight-medium); 100 + width: 100%; 101 + display: flex; 102 + align-items: center; 103 + gap: var(--spacing-2); 104 + } 105 + 106 + li > a[aria-current="page"] { 107 + background-color: var(--color-base-200); 108 + } 109 + 110 + li > a:hover { 111 + background-color: var(--color-muted-100); 112 + } 113 + </style>
+23 -9
client/src/lib/index.ts
··· 2 2 3 3 import ky, { type KyInstance, type Options, type ResponsePromise } from "ky"; 4 4 import { auth } from "$lib/auth/server"; 5 - import * as v from "valibot"; 5 + import type { Method } from "$lib/types"; 6 6 7 7 export const api = ky.create({ 8 8 fetch: fetch, 9 - json: true, 10 9 prefixUrl: "http://localhost:3000", 11 10 hooks: { 12 11 beforeRequest: [ 13 - (request) => { 14 - const token = auth.api.getToken({ 15 - headers: request.headers, 16 - }); 17 - request.headers.set("Authorization", `Bearer ${token}`); 12 + async (request, options) => { 13 + try { 14 + if (options.context.headers) { 15 + const token = await auth.api.getToken({ 16 + headers: options.context.headers as Headers, 17 + }); 18 + request.headers.set("Authorization", `Bearer ${token.token}`); 19 + } 20 + } catch { 21 + console.log("No token found"); 22 + } 18 23 }, 19 24 ], 20 25 }, 21 26 }); 22 27 23 28 export type Endpoint = { 24 - method: "get" | "post" | "put" | "delete" | "patch"; 29 + method: Method; 25 30 url: string; 26 31 }; 27 32 ··· 29 34 instance: KyInstance, 30 35 endpoint: Endpoint, 31 36 options = {} as Options, 37 + headers?: Headers, 32 38 ): ResponsePromise<T> { 33 - return instance<T>(endpoint.url, { method: endpoint.method, ...options }); 39 + const url = endpoint.url.startsWith("/") 40 + ? endpoint.url.slice(1) 41 + : endpoint.url; 42 + 43 + return instance<T>(url, { 44 + method: endpoint.method, 45 + context: { headers: headers }, 46 + ...options, 47 + }); 34 48 }
+6 -1
client/src/lib/schemas/requests.ts
··· 1 1 import * as v from "valibot"; 2 2 3 - export const LoginSchema = v.object({ 3 + export const SignupSchema = v.object({ 4 4 email: v.pipe(v.string(), v.email(), v.nonEmpty()), 5 5 password: v.pipe(v.string(), v.nonEmpty()), 6 6 name: v.pipe(v.string(), v.nonEmpty()), ··· 9 9 export type FormErrors<TSchema extends v.GenericSchema> = ReturnType< 10 10 typeof v.flatten<TSchema> 11 11 >["nested"]; 12 + 13 + export const LoginSchema = v.object({ 14 + email: v.pipe(v.string(), v.email(), v.nonEmpty()), 15 + password: v.pipe(v.string(), v.nonEmpty()), 16 + });
+2 -1
client/src/lib/types.ts
··· 11 11 label: string; 12 12 href: string; 13 13 pathname?: string; 14 + icon?: string; 14 15 }; 15 16 16 17 export type Method = "get" | "post" | "put" | "patch" | "delete"; 17 18 18 - export type MethodURL = { 19 + export type Endpoint = { 19 20 method: Method; 20 21 url: string; 21 22 };
-27
client/src/routes/(app)/+layout.svelte
··· 1 - <script lang="ts"> 2 - import Footer from "$lib/components/layout/Footer.svelte"; 3 - import Navigation from "$lib/components/layout/Navigation.svelte"; 4 - import type { Snippet } from "svelte"; 5 - 6 - type Props = { 7 - children: Snippet; 8 - }; 9 - 10 - let { children }: Props = $props(); 11 - </script> 12 - 13 - <Navigation /> 14 - <div> 15 - {@render children()} 16 - </div> 17 - <Footer /> 18 - 19 - <style> 20 - :global(body) { 21 - display: grid; 22 - grid-template-rows: var(--spacing-16) 1fr max-content; 23 - min-height: 100dvh; 24 - 25 - row-gap: var(--spacing-6); 26 - } 27 - </style>
client/src/routes/(app)/+page.svelte client/src/routes/app/+page.svelte
client/src/routes/(app)/library/artists/+page.svelte client/src/routes/app/library/artists/+page.svelte
+1
client/src/routes/(web)/+page.svelte
··· 1 + <h1>Index</h1>
+37
client/src/routes/app/+layout.svelte
··· 1 + <script lang="ts"> 2 + import Footer from "$lib/components/layout/Footer.svelte"; 3 + import Navigation from "$lib/components/layout/app/Navigation.svelte"; 4 + import type { Snippet } from "svelte"; 5 + 6 + type Props = { 7 + children: Snippet; 8 + }; 9 + 10 + let { children }: Props = $props(); 11 + </script> 12 + 13 + <Navigation /> 14 + <div class="content"> 15 + {@render children()} 16 + </div> 17 + 18 + <style> 19 + :global(body) { 20 + display: grid; 21 + grid-template-rows: 1fr; 22 + grid-template-columns: var(--width-2xs) 3fr; 23 + min-height: 100dvh; 24 + grid-template-areas: "navigation content"; 25 + row-gap: var(--spacing-6); 26 + } 27 + 28 + .content { 29 + grid-area: content; 30 + border-left: var(--theme-default-border); 31 + background-color: var(--color-base-300); 32 + } 33 + 34 + :global(body > nav) { 35 + grid-area: navigation; 36 + } 37 + </style>
+18
client/src/routes/app/dashboard/+page.svelte
··· 1 + <main> 2 + <header class="header"> 3 + <hgroup> 4 + <h1 class="h1">Dashboard</h1> 5 + <p class="subtitle"> 6 + Lorem ipsum dolor, sit amet consectetur adipisicing elit. Beatae 7 + veritatis corporis harum, odio quaerat necessitatibus laborum quas 8 + ducimus pariatur eos! 9 + </p> 10 + </hgroup> 11 + </header> 12 + </main> 13 + 14 + <style> 15 + main { 16 + padding: var(--spacing-6); 17 + } 18 + </style>
client/src/routes/app/library/+page.svelte

This is a binary file and will not be displayed.

+11
client/src/routes/app/library/artists/+page.server.ts
··· 1 + import { api, call } from "$lib"; 2 + import type { PageServerLoad } from "./$types"; 3 + import { GET_Artists } from "$lib/.gen/routes"; 4 + 5 + export const load: PageServerLoad = async ({ request }) => { 6 + const result = await call(api, GET_Artists(), {}, request.headers).json(); 7 + 8 + return { 9 + artists: result, 10 + }; 11 + };
+18
client/src/routes/app/mixtapes/+page.svelte
··· 1 + <main> 2 + <header class="header"> 3 + <hgroup> 4 + <h1 class="h1">Mixtapes</h1> 5 + <p class="subtitle"> 6 + Lorem ipsum dolor, sit amet consectetur adipisicing elit. Beatae 7 + veritatis corporis harum, odio quaerat necessitatibus laborum quas 8 + ducimus pariatur eos! 9 + </p> 10 + </hgroup> 11 + </header> 12 + </main> 13 + 14 + <style> 15 + main { 16 + padding: var(--spacing-6); 17 + } 18 + </style>
+43
client/src/routes/authentication/signin/+page.server.ts
··· 1 + import { LoginSchema } from "$lib/schemas/requests"; 2 + import type { Errors } from "$lib/types"; 3 + import { fail, redirect } from "@sveltejs/kit"; 4 + import type { Actions } from "./$types"; 5 + import * as v from "valibot"; 6 + import { auth } from "$lib/auth/server"; 7 + import { APIError } from "better-auth"; 8 + 9 + export const actions: Actions = { 10 + default: async ({ request, locals }) => { 11 + const data = Object.fromEntries(await request.formData()); 12 + 13 + let result = v.safeParse(LoginSchema, data); 14 + 15 + if (!result.success) { 16 + return fail(400, { 17 + errors: v.flatten(result.issues).nested as Errors<typeof LoginSchema>, 18 + data: data, 19 + }); 20 + } 21 + 22 + try { 23 + await auth.api.signInEmail({ 24 + body: { 25 + email: result.output.email, 26 + password: result.output.password, 27 + }, 28 + }); 29 + } catch (error) { 30 + if (error instanceof APIError) { 31 + return { 32 + success: false, 33 + data: data, 34 + errors: { email: "Something went wrong" } as Errors< 35 + typeof LoginSchema 36 + >, 37 + }; 38 + } 39 + } 40 + 41 + return redirect(303, "/"); 42 + }, 43 + };
+17 -2
client/src/routes/authentication/signin/+page.svelte
··· 1 1 <script lang="ts"> 2 + import { enhance } from "$app/forms"; 2 3 import Back from "$lib/components/interaction/Back.svelte"; 3 4 import Button from "$lib/components/interaction/Button.svelte"; 4 5 import Field from "$lib/components/interaction/Field.svelte"; 6 + import type { PageProps } from "./$types"; 7 + 8 + let { form }: PageProps = $props(); 5 9 </script> 6 10 7 11 <header class="header"> ··· 17 21 18 22 <Back href="/" label="Go back home" /> 19 23 20 - <form> 21 - <Field as="input" type="text" label="Email" description="Enter your email" /> 24 + <form method="POST" use:enhance> 25 + <Field 26 + as="input" 27 + name="email" 28 + type="text" 29 + label="Email" 30 + description="Enter your email" 31 + errors={form?.errors.email} 32 + value={form?.data.email} 33 + /> 22 34 <Field 23 35 as="input" 24 36 type="password" 25 37 label="Password" 38 + name="password" 26 39 description="Enter your password" 40 + errors={form?.errors.password} 41 + value={form?.data.password} 27 42 /> 28 43 <Field 29 44 as="checkbox"
+4 -4
client/src/routes/authentication/signup/+page.server.ts
··· 1 1 import { auth } from "$lib/auth/server"; 2 - import { LoginSchema, type FormErrors } from "$lib/schemas/requests"; 2 + import { SignupSchema, type FormErrors } from "$lib/schemas/requests"; 3 3 import type { Errors } from "$lib/types"; 4 4 import { fail, redirect, type Actions } from "@sveltejs/kit"; 5 5 import { APIError } from "better-auth"; ··· 9 9 default: async ({ request }) => { 10 10 const data = Object.fromEntries(await request.formData()); 11 11 12 - let result = v.safeParse(LoginSchema, data); 12 + let result = v.safeParse(SignupSchema, data); 13 13 14 14 if (!result.success) { 15 15 return fail(400, { 16 - errors: v.flatten(result.issues).nested as Errors<typeof LoginSchema>, 16 + errors: v.flatten(result.issues).nested as Errors<typeof SignupSchema>, 17 17 data: data, 18 18 }); 19 19 } ··· 31 31 return { 32 32 success: false, 33 33 errors: { email: "Something went wrong" } as Errors< 34 - typeof LoginSchema 34 + typeof SignupSchema 35 35 >, 36 36 }; 37 37 }
+1 -1
client/src/styles/typography.css
··· 1 1 .h1 { 2 2 font-size: var(--text-2xl); 3 - line-height: var(--leading-normal); 3 + line-height: var(--leading-tight); 4 4 font-weight: var(--font-weight-medium); 5 5 color: var(--color-content-100); 6 6 }
-438
combined.txt
··· 1 - package authentication 2 - 3 - import ( 4 - "log" 5 - "net/http" 6 - "time" 7 - 8 - "golang.org/x/crypto/bcrypt" 9 - ) 10 - 11 - type User struct { 12 - ID int 13 - Email string 14 - HashedPassword string 15 - ConfirmedAt time.Time 16 - } 17 - 18 - type Repo interface { 19 - GetUserByEmail(email string) (User, error) 20 - GetUserByID(id int) (User, error) 21 - GetUserByTokenHash(hash string) (User, Token, error) 22 - CreateUser(user User) (int, error) 23 - UpdateUser(user User) error 24 - 25 - GetTokenByHash(hash string) (Token, error) 26 - ListTokenByUserID(userID int) ([]Token, error) 27 - CreateToken(token Token) error 28 - DeleteToken(hash string) error 29 - } 30 - 31 - type Authenticator struct { 32 - repo Repo 33 - invalidHashPassword []byte 34 - store SessionStore 35 - } 36 - 37 - func NewAuthenticator(repo Repo) *Authenticator { 38 - invalidHashPassword, err := bcrypt.GenerateFromPassword([]byte("invalid"), bcrypt.DefaultCost) 39 - if err != nil { 40 - log.Panicln(err) 41 - } 42 - 43 - return &Authenticator{ 44 - repo: repo, 45 - invalidHashPassword: invalidHashPassword, 46 - } 47 - } 48 - 49 - func (a *Authenticator) GetUserByEmailAndPassword(email, password string) (User, error) { 50 - user, err := a.repo.GetUserByEmail(email) 51 - 52 - if ok := a.IsPasswordCorrect(user.HashedPassword, password, err == nil); !ok { 53 - return User{}, ErrInvalidCredentials 54 - } 55 - 56 - return user, nil 57 - } 58 - 59 - func (a *Authenticator) IsPasswordCorrect(current string, input string, isValidUser bool) bool { 60 - if !isValidUser { 61 - _ = bcrypt.CompareHashAndPassword(a.invalidHashPassword, []byte(input)) 62 - return false 63 - } 64 - err := bcrypt.CompareHashAndPassword([]byte(current), []byte(input)) 65 - return err == nil 66 - } 67 - 68 - func (a *Authenticator) UpdateUserEmail(userID int, hash string) error { 69 - user, err := a.repo.GetUserByID(userID) 70 - if err != nil { 71 - return err 72 - } 73 - 74 - raw, err := GenerateHashFromToken(hash) 75 - if err != nil { 76 - return err 77 - } 78 - 79 - token, err := a.repo.GetTokenByHash(string(raw)) 80 - if err != nil { 81 - return err 82 - } 83 - 84 - if err := token.VerifyByPurpose(CreateChangeTokenPurpose(user.Email)); err != nil { 85 - return err 86 - } 87 - 88 - user.Email = token.SentTo 89 - if err := a.repo.UpdateUser(user); err != nil { 90 - return err 91 - } 92 - 93 - return a.repo.DeleteToken(token.Hash) 94 - } 95 - 96 - func (a *Authenticator) UpdateUserPassword(userID int, current string, replacement string) error { 97 - user, err := a.repo.GetUserByID(userID) 98 - if err != nil { 99 - return err 100 - } 101 - 102 - if !a.IsPasswordCorrect(user.HashedPassword, current, true) { 103 - return ErrInvalidCredentials 104 - } 105 - 106 - hashed, err := bcrypt.GenerateFromPassword([]byte(replacement), bcrypt.DefaultCost) 107 - if err != nil { 108 - return err 109 - } 110 - 111 - user.HashedPassword = string(hashed) 112 - return a.repo.UpdateUser(user) 113 - } 114 - 115 - func (a *Authenticator) ConfirmUser(hash string) error { 116 - user, token, err := a.repo.GetUserByTokenHash(hash) 117 - if err != nil { 118 - return err 119 - } 120 - 121 - if err := token.VerifyByPurpose(TokenPurposeConfirm); err != nil { 122 - return err 123 - } 124 - 125 - user.ConfirmedAt = time.Now() 126 - if err := a.repo.UpdateUser(user); err != nil { 127 - return err 128 - } 129 - 130 - return a.repo.DeleteToken(token.Hash) 131 - } 132 - 133 - func (a *Authenticator) ResetUserPassword(hash string, replacement string) error { 134 - user, token, err := a.repo.GetUserByTokenHash(hash) 135 - if err != nil { 136 - return err 137 - } 138 - 139 - if err := token.VerifyByPurpose(TokenPurposePasswordReset); err != nil { 140 - return err 141 - } 142 - 143 - hashed, err := bcrypt.GenerateFromPassword([]byte(replacement), bcrypt.DefaultCost) 144 - if err != nil { 145 - return err 146 - } 147 - 148 - user.HashedPassword = string(hashed) 149 - if err := a.repo.UpdateUser(user); err != nil { 150 - return err 151 - } 152 - 153 - return a.repo.DeleteToken(token.Hash) 154 - } 155 - 156 - func (a *Authenticator) LogIn(w http.ResponseWriter, r *http.Request, user User) error { 157 - data, err := a.store.Get(r) 158 - if err != nil { 159 - return err 160 - } 161 - 162 - if err := RenewSession(w, r, a.store); err != nil { 163 - return err 164 - } 165 - 166 - token, raw, err := CreateSessionToken(user.ID) 167 - if err != nil { 168 - return err 169 - } 170 - 171 - if err := a.repo.CreateToken(token); err != nil { 172 - return err 173 - } 174 - 175 - if err := a.store.Set(w, r, SessionData{Token: raw}); err != nil { 176 - return err 177 - } 178 - 179 - returnTo := PathLogin 180 - if data.ReturnTo != "" { 181 - returnTo = data.ReturnTo 182 - } 183 - 184 - http.Redirect(w, r, returnTo, http.StatusFound) 185 - return nil 186 - } 187 - 188 - func (a *Authenticator) LogOut(w http.ResponseWriter, r *http.Request) error { 189 - data, err := a.store.Get(r) 190 - if err != nil { 191 - return err 192 - } 193 - 194 - if data.Token == "" { 195 - return nil 196 - } 197 - 198 - hash, err := GenerateHashFromToken(data.Token) 199 - if err != nil { 200 - return err 201 - } 202 - 203 - if err := a.repo.DeleteToken(string(hash)); err != nil { 204 - return err 205 - } 206 - 207 - if err := RenewSession(w, r, a.store); err != nil { 208 - _ = a.store.Delete(w, r) 209 - return err 210 - } 211 - 212 - http.Redirect(w, r, PathSignedOut, http.StatusFound) 213 - return nil 214 - } 215 - package authentication 216 - 217 - import "errors" 218 - 219 - var ( 220 - ErrSessionExpired = errors.New("session expired") 221 - ErrWrongPurpose = errors.New("wrong purpose") 222 - ErrInvalidCredentials = errors.New("invalid credentials") 223 - ErrNoUserInContext = errors.New("no user in context") 224 - ) 225 - package authentication 226 - 227 - import ( 228 - "context" 229 - "net/http" 230 - ) 231 - 232 - type SessionData struct { 233 - Token string 234 - ReturnTo string 235 - } 236 - 237 - type SessionStore interface { 238 - Get(r *http.Request) (SessionData, error) 239 - Set(w http.ResponseWriter, r *http.Request, data SessionData) error 240 - Delete(w http.ResponseWriter, r *http.Request) error 241 - } 242 - 243 - func RenewSession(w http.ResponseWriter, r *http.Request, store SessionStore) error { 244 - if err := store.Delete(w, r); err != nil { 245 - return err 246 - } 247 - 248 - if err := store.Set(w, r, SessionData{}); err != nil { 249 - _ = store.Delete(w, r) 250 - return err 251 - } 252 - 253 - return nil 254 - } 255 - 256 - type Middleware struct { 257 - repo Repo 258 - store SessionStore 259 - } 260 - 261 - func NewMiddleware(repo Repo, store SessionStore) *Middleware { 262 - return &Middleware{ 263 - repo: repo, 264 - store: store, 265 - } 266 - } 267 - 268 - func (m *Middleware) FetchUser(next http.Handler) http.Handler { 269 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 270 - data, err := m.store.Get(r) 271 - if err != nil { 272 - next.ServeHTTP(w, r) 273 - return 274 - } 275 - 276 - if data.Token == "" { 277 - next.ServeHTTP(w, r) 278 - return 279 - } 280 - 281 - hash, err := GenerateHashFromToken(data.Token) 282 - if err != nil { 283 - next.ServeHTTP(w, r) 284 - return 285 - } 286 - 287 - user, token, err := m.repo.GetUserByTokenHash(string(hash)) 288 - if err != nil { 289 - next.ServeHTTP(w, r) 290 - } 291 - 292 - if err := token.VerifyByPurpose(TokenPurposeSession); err != nil { 293 - next.ServeHTTP(w, r) 294 - } 295 - 296 - r = r.WithContext(context.WithValue(r.Context(), ContextKeyUser, user)) 297 - 298 - next.ServeHTTP(w, r) 299 - }) 300 - } 301 - 302 - func (m *Middleware) RequireUser(next http.Handler) http.Handler { 303 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 304 - _, err := GetUserFromRequest(r) 305 - if err != nil { 306 - _ = m.store.Set(w, r, SessionData{ReturnTo: r.URL.Path}) 307 - http.Redirect(w, r, PathLogin, http.StatusFound) 308 - return 309 - } 310 - 311 - next.ServeHTTP(w, r) 312 - }) 313 - } 314 - 315 - func (m *Middleware) RequireNoUser(next http.Handler) http.Handler { 316 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 317 - _, err := GetUserFromRequest(r) 318 - if err == nil { 319 - http.Redirect(w, r, PathSignedIn, http.StatusFound) 320 - return 321 - } 322 - 323 - next.ServeHTTP(w, r) 324 - }) 325 - } 326 - 327 - const ContextKeyUser = "authentication-user" 328 - 329 - func GetUserFromRequest(r *http.Request) (User, error) { 330 - val := r.Context().Value(ContextKeyUser) 331 - if val == nil { 332 - return User{}, ErrNoUserInContext 333 - } 334 - 335 - user, ok := val.(User) 336 - if !ok { 337 - return User{}, ErrNoUserInContext 338 - } 339 - 340 - return user, nil 341 - } 342 - package authentication 343 - 344 - import "time" 345 - 346 - const ( 347 - SessionValidityDuration = 7 * 24 * time.Hour 348 - ) 349 - 350 - const ( 351 - PathLogin = "/login" 352 - PathSignedIn = "/" 353 - PathSignedOut = "/" 354 - ) 355 - package authentication 356 - 357 - import ( 358 - "crypto/rand" 359 - "crypto/sha256" 360 - "crypto/subtle" 361 - "encoding/base64" 362 - "time" 363 - ) 364 - 365 - type Token struct { 366 - Hash string 367 - UserID int 368 - Purpose TokenPurpose 369 - ExpiresAt time.Time 370 - SentTo string 371 - } 372 - 373 - func (t *Token) VerifyExpired() error { 374 - if time.Now().After(t.ExpiresAt) { 375 - return ErrSessionExpired 376 - } 377 - return nil 378 - } 379 - 380 - // VerifyByPurpose verifies if the session is valid for the given purpose and not expired. 381 - func (t *Token) VerifyByPurpose(purpose TokenPurpose) error { 382 - if subtle.ConstantTimeCompare([]byte(t.Purpose), []byte(purpose)) != 1 { 383 - return ErrWrongPurpose 384 - } 385 - return t.VerifyExpired() 386 - } 387 - 388 - type TokenPurpose string 389 - 390 - const ( 391 - TokenPurposeSession TokenPurpose = "session" 392 - TokenPurposePasswordReset TokenPurpose = "password-reset" 393 - TokenPurposeConfirm TokenPurpose = "confirm" 394 - ) 395 - 396 - func CreateChangeTokenPurpose(value string) TokenPurpose { 397 - return TokenPurpose("change:" + value) 398 - } 399 - 400 - // Create a token model. The function will return that model, its raw token value and an error if 401 - // any. 402 - func CreateSessionToken(userID int) (Token, string, error) { 403 - raw, hash, err := CreateTokenHash() 404 - if err != nil { 405 - return Token{}, "", err 406 - } 407 - 408 - token := Token{ 409 - UserID: userID, 410 - Purpose: TokenPurposeSession, 411 - ExpiresAt: time.Now().Add(SessionValidityDuration), 412 - Hash: string(hash), 413 - } 414 - 415 - return token, raw, nil 416 - } 417 - 418 - // Generate a random token, and return its raw value (for the user), its hash and a possible error 419 - func CreateTokenHash() (string, []byte, error) { 420 - token := make([]byte, 32) 421 - _, err := rand.Read(token) 422 - if err != nil { 423 - return "", nil, err 424 - } 425 - hash := sha256.Sum256(token) 426 - return base64.StdEncoding.EncodeToString(token), hash[:], nil 427 - } 428 - 429 - func GenerateHashFromToken(token string) ([]byte, error) { 430 - // Always perform the hash operation, even if decode fails 431 - raw, err := base64.StdEncoding.DecodeString(token) 432 - if err != nil { 433 - // Continue with dummy data to maintain constant time 434 - raw = make([]byte, 32) 435 - } 436 - hash := sha256.Sum256(raw) 437 - return hash[:], nil 438 - }
+3
go.mod
··· 13 13 modernc.org/sqlite v1.44.1 14 14 ) 15 15 16 + require github.com/ajg/form v1.5.1 // indirect 17 + 16 18 require ( 17 19 github.com/MicahParks/jwkset v0.11.0 // indirect 18 20 github.com/apache/arrow-go/v18 v18.4.1 // indirect ··· 25 27 github.com/duckdb/duckdb-go/arrowmapping v0.0.27 // indirect 26 28 github.com/duckdb/duckdb-go/mapping v0.0.27 // indirect 27 29 github.com/dustin/go-humanize v1.0.1 // indirect 30 + github.com/go-chi/render v1.0.3 28 31 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 29 32 github.com/goccy/go-json v0.10.5 // indirect 30 33 github.com/google/flatbuffers v25.9.23+incompatible // indirect
+4
go.sum
··· 2 2 github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0= 3 3 github.com/MicahParks/keyfunc/v3 v3.7.0 h1:pdafUNyq+p3ZlvjJX1HWFP7MA3+cLpDtg69U3kITJGM= 4 4 github.com/MicahParks/keyfunc/v3 v3.7.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0= 5 + github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= 6 + github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= 5 7 github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= 6 8 github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= 7 9 github.com/apache/arrow-go/v18 v18.4.1 h1:q/jVkBWCJOB9reDgaIZIdruLQUb1kbkvOnOFezVH1C4= ··· 32 34 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 33 35 github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= 34 36 github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 37 + github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= 38 + github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= 35 39 github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= 36 40 github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 37 41 github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
+13
internal/web/handlers/catalog.go
··· 1 + package handlers 2 + 3 + import "github.com/oscar345/keeptrack/internal/services" 4 + 5 + type CatalogArtistHandler struct { 6 + artistService services.ArtistService 7 + } 8 + 9 + func NewCatalogArtistHandler(artistService services.ArtistService) *CatalogArtistHandler { 10 + return &CatalogArtistHandler{ 11 + artistService: artistService, 12 + } 13 + }
+11
internal/web/middleware/middleware.go
··· 43 43 }) 44 44 } 45 45 } 46 + 47 + func RequireUser(next http.Handler) http.Handler { 48 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 + if r.Context().Value(UserContextKey) == nil { 50 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 51 + return 52 + } 53 + 54 + next.ServeHTTP(w, r) 55 + }) 56 + }
+18 -14
internal/web/router/router.go
··· 1 1 package router 2 2 3 3 import ( 4 + "log" 4 5 "net/http" 5 6 7 + "github.com/MicahParks/keyfunc/v3" 6 8 "github.com/go-chi/chi/v5" 7 9 chimiddleware "github.com/go-chi/chi/v5/middleware" 10 + "github.com/go-chi/render" 8 11 "github.com/oscar345/keeptrack/internal/config" 9 12 "github.com/oscar345/keeptrack/internal/services" 10 13 "github.com/oscar345/keeptrack/internal/web/handlers" 11 - "github.com/oscar345/keeptrack/private" 14 + "github.com/oscar345/keeptrack/internal/web/middleware" 12 15 ) 13 16 14 17 type Server struct { ··· 29 32 } 30 33 } 31 34 32 - func assetsFileSystemHandler(cfg *config.Config) http.Handler { 33 - if cfg.Environment == config.Development { 34 - return http.StripPrefix("/assets", http.FileServer(http.Dir("private/assets"))) 35 - } 36 - return http.FileServer(http.FS(private.AssetsFS)) 37 - } 38 - 39 35 func (s *Server) Router() *chi.Mux { 40 36 r := chi.NewRouter() 41 37 38 + keyfn, err := keyfunc.NewDefault([]string{"http://localhost:5173/api/auth/jwks"}) 39 + if err != nil { 40 + log.Fatalf("Failed to create JWKS from resource at the given URL.\nError: %s", err) 41 + } 42 + 42 43 r.Use( 43 44 chimiddleware.Logger, 44 45 chimiddleware.RequestID, 45 46 chimiddleware.CleanPath, 47 + middleware.FetchUser(keyfn), 46 48 ) 47 49 48 - r.Handle("/assets*", assetsFileSystemHandler(s.config)) 49 50 r.Handle("/public*", http.StripPrefix("/public", http.FileServer(http.Dir("public/storage")))) 50 51 51 - r.Group(s.index()) 52 + r. 53 + With(middleware.RequireUser). 54 + Route("/artists", s.artists()) 52 55 53 56 return r 54 57 } 55 58 56 - func (s *Server) index() func(chi.Router) { 57 - _ = handlers.NewIndexHandler() 59 + func (s *Server) artists() func(chi.Router) { 60 + _ = handlers.NewCatalogArtistHandler(s.artistService) 58 61 59 62 return func(r chi.Router) { 60 63 r.Get("/", func(w http.ResponseWriter, r *http.Request) { 61 - 64 + render.JSON(w, r, render.M{ 65 + "message": "Hello World", 66 + }) 62 67 }) 63 - 64 68 } 65 69 }
+2 -2
pkg/bridge/routes.go
··· 65 65 66 66 func createTypescriptFunc(writer io.Writer, method, route string) error { 67 67 fun := ` 68 - export function {{ .name }}({{ .params }}) : { url: string, method: Method} { 68 + export function {{ .name }}({{ .params }}) : Endpoint { 69 69 return { 70 70 url: '''{{ .route }}''', 71 71 method: "{{ .method }}" ··· 100 100 101 101 func createFilePrefix(writer io.Writer) error { 102 102 fun := ` 103 - import type { Method } from "$lib/types"; 103 + import { type Endpoint } from "$lib/types"; 104 104 ` 105 105 106 106 templ, err := template.New("prefix").Parse(fun)