diff --git a/package-lock.json b/package-lock.json index 7f61cdd..c13abec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,8 @@ "nodemailer": "^7.0.13", "postgres": "^3.4.7", "sharp": "^0.34.5", - "svelte-preprocess": "^6.0.3" + "svelte-preprocess": "^6.0.3", + "valibot": "^1.2.0" }, "devDependencies": { "@eslint/compat": "^1.4.0", @@ -6646,6 +6647,20 @@ "dev": true, "license": "MIT" }, + "node_modules/valibot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index 8a4b481..fffb089 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "nodemailer": "^7.0.13", "postgres": "^3.4.7", "sharp": "^0.34.5", - "svelte-preprocess": "^6.0.3" + "svelte-preprocess": "^6.0.3", + "valibot": "^1.2.0" } } diff --git a/src/app.css b/src/app.css index 5d4eb59..4011771 100644 --- a/src/app.css +++ b/src/app.css @@ -39,6 +39,7 @@ --sidebar-ring: oklch(0.606 0.25 292.717); --warning: oklch(0.84 0.16 84); --error: oklch(0.577 0.245 27.325); + --positive: oklch(0.5 0.2067 147.18); } .dark { @@ -75,6 +76,7 @@ --sidebar-ring: oklch(0.541 0.281 293.009); --warning: oklch(0.84 0.16 84); --error: oklch(0.704 0.191 22.216); + --positive: oklch(0.7522 0.2067 147.18); } @theme inline { @@ -115,6 +117,7 @@ --color-sidebar-ring: var(--sidebar-ring); --color-warning: var(--warning); --color-error: var(--error); + --color-positive: var(--positive); } @layer base { diff --git a/src/lib/auth/index.server.ts b/src/lib/auth/index.server.ts index 0c7c122..fc2fcf8 100644 --- a/src/lib/auth/index.server.ts +++ b/src/lib/auth/index.server.ts @@ -1,10 +1,10 @@ -import type { User } from '$lib/types/user'; +import type { User, UserPayload } from '$lib/types/user'; import bcrypt from 'bcrypt'; import sql from '$lib/db/db.server'; -import type { Cookies } from '@sveltejs/kit'; +import { type Cookies, error } from '@sveltejs/kit'; import jwt from 'jsonwebtoken'; -export function setJWT(cookies: Cookies, user: User) { +export function setJWTCookie(cookies: Cookies, user: User) { const payload = { id: user.id, email: user.email, @@ -12,10 +12,10 @@ export function setJWT(cookies: Cookies, user: User) { }; if (process.env.JWT_SECRET === undefined) { - throw new Error('JWT_SECRET not defined'); + throw Error('JWT_SECRET not defined'); } if (process.env.BASE_URL === undefined) { - throw new Error('BASE_URL not defined'); + throw Error('BASE_URL not defined'); } const secure: boolean = process.env.BASE_URL?.includes('https://'); @@ -25,17 +25,14 @@ export function setJWT(cookies: Cookies, user: User) { cookies.set('jwt', JWT, { maxAge, path: '/', httpOnly: false, secure }); } -// export function checkPerms(cookies: Cookies, perms: number): void { -// const JWT = cookies.get('jwt'); -// if (!JWT) throw error(403, 'Unauthorized'); -// if (process.env.JWT_SECRET === undefined) { -// throw new Error('JWT_SECRET not defined'); -// } -// const user = jwt.verify(JWT, process.env.JWT_SECRET) as User; -// if ((user.perms & perms) !== perms) { -// throw error(403, 'Unauthorized'); -// } -// } +export function verifyJWT(cookies: Cookies): UserPayload { + const JWT = cookies.get('jwt'); + if (!JWT) throw error(403, 'Unauthorized'); + if (process.env.JWT_SECRET === undefined) { + throw new Error('JWT_SECRET not defined'); + } + return jwt.verify(JWT, process.env.JWT_SECRET) as UserPayload; +} // // export function hasPerms(cookies: Cookies, perms: number): boolean { // const JWT = cookies.get('jwt'); @@ -48,14 +45,13 @@ export function setJWT(cookies: Cookies, user: User) { // } export async function login(email: string, password: string): Promise { - try { - const [user]: User[] = await sql` + const [user]: User[] = await sql` SELECT * FROM users WHERE email = ${email}; `; - - if (await bcrypt.compare(password, user.password_hash!)) { - delete user.password_hash; + if (user) { + if (await bcrypt.compare(password, user.passwordHash!)) { + delete user.passwordHash; // eslint-disable-next-line @typescript-eslint/no-unused-expressions sql` @@ -66,15 +62,13 @@ export async function login(email: string, password: string): Promise { return user; } - } catch { - throw Error('Error signing in '); } throw Error('Invalid email or password'); } // await createUser({ +// name: 'Drake', +// username: 'drake', // email: 'drake@marinodev.com', -// password: 'password', -// perms: 255, -// name: 'Drake' +// password: 'password' // }); diff --git a/src/lib/components/custom/item-listing.svelte b/src/lib/components/custom/item-listing.svelte index 3f6f459..340ebb7 100644 --- a/src/lib/components/custom/item-listing.svelte +++ b/src/lib/components/custom/item-listing.svelte @@ -3,6 +3,13 @@ import type { Item } from '$lib/types/item'; import { Badge } from '$lib/components/ui/badge'; import LocationIcon from '@lucide/svelte/icons/map-pinned'; + import CheckIcon from '@lucide/svelte/icons/check'; + import XIcon from '@lucide/svelte/icons/x'; + import { Button } from '$lib/components/ui/button'; + import * as Tooltip from '$lib/components/ui/tooltip'; + import { dateFormatOptions } from '$lib/shared'; + import { approveDenyItem } from '$lib/db/items.remote'; + import { invalidateAll } from '$app/navigation'; export let item: Item = { id: 2, @@ -14,32 +21,71 @@ foundLocation: 'By the tennis courts.' }; export let admin = false; + + let timeSincePosted: number | string = (new Date().getTime() - item.foundDate.getTime()) / 1000 / 60 / 60 / 24; // days + if (timeSincePosted < 1) { + timeSincePosted = '<1'; + } else { + timeSincePosted = Math.round(timeSincePosted); + } - +
- diff --git a/src/lib/components/ui/tooltip/index.ts b/src/lib/components/ui/tooltip/index.ts new file mode 100644 index 0000000..1718604 --- /dev/null +++ b/src/lib/components/ui/tooltip/index.ts @@ -0,0 +1,19 @@ +import Root from "./tooltip.svelte"; +import Trigger from "./tooltip-trigger.svelte"; +import Content from "./tooltip-content.svelte"; +import Provider from "./tooltip-provider.svelte"; +import Portal from "./tooltip-portal.svelte"; + +export { + Root, + Trigger, + Content, + Provider, + Portal, + // + Root as Tooltip, + Content as TooltipContent, + Trigger as TooltipTrigger, + Provider as TooltipProvider, + Portal as TooltipPortal, +}; diff --git a/src/lib/components/ui/tooltip/tooltip-content.svelte b/src/lib/components/ui/tooltip/tooltip-content.svelte new file mode 100644 index 0000000..2662522 --- /dev/null +++ b/src/lib/components/ui/tooltip/tooltip-content.svelte @@ -0,0 +1,52 @@ + + + + + {@render children?.()} + + {#snippet child({ props })} +
+ {/snippet} +
+
+
diff --git a/src/lib/components/ui/tooltip/tooltip-portal.svelte b/src/lib/components/ui/tooltip/tooltip-portal.svelte new file mode 100644 index 0000000..d234f7d --- /dev/null +++ b/src/lib/components/ui/tooltip/tooltip-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/tooltip/tooltip-provider.svelte b/src/lib/components/ui/tooltip/tooltip-provider.svelte new file mode 100644 index 0000000..8150bef --- /dev/null +++ b/src/lib/components/ui/tooltip/tooltip-provider.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/src/lib/components/ui/tooltip/tooltip-trigger.svelte new file mode 100644 index 0000000..1acdaa4 --- /dev/null +++ b/src/lib/components/ui/tooltip/tooltip-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/tooltip/tooltip.svelte b/src/lib/components/ui/tooltip/tooltip.svelte new file mode 100644 index 0000000..0b0f9ce --- /dev/null +++ b/src/lib/components/ui/tooltip/tooltip.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/db/items.remote.ts b/src/lib/db/items.remote.ts new file mode 100644 index 0000000..1658a59 --- /dev/null +++ b/src/lib/db/items.remote.ts @@ -0,0 +1,45 @@ +import { getRequestEvent, query } from '$app/server'; +import * as v from 'valibot'; +import sql from '$lib/db/db.server'; +import { verifyJWT } from '$lib/auth/index.server'; + +export const genDescription = query(async () => { + await new Promise((f) => setTimeout(f, 1000)); + + return 'A matte black water bottle with a black lid and a "BKLYN BENTO" logo on the side, resting on a tree trunk in a forest.'; +}); + +export const approveDenyItem = query( + v.object({ id: v.number(), approved: v.boolean() }), + async ({ id, approved }) => { + console.log('called'); + + const { cookies } = getRequestEvent(); + const userPayload = verifyJWT(cookies); + console.log('1'); + + if (approved) { + const reponse = await sql` + UPDATE items + SET + approved_date = NOW(), + emails = ( + SELECT array_agg(DISTINCT email) + FROM ( + SELECT ${userPayload.email} AS email + UNION ALL + SELECT u.email + FROM users u + WHERE (u.settings->>'notifyAllApprovedInquiries')::boolean = true + ) t + ) + WHERE id = ${id}; + `; + console.log(reponse); + } else { + await sql` + DELETE FROM items WHERE id = ${id}; + `; + } + } +); diff --git a/src/lib/db/items.server.ts b/src/lib/db/items.server.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/lib/db/users.server.ts b/src/lib/db/users.server.ts index e69de29..e7fa502 100644 --- a/src/lib/db/users.server.ts +++ b/src/lib/db/users.server.ts @@ -0,0 +1,15 @@ +import { DefaultUserSettings, type User } from '$lib/types/user'; +import sql from '$lib/db/db.server'; +import bcrypt from 'bcrypt'; + +export async function createUser(user: User): Promise { + const password_hash: string = await bcrypt.hash(user.password!, 12); + + const response = await sql` + INSERT INTO users (username, email, name, password_hash, settings, created_at, last_sign_in) + VALUES (${user.username}, ${user.email}, ${user.name}, ${password_hash}, ${sql.json(DefaultUserSettings)}, NOW(), NOW()) RETURNING id; + `; + user.id = response[0].id; + + return user; +} diff --git a/src/lib/shared.ts b/src/lib/shared.ts index 6faa9c3..ea90c63 100644 --- a/src/lib/shared.ts +++ b/src/lib/shared.ts @@ -1,5 +1,4 @@ import type { User } from '$lib/types/user'; -import { fail } from '@sveltejs/kit'; export const getCookieValue = (name: string): string => document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.pop() || ''; @@ -15,30 +14,40 @@ export const getUserFromJWT = (jwt: string): User => JSON.parse(atob(jwt.split(' // return userData; // }; -export function getFormString(data: FormData, key: string): string | undefined { - const value = data.get(key); - if (value === null) { - return undefined; - } +// export function getFormString(data: FormData, key: string): string | undefined { +// const value = data.get(key); +// if (value === null) { +// return undefined; +// } +// +// if (typeof value !== 'string') { +// throw fail(400, { +// error: `Incorrect input in field ${key}.`, +// success: false +// }); +// } +// return value.trim(); +// } - if (typeof value !== 'string') { - throw fail(400, { - error: `Incorrect input in field ${key}.`, - success: false - }); +export function getFormString(data: FormData, key: string) { + const value = data.get(key); + + if (!value) return null; + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed !== '') return trimmed; } - return value.trim(); + throw Error(`Invalid field ${key}.`); } export function getRequiredFormString(data: FormData, key: string) { const value = data.get(key); - if (typeof value !== 'string' || value === '') { - throw fail(400, { - error: `Missing required field ${key}.`, - success: false - }); + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed !== '') return trimmed; } - return value.trim(); + throw Error(`Missing/invalid required field ${key}.`); } export const dateFormatOptions: Intl.DateTimeFormatOptions = { diff --git a/src/lib/types/user.ts b/src/lib/types/user.ts index 3ad6537..83c3761 100644 --- a/src/lib/types/user.ts +++ b/src/lib/types/user.ts @@ -4,7 +4,7 @@ export interface User { email: string; name: string; password?: string; - password_hash?: string; + passwordHash?: string; settings?: UserSettings; createdAt: Date; lastSignIn: Date; @@ -17,11 +17,11 @@ export interface UserPayload { name: string; } -export interface UserSettings { +export type UserSettings = { staleItemDays: number; notifyAllApprovedInquiries?: boolean; notifyAllTurnedInInquiries?: boolean; -} +}; export const DefaultUserSettings: UserSettings = { staleItemDays: 30, diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts deleted file mode 100644 index 79c63ce..0000000 --- a/src/routes/+page.server.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { Actions, PageServerLoad } from './$types'; -import { getFormString, getRequiredFormString } from '$lib/shared'; -import { fail } from '@sveltejs/kit'; -import path from 'path'; -import sql from '$lib/db/db.server'; -import sharp from 'sharp'; -import { writeFileSync } from 'node:fs'; -import type { Item } from '$lib/types/item'; - -export const load: PageServerLoad = async () => { - const items: Item[] = await sql`SELECT * FROM items;`; - return { - items - }; -}; - -export const actions = { - create: async ({ request }) => { - const data = await request.formData(); - - const description = getRequiredFormString(data, 'description'); - const foundLocation = getFormString(data, 'foundLocation') || null; - const email = getFormString(data, 'email') || null; - const location = getFormString(data, 'location') || null; - const file = data.get('image')!; - - if (!email && location !== 'turnedIn') { - fail(400, { - error: "Email is required if it is still in finder's possession", - success: false - }); - } - - if (!(file instanceof File)) { - return fail(400, { error: 'No file uploaded or file is invalid', success: false }); - } - - // Convert File → Buffer - const inputBuffer = Buffer.from(await file.arrayBuffer()); - - // Detect format (Sharp does this internally) - const image = sharp(inputBuffer); - const metadata = await image.metadata(); - - let outputBuffer: Buffer; - - if (metadata.format === 'jpeg') { - // Already JPG → keep as-is - outputBuffer = inputBuffer; - } else { - // Convert to JPG - outputBuffer = await image - .jpeg({ quality: 90 }) // adjust if needed - .toBuffer(); - } - - let emailList = null; - if (email) { - emailList = [email]; - } - - const response = await sql` - INSERT INTO items ( - email, - description, - transferred, - found_location - ) VALUES ( - ${emailList}, - ${description}, - ${location === 'turnedIn'}, - ${foundLocation} - ) - RETURNING id; - `; - - try { - // It's a good idea to validate the filename to prevent path traversal attacks - const savePath = path.join('uploads', `${response[0]['id']}.jpg`); - - writeFileSync(savePath, outputBuffer); - } catch (err) { - console.error('File upload failed:', err); - return fail(500, { error: 'Internal server error during file upload', success: false }); - } - - return { success: true }; - } -} satisfies Actions; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index a934d05..e69de29 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,111 +0,0 @@ - - -
-
-

Found Items

-
- - Post an item - - - - Submit Found Item - - Your item will need to be approved before becoming public. - - -
- - - - - Description* - - - - - - Where did you find it? - - - - -
- - -
-
- - -
-
- - - Your Email - - - - -
- - - Cancel - - - -
-
-
-
-
- -
- {#each data.items as item (item.id)} - - {/each} - -
-
- - -{#if isGenerating} -
-

Loading...

-
-{/if} - diff --git a/src/routes/gen-desc.remote.ts b/src/routes/gen-desc.remote.ts deleted file mode 100644 index c62fafe..0000000 --- a/src/routes/gen-desc.remote.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { query } from '$app/server'; - -export const genDescription = query(async () => { - await new Promise((f) => setTimeout(f, 1000)); - - return 'A matte black water bottle with a black lid and a "BKLYN BENTO" logo on the side, resting on a tree trunk in a forest.'; -}); diff --git a/src/routes/items/+page.server.ts b/src/routes/items/+page.server.ts new file mode 100644 index 0000000..ae7f6c4 --- /dev/null +++ b/src/routes/items/+page.server.ts @@ -0,0 +1,115 @@ +import type { Actions, PageServerLoad } from '$types'; +import { fail } from '@sveltejs/kit'; +import path from 'path'; +import sql from '$lib/db/db.server'; +import sharp from 'sharp'; +import { writeFileSync } from 'node:fs'; +import type { Item } from '$lib/types/item'; +import { getFormString, getRequiredFormString } from '$lib/shared'; + +export const load: PageServerLoad = async () => { + const items: Item[] = await sql`SELECT * FROM items;`; + return { + items + }; +}; + +export const actions: Actions = { + create: async ({ request }) => { + const data = await request.formData(); + + let description: string; + let foundLocation: string | null; + let email: string | null; + let location: string | null; + + try { + description = getRequiredFormString(data, 'description'); + foundLocation = getFormString(data, 'foundLocation'); + email = getFormString(data, 'email'); + location = getFormString(data, 'location'); + + const file = data.get('image'); + if (file === null) + return fail(400, { + message: `Missing required field image.`, + success: false + }); + + if (!email && location !== 'turnedIn') { + fail(400, { + message: "Email is required if it is still in finder's possession", + success: false + }); + } + + if (!(file instanceof File)) { + return fail(400, { message: 'No file uploaded or file is invalid', success: false }); + } + + // Convert File → Buffer + const inputBuffer = Buffer.from(await file.arrayBuffer()); + + // Detect format (Sharp does this internally) + const image = sharp(inputBuffer); + const metadata = await image.metadata(); + + let outputBuffer: Buffer; + + if (metadata.format === 'jpeg') { + // Already JPG → keep as-is + outputBuffer = inputBuffer; + } else { + // Convert to JPG + outputBuffer = await image + .jpeg({ quality: 90 }) // adjust if needed + .toBuffer(); + } + + const response = await sql` + INSERT INTO items ( + emails, + description, + transferred, + found_location + ) VALUES ( + ${ + location === 'turnedIn' + ? sql`( + SELECT array_agg(DISTINCT email) + FROM ( + SELECT ${email} AS email + UNION ALL + SELECT u.email + FROM users u + WHERE (u.settings->>'notifyAllTurnedInInquiries')::boolean = true + ) t + )` + : [email] + }, + ${description}, + ${location === 'turnedIn'}, + ${foundLocation} + ) + RETURNING id; + `; + + try { + // It's a good idea to validate the filename to prevent path traversal attacks + const savePath = path.join('uploads', `${response[0]['id']}.jpg`); + + writeFileSync(savePath, outputBuffer); + } catch (err) { + console.error('File upload failed:', err); + return fail(500, { message: 'Internal server error during file upload', success: false }); + } + } catch (e) { + return fail(400, { + message: e instanceof Error ? e.message : 'Unknown error occurred', + success: false + }); + } + + return { success: true }; + } +} satisfies Actions; diff --git a/src/routes/items/+page.svelte b/src/routes/items/+page.svelte new file mode 100644 index 0000000..9fc5100 --- /dev/null +++ b/src/routes/items/+page.svelte @@ -0,0 +1,139 @@ + + +
+
+

Found Items

+
+ + Post an item + + + + Submit Found Item + + Your item will need to be approved before becoming public. + + +
+ + + + + Description* + + + + + + Where did you find it? + + + + +
+ + +
+
+ + +
+
+ + + Your Email + + + + +
+ + + Cancel + + + +
+
+
+
+
+ +
+ + {#if data.user && data.items.some( + (item) => item.approvedDate === null + )} + + Pending items + + {/if} + + {#each data.items as item (item.id)} + {#if item.approvedDate === null} + + {/if} + {/each} + + {#if data.user && data.items.some( + (item) => item.approvedDate !== null + )} + + Public items + + {/if} + + {#each data.items as item (item.id)} + {#if item.approvedDate !== null} + + {/if} + {/each} + +
+ +
+ + +{#if isGenerating} +
+

Loading...

+
+{/if} + diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index e69de29..88cf140 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -0,0 +1,20 @@ +import { type Actions, fail, redirect } from '@sveltejs/kit'; +import { getRequiredFormString } from '$lib/shared'; +import { login, setJWTCookie } from '$lib/auth/index.server'; + +export const actions = { + default: async ({ request, cookies }) => { + const data = await request.formData(); + + try { + const email = getRequiredFormString(data, 'email'); + const password = getRequiredFormString(data, 'password'); + + const user = await login(email, password); + setJWTCookie(cookies, user); + } catch (e) { + return fail(400, { message: e instanceof Error ? e.message : 'Unknown error occurred' }); + } + throw redirect(303, '/'); + } +} satisfies Actions; diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 656c796..bb23230 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -1,5 +1,14 @@
@@ -7,7 +16,41 @@
- +
+ +
+

Login to your account

+

+ Only admins need to log in.
Lost something? Go home. +

+
+ + Email + + + +
+ Password + + + +
+ + {#if form?.message} +

{form.message}

+ {/if} +
+ + + + + + + + + +
+
diff --git a/src/routes/login/login-form.svelte b/src/routes/login/login-form.svelte index 00a268d..d4c6758 100644 --- a/src/routes/login/login-form.svelte +++ b/src/routes/login/login-form.svelte @@ -8,14 +8,18 @@ import { Button } from '$lib/components/ui/button'; import { cn, type WithElementRef } from '$lib/utils.js'; import type { HTMLFormAttributes } from 'svelte/elements'; + import type { PageProps } from '$types'; let { class: className, + form, ...restProps - }: WithElementRef = $props(); + }: WithElementRef & { form?: PageProps } = $props(); + + -
+

Login to your account

@@ -25,14 +29,14 @@
Email - + diff --git a/src/routes/page.svelte.spec.ts b/src/routes/page.svelte.spec.ts index 3c6adf3..a77b497 100644 --- a/src/routes/page.svelte.spec.ts +++ b/src/routes/page.svelte.spec.ts @@ -1,7 +1,7 @@ import { page } from '@vitest/browser/context'; import { describe, expect, it } from 'vitest'; import { render } from 'vitest-browser-svelte'; -import Page from './+page.svelte'; +import Page from './items/+page.svelte'; describe('/+page.svelte', () => { it('should render h1', async () => {