better validator

This commit is contained in:
cirroskais 2024-08-02 09:33:34 -04:00
parent 303f1a232f
commit 7745b07418
No known key found for this signature in database
GPG key ID: 5FC73EBF2678E33D
23 changed files with 223 additions and 150 deletions

View file

@ -33,6 +33,6 @@
"mime": "^4.0.4", "mime": "^4.0.4",
"minio": "^7.1.4", "minio": "^7.1.4",
"svelte-sonner": "^0.3.27", "svelte-sonner": "^0.3.27",
"validator": "^13.12.0" "zod": "^3.23.8"
} }
} }

View file

@ -25,7 +25,7 @@
{#if visible} {#if visible}
<div <div
transition:slide transition:slide
class="w-[12rem] z-10 h-fit translate-y-12 py-2 bg-crust rounded-lg origin-top right-0 absolute transition-all shadow-md translate-x-3.5" class="w-[12rem] z-10 h-fit translate-y-11 py-2 bg-crust rounded-lg origin-top right-0 absolute transition-all shadow-md translate-x-4 outline outline-1 outline-surface0"
> >
<slot /> <slot />
</div> </div>

View file

@ -1,4 +1,5 @@
<script> <script>
// @ts-ignore
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { Mail, SquareAsterisk, LogIn, Undo } from 'lucide-svelte'; import { Mail, SquareAsterisk, LogIn, Undo } from 'lucide-svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
@ -35,6 +36,7 @@
method: 'POST', method: 'POST',
body: JSON.stringify({ email, password }) body: JSON.stringify({ email, password })
}).catch((_) => toast.error(_.message)); }).catch((_) => toast.error(_.message));
// @ts-ignore
const body = await response.json().catch((_) => toast.error(_.message)); const body = await response.json().catch((_) => toast.error(_.message));
if (!body?.success) { if (!body?.success) {

View file

@ -46,6 +46,7 @@
method: 'POST', method: 'POST',
body: JSON.stringify({ username, email, password }) body: JSON.stringify({ username, email, password })
}).catch((_) => toast.error(_.message)); }).catch((_) => toast.error(_.message));
// @ts-ignore
const body = await response.json().catch((_) => toast.error(_.message)); const body = await response.json().catch((_) => toast.error(_.message));
if (!body?.success) { if (!body?.success) {

View file

@ -47,7 +47,7 @@
<DropdownButton href="/settings"> <DropdownButton href="/settings">
<p class="my-auto">Settings</p> <p class="my-auto">Settings</p>
</DropdownButton> </DropdownButton>
<DropdownButton href="/api/auth/logout"> <DropdownButton href="/api/v1/auth/logout">
<p class="my-auto text-red-500">Logout</p> <p class="my-auto text-red-500">Logout</p>
</DropdownButton> </DropdownButton>
</Dropdown> </Dropdown>

View file

@ -1,18 +1,22 @@
<script> <script lang="ts">
import { CircleAlert, Check } from 'lucide-svelte'; import { CircleAlert, Check } from 'lucide-svelte';
export let type, name, id, placeholder, value, required; export let type, name, id, placeholder, value: string, required;
</script> </script>
<!-- insane that i have to do this because --> <!--
<!-- 'type' attribute cannot be dynamic if input uses two-way binding --> insane that i have to do this because
'type' attr cannot be dynamic if input
uses two-way binding
-->
{#if type === 'username'} {#if type === 'username'}
<div class="flex p-2 space-x-1 rounded-lg shadow-md bg-crust"> <div class="flex p-2 space-x-1 rounded-lg shadow-md bg-crust">
<div class="py-0.5 pr-1 border-r-2 border-overlay2"> <div class="py-0.5 pr-1 border-r-2 border-overlay2">
<slot /> <slot />
</div> </div>
<input <input
class="py-0.5 bg-crust peer placeholder:text-overlay1" class="py-0.5 w-64 bg-crust placeholder:text-overlay1"
type="username" type="username"
{name} {name}
{id} {id}
@ -20,12 +24,6 @@
{required} {required}
bind:value bind:value
/> />
<div class="hidden my-auto peer-invalid:flex">
<CircleAlert />
</div>
<div class="my-auto peer-invalid:hidden">
<Check />
</div>
</div> </div>
{:else if type === 'email'} {:else if type === 'email'}
<div class="flex p-2 space-x-1 rounded-lg shadow-md bg-crust"> <div class="flex p-2 space-x-1 rounded-lg shadow-md bg-crust">
@ -33,7 +31,7 @@
<slot /> <slot />
</div> </div>
<input <input
class="py-0.5 bg-crust peer placeholder:text-overlay1" class="py-0.5 w-64 bg-crust placeholder:text-overlay1"
type="email" type="email"
{name} {name}
{id} {id}
@ -41,12 +39,6 @@
{required} {required}
bind:value bind:value
/> />
<div class="hidden my-auto peer-invalid:flex">
<CircleAlert />
</div>
<div class="my-auto peer-invalid:hidden">
<Check />
</div>
</div> </div>
{:else if type === 'password'} {:else if type === 'password'}
<div class="flex p-2 space-x-1 rounded-lg shadow-md bg-crust"> <div class="flex p-2 space-x-1 rounded-lg shadow-md bg-crust">
@ -54,7 +46,7 @@
<slot /> <slot />
</div> </div>
<input <input
class="py-0.5 bg-crust peer placeholder:text-overlay1" class="py-0.5 w-64 bg-crust placeholder:text-overlay1"
type="password" type="password"
{name} {name}
{id} {id}
@ -62,11 +54,5 @@
{required} {required}
bind:value bind:value
/> />
<div class="hidden my-auto peer-invalid:flex">
<CircleAlert />
</div>
<div class="my-auto peer-invalid:hidden">
<Check />
</div>
</div> </div>
{/if} {/if}

View file

@ -1,7 +1,15 @@
<script> <script lang="ts">
import { invalidateAll } from '$app/navigation';
import { X } from 'lucide-svelte'; import { X } from 'lucide-svelte';
export let key; export let key: { id: string };
function remove() {
fetch('/api/v1/keys', {
method: 'DELETE',
body: JSON.stringify({ key: key.id })
}).then(() => invalidateAll());
}
</script> </script>
<tr> <tr>
@ -9,7 +17,7 @@
<tt class="text-md">{key.id}</tt> <tt class="text-md">{key.id}</tt>
</th> </th>
<th class="flex"> <th class="flex">
<button class="m-auto hover:text-red"> <button class="m-auto hover:text-red" on:click={remove}>
<X></X> <X></X>
</button> </button>
</th> </th>

View file

@ -161,3 +161,14 @@ export async function createUserApiKey(userId: number, permissions: number, expi
} }
}); });
} }
export async function deleteUserApiKey(userId: number, id: string) {
if (!userId || !id) return false;
return await prisma.aPIKey.delete({
where: {
userId,
id
}
});
}

View file

@ -1,8 +0,0 @@
import validator from 'validator';
import { MAIL_WHITELIST } from '$lib/config';
// https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators
export function email(input) {
return validator.isEmail(input, { host_whitelist: MAIL_WHITELIST });
}

View file

@ -0,0 +1,42 @@
import { z } from 'zod';
import { findUser } from './database';
const INTERNAL_email = z.string().email('Invalid email address.');
export const email = INTERNAL_email.parse;
export const emailAndNotUsed = INTERNAL_email.refine(async (_) => {
return !Boolean(await findUser({ email: _ }));
}, 'Email is already being used.').parseAsync;
const INTERNAL_username = z
.string()
.min(3, 'Username must be at least 3 characters.')
.max(16, 'Usernames must be no more than 16 characters.')
.regex(
new RegExp(/^[A-z0-9\_\-\.]+$/g),
'Usernames must be alphanumeric with dashes, underscores, and periods.'
);
export const username = INTERNAL_username.parse;
export const usernameAndNotUsed = INTERNAL_username.refine(async (_) => {
return !Boolean(await findUser({ username: _ }));
}, 'Username is already being used.').parseAsync;
export const password = z
.string()
.min(6, 'Passwords must be longer than 6 characters.')
.max(128, 'You do not need a password longer than 128 fucking characters.').parse;
export const embedTitle = z
.string()
.max(256, 'Title must not be longer than 256 characters.').parse;
export const embedDescription = z
.string()
.max(2000, 'Description must not be longer than 2000 characters.').parse;
export const embedColor = z
.number()
.max(parseInt('ffffff', 16), 'Color must be less than 16777215.').parse;

View file

@ -1,9 +1,21 @@
<script> <script lang="ts">
import { Info, X, Plus } from 'lucide-svelte'; import { Info, X, Plus } from 'lucide-svelte';
import Button from '$lib/components/Inputs/Button.svelte'; import Button from '$lib/components/Inputs/Button.svelte';
import ListedApiKey from '$lib/components/ListedAPIKey.svelte'; import ListedApiKey from '$lib/components/ListedAPIKey.svelte';
import { invalidateAll } from '$app/navigation';
export let data; export let data;
let username: string, email: string;
let title: string, description: string, color: string;
let opassword: string, npassword: string, cpassword: string;
let newPostsPublic: boolean, encryptUploads: boolean;
async function createApiKey() {
fetch('/api/v1/keys', {
method: 'POST'
}).then(() => invalidateAll());
}
</script> </script>
<div class="grid grid-cols-1 gap-2 mx-auto xl:grid-cols-3 w-fit"> <div class="grid grid-cols-1 gap-2 mx-auto xl:grid-cols-3 w-fit">
@ -21,6 +33,8 @@
name="username" name="username"
id="username" id="username"
placeholder="cirro" placeholder="cirro"
maxlength="16"
bind:value={username}
/> />
<p class="text-sm text-surface2"> <p class="text-sm text-surface2">
Your username is used to identify you around the site. You can change it at any time. Your username is used to identify you around the site. You can change it at any time.
@ -36,6 +50,7 @@
name="email" name="email"
id="email" id="email"
placeholder="c*******@madhouselabs.net" placeholder="c*******@madhouselabs.net"
bind:value={email}
/> />
<p class="text-sm text-surface2"> <p class="text-sm text-surface2">
Changing your email may require you to verify ownership. Changing your email may require you to verify ownership.
@ -50,7 +65,7 @@
<div class="flex flex-col gap-2 p-2 max-w-lg rounded-lg bg-crust"> <div class="flex flex-col gap-2 p-2 max-w-lg rounded-lg bg-crust">
<p class="flex text-xl font-bold"> <p class="flex text-xl font-bold">
API Keys API Keys
<button class="my-auto ml-auto hover:text-blue"> <button class="my-auto ml-auto hover:text-blue" on:click={createApiKey}>
<Plus></Plus> <Plus></Plus>
</button> </button>
</p> </p>
@ -89,23 +104,25 @@
name="title" name="title"
id="title" id="title"
placeholder={`{{ file }}`} placeholder={`{{ file }}`}
maxlength="256"
bind:value={title}
/> />
<p class="text-sm text-surface2">The title shown on the embed.</p> <p class="text-sm text-surface2">The title shown on the embed. Max 256 characters.</p>
</div> </div>
</div> </div>
<div> <div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label class="text-lg font-bold" for="description">Desciption</label> <label class="text-lg font-bold" for="description">Desciption</label>
<textarea <textarea
class="px-2 py-1 h-48 rounded-lg ring-1 transition-all bg-mantle ring-surface2 focus-visible:outline-none focus-visible:outline-overlay0" class="px-2 py-1 h-48 rounded-lg ring-1 transition-all resize-none bg-mantle ring-surface2 focus-visible:outline-none focus-visible:outline-overlay0"
name="description" name="description"
id="description" id="description"
placeholder={`Uploaded by {{ username }} at {{ time }}`} placeholder={`Uploaded by {{ username }} at {{ time }}`}
maxlength="2000"
bind:value={description}
></textarea> ></textarea>
<p class="text-sm text-surface2"> <p class="text-sm text-surface2">The description of the embed. Max 2000 characters.</p>
The description of the embed. Can have up to 2000 characters.
</p>
</div> </div>
</div> </div>

View file

@ -1,4 +1,3 @@
/** @type {import('./$types').RequestHandler} */
export function GET() { export function GET() {
return new Response('OK'); return new Response('OK');
} }

View file

@ -1,39 +0,0 @@
import { json } from '@sveltejs/kit';
import { COOKIE } from '$lib/config';
import { findUser, createSession } from '$lib/server/database';
import { email } from '$lib/server/validator';
import { verifyHash } from '$lib/server/crypto';
/** @type {import('./$types').RequestHandler} */
export async function POST(event) {
const { request, cookies } = event;
const body = await request.json();
if (!body?.email || !email(body?.email))
return json({ error: 'Invalid email.' }, { status: 400 });
if (!body?.password || body?.password.length > 128 || body?.password.length < 6)
return json({ error: 'Invalid password.' }, { status: 400 });
const user = await findUser({ email: body?.email });
if (!user) return json({ error: 'User record not found.' }, { status: 401 });
if (!(await verifyHash(user.password, body?.password)))
return json({ error: 'User record not found.' }, { status: 401 });
const session = await createSession(user.id);
cookies.set(COOKIE, session.id, { path: '/' });
return json(
{
success: true,
data: {
id: user.id,
username: user.username,
email: user.email
}
},
{ status: 200 }
);
}

View file

@ -0,0 +1,47 @@
import { json } from '@sveltejs/kit';
import { COOKIE } from '$lib/config';
import { findUser, createSession } from '$lib/server/database';
import { email } from '$lib/server/validator';
import { verifyHash } from '$lib/server/crypto';
import * as validator from '$lib/server/validator';
import { ZodError } from 'zod';
export async function POST({ request, cookies }) {
const body = (await request.json()) as {
email?: string;
password?: string;
};
try {
const email = validator.email(body.email);
const password = validator.password(body.password);
const user = await findUser({ email: email });
if (!user) return json({ error: 'User record not found.' }, { status: 401 });
if (!(await verifyHash(user.password, password)))
return json({ error: 'User record not found.' }, { status: 401 });
const session = await createSession(user.id);
cookies.set(COOKIE, session.id, { path: '/' });
return json(
{
success: true,
data: {
id: user.id,
username: user.username,
email: user.email
}
},
{ status: 200 }
);
} catch (e) {
if (e instanceof ZodError) {
return json({ error: e.errors[0].message }, { status: 400 });
} else {
return json({ error: 'Internal Server Error' }, { status: 500 });
}
}
}

View file

@ -2,9 +2,11 @@ import { redirect } from '@sveltejs/kit';
import { getSession, deleteSession } from '$lib/server/database'; import { getSession, deleteSession } from '$lib/server/database';
import { COOKIE } from '$lib/config'; import { COOKIE } from '$lib/config';
/** @type {import('./$types').RequestHandler} */
export async function GET({ cookies }) { export async function GET({ cookies }) {
const session = await getSession(cookies.get(COOKIE)); const cookie = cookies.get(COOKIE);
if (!cookie) return redirect(302, '/');
const session = await getSession(cookie);
if (!session) { if (!session) {
cookies.delete(COOKIE, { path: '/' }); cookies.delete(COOKIE, { path: '/' });
return redirect(302, '/'); return redirect(302, '/');

View file

@ -1,45 +0,0 @@
import { json } from '@sveltejs/kit';
import { COOKIE } from '$lib/config';
import { createUser, createSession, findUser } from '$lib/server/database';
import { email } from '$lib/server/validator';
export async function POST(event) {
const { request, cookies } = event;
const body = await request.json();
if (!body?.username || body?.username.length > 16 || body?.username.length < 3)
return json({ error: 'Invalid username.' }, { status: 400 });
if (!body?.email || !email(body?.email))
return json({ error: 'Invalid email.' }, { status: 400 });
if (!body?.password || body?.password.length > 128 || body?.password.length < 6)
return json({ error: 'Invalid password.' }, { status: 400 });
const usernameTaken = !!(await findUser({ username: body?.username }));
if (usernameTaken) return json({ error: 'That username is taken.' }, { status: 400 });
const emailUsed = !!(await findUser({ email: body?.email }));
if (emailUsed)
return json({ error: 'That email has been used too many times.' }, { status: 400 });
const user = await createUser(body?.username, body?.email, body?.password).catch((e) => {});
if (!user) return json({ error: 'Internal Server Error' }, { status: 500 });
const session = await createSession(user.id);
if (!session) return json({ error: 'Internal Server Error' }, { status: 500 });
cookies.set(COOKIE, session.id, { path: '/' });
return json(
{
success: true,
data: {
id: user.id,
username: user.username,
email: user.email
}
},
{ status: 200 }
);
}

View file

@ -0,0 +1,42 @@
import { json } from '@sveltejs/kit';
import { COOKIE } from '$lib/config';
import { createUser, createSession } from '$lib/server/database';
import * as validator from '$lib/server/validator';
import { ZodError } from 'zod';
export async function POST({ request, cookies }) {
const body = (await request.json()) as {
username?: string;
password?: string;
email?: string;
};
try {
const username = await validator.usernameAndNotUsed(body.username);
const email = await validator.emailAndNotUsed(body.email);
const password = validator.password(body.password);
const user = await createUser(username, email, password);
const session = await createSession(user.id);
cookies.set(COOKIE, session.id, { path: '/' });
return json(
{
success: true,
data: {
id: user.id,
username: user.username,
email: user.email
}
},
{ status: 200 }
);
} catch (e) {
if (e instanceof ZodError) {
return json({ error: e.errors[0].message }, { status: 400 });
} else {
return json({ error: 'Internal Server Error' }, { status: 500 });
}
}
}

View file

@ -1,6 +1,6 @@
import { API_KEY_PERMISSIONS } from '$lib/config.js'; import { API_KEY_PERMISSIONS } from '$lib/config.js';
import { createUserApiKey, getUserApiKeys } from '$lib/server/database'; import { createUserApiKey, deleteUserApiKey, getUserApiKeys } from '$lib/server/database';
import { json } from '@sveltejs/kit'; import { error, json } from '@sveltejs/kit';
export async function GET({ locals }) { export async function GET({ locals }) {
return json(await getUserApiKeys(locals.user.id)); return json(await getUserApiKeys(locals.user.id));
@ -15,3 +15,15 @@ export async function POST({ locals }) {
) )
); );
} }
export async function DELETE({ locals, request }) {
const body = (await request.json().catch(() => {})) as { key?: string };
if (!body?.key) error(400, { status: 400, message: 'Missing "key" value.' });
if (!locals.user) error(401, { status: 401, message: 'Unauthorized' });
return json(
await deleteUserApiKey(locals.user.id, body.key).catch((_) =>
error(400, { status: 400, message: 'API key does not exist.' })
)
);
}

View file

@ -1,10 +1,9 @@
import { bytesToHumanReadable } from '$lib'; import { bytesToHumanReadable } from '$lib';
import prisma from '$lib/server/database'; import prisma from '$lib/server/database';
import minio, { USAGE } from '$lib/server/minio'; import { USAGE } from '$lib/server/minio';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
/** @type {import("@sveltejs/kit").RequestHandler} */
export async function GET() { export async function GET() {
return json({ return json({
users: await prisma.user.count({}), users: await prisma.user.count({}),

View file

@ -1,8 +0,0 @@
import { json } from '@sveltejs/kit';
/** @type {import('./$types').RequestHandler} */
export async function GET(event) {
const { request, cookies, locals } = event;
return json(locals?.user);
}

View file

@ -0,0 +1,5 @@
import { json } from '@sveltejs/kit';
export async function GET({ locals }) {
return json(locals.user);
}

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -1787,11 +1787,6 @@ util@^0.12.3:
is-typed-array "^1.1.3" is-typed-array "^1.1.3"
which-typed-array "^1.1.2" which-typed-array "^1.1.2"
validator@^13.12.0:
version "13.12.0"
resolved "https://registry.yarnpkg.com/validator/-/validator-13.12.0.tgz#7d78e76ba85504da3fee4fd1922b385914d4b35f"
integrity sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==
vite@^5.3.5: vite@^5.3.5:
version "5.3.5" version "5.3.5"
resolved "https://registry.yarnpkg.com/vite/-/vite-5.3.5.tgz#b847f846fb2b6cb6f6f4ed50a830186138cb83d8" resolved "https://registry.yarnpkg.com/vite/-/vite-5.3.5.tgz#b847f846fb2b6cb6f6f4ed50a830186138cb83d8"
@ -1870,3 +1865,8 @@ yaml@^2.3.4:
version "2.5.0" version "2.5.0"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.5.0.tgz#c6165a721cf8000e91c36490a41d7be25176cf5d" resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.5.0.tgz#c6165a721cf8000e91c36490a41d7be25176cf5d"
integrity sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw== integrity sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==
zod@^3.23.8:
version "3.23.8"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==