items revisions
ci / docker_image (push) Successful in 2m56s
ci / deploy (push) Successful in 26s

This commit is contained in:
2026-02-04 01:30:46 -06:00
parent 4ea6549ac7
commit 8ea632a14f
25 changed files with 621 additions and 280 deletions
+20 -26
View File
@@ -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<User> {
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<User> {
return user;
}
} catch {
throw Error('Error signing in ');
}
throw Error('Invalid email or password');
}
// await createUser(<User>{
// name: 'Drake',
// username: 'drake',
// email: 'drake@marinodev.com',
// password: 'password',
// perms: 255,
// name: 'Drake'
// password: 'password'
// });
+61 -15
View File
@@ -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 = <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);
}
</script>
<a href="items/{item.id}"
class="bg-card text-card-foreground flex flex-col gap-2 rounded-xl border shadow-sm max-w-sm overflow-hidden min-2-3xs">
<div
class="h-full bg-card text-card-foreground flex flex-col gap-2 rounded-xl border shadow-sm max-w-sm overflow-hidden min-2-3xs">
<img src="https://fbla26.marinodev.com/uploads/{item.id}.jpg" alt="" class="object-cover max-h-48">
<div class="px-2 pb-2">
<div class="flex-col flex h-full px-2 pb-2">
<!-- <div class="font-bold inline-block">{item.title}</div>-->
<!-- <div class="inline-block">-->
<div>
<!-- <div class="font-bold inline-block">{item.title}</div>-->
<!-- <div class="inline-block">-->
{#if item.transferred}
<Badge variant="secondary" class="float-right">In Lost & Found</Badge>
<Badge variant="secondary" class="inline-block">In Lost & Found</Badge>
{:else}
<Badge variant="outline" class="float-right">With Finder</Badge>
{/if}
<div>{item.description}</div>
{#if item.foundLocation}
<div class="pt-2">
<LocationIcon class="float-left mr-1" size={24} />
<div>{item.foundLocation}</div>
</div>
<Badge variant="secondary" class="inline-block">With Finder</Badge>
{/if}
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger
>
<Badge variant="outline" class="inline-block">{timeSincePosted}
day{(timeSincePosted === 1 || timeSincePosted === '<1') ? '' : 's'} ago
</Badge>
</Tooltip.Trigger
>
<Tooltip.Content>
<p>{item.foundDate.toLocaleDateString('en-US', dateFormatOptions)}</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</div>
<div class="flex-1">{item.description}</div>
{#if item.foundLocation}
<div class="mt-2">
<LocationIcon class="float-left mr-1" size={24} />
<div>{item.foundLocation}</div>
</div>
{/if}
{#if admin && item.approvedDate === null}
<div class="mt-2 justify-between flex">
<Button variant="ghost" class="text-positive"
onclick={async () => {await approveDenyItem({id: item.id, approved: true});
invalidateAll()}}>
<CheckIcon />
Approve
</Button>
<Button variant="ghost" class="text-destructive"
onclick={async () => {await approveDenyItem({id: item.id, approved: false});
invalidateAll()}}>
<XIcon />
Deny
</Button>
</div>
{/if}
</div>
</a>
</div>
+19
View File
@@ -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,
};
@@ -0,0 +1,52 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import TooltipPortal from "./tooltip-portal.svelte";
import type { ComponentProps } from "svelte";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 0,
side = "top",
children,
arrowClasses,
portalProps,
...restProps
}: TooltipPrimitive.ContentProps & {
arrowClasses?: string;
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof TooltipPortal>>;
} = $props();
</script>
<TooltipPortal {...portalProps}>
<TooltipPrimitive.Content
bind:ref
data-slot="tooltip-content"
{sideOffset}
{side}
class={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--bits-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...restProps}
>
{@render children?.()}
<TooltipPrimitive.Arrow>
{#snippet child({ props })}
<div
class={cn(
"bg-foreground z-50 size-2.5 rotate-45 rounded-[2px]",
"data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]",
"data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]",
"data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2",
"data-[side=left]:-translate-y-[calc(50%_-_3px)]",
arrowClasses
)}
{...props}
></div>
{/snippet}
</TooltipPrimitive.Arrow>
</TooltipPrimitive.Content>
</TooltipPortal>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
let { ...restProps }: TooltipPrimitive.PortalProps = $props();
</script>
<TooltipPrimitive.Portal {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
let { ...restProps }: TooltipPrimitive.ProviderProps = $props();
</script>
<TooltipPrimitive.Provider {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: TooltipPrimitive.TriggerProps = $props();
</script>
<TooltipPrimitive.Trigger bind:ref data-slot="tooltip-trigger" {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: TooltipPrimitive.RootProps = $props();
</script>
<TooltipPrimitive.Root bind:open {...restProps} />
+45
View File
@@ -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};
`;
}
}
);
View File
+15
View File
@@ -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<User> {
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;
}
+27 -18
View File
@@ -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 = {
+3 -3
View File
@@ -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,