Merge pull request 'api keys & better validator' (#6) from development into master

Reviewed-on: #6
This commit is contained in:
cirroskais 2024-08-02 15:37:10 +00:00
commit cadbd32474
36 changed files with 496 additions and 224 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

@ -0,0 +1,16 @@
-- AlterTable
ALTER TABLE `User` ADD COLUMN `totp` VARCHAR(191) NULL;
-- CreateTable
CREATE TABLE `APIKey` (
`id` VARCHAR(191) NOT NULL,
`userId` INTEGER NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`expiresAt` DATETIME(3) NOT NULL,
`permissions` INTEGER NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `APIKey` ADD CONSTRAINT `APIKey_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;

View file

@ -12,6 +12,7 @@ model User {
username String @unique username String @unique
email String @unique email String @unique
password String password String
totp String?
role Role role Role
createdAt DateTime @default(now()) createdAt DateTime @default(now())
lastSeen DateTime @default(now()) lastSeen DateTime @default(now())
@ -20,6 +21,7 @@ model User {
maxUploadMB Int @default(100) maxUploadMB Int @default(100)
uploads Upload[] uploads Upload[]
sessions Session[] sessions Session[]
apiKeys APIKey[]
} }
model Session { model Session {
@ -58,6 +60,15 @@ model Upload {
uploaded DateTime @default(now()) uploaded DateTime @default(now())
} }
model APIKey {
id String @id
user User @relation(fields: [userId], references: [id])
userId Int
createdAt DateTime @default(now())
expiresAt DateTime
permissions Int
}
enum Role { enum Role {
ADMINISTRATOR ADMINISTRATOR
USER USER

View file

@ -1,44 +0,0 @@
import { error, redirect } from '@sveltejs/kit';
import { getSession } from '$lib/server/database';
import { COOKIE } from '$lib/config';
const PUBLIC_RESOURCES = [
'/',
'/api',
'/api/auth/register',
'/api/auth/login',
'/terms',
'/privacy'
];
/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) {
const { cookies, locals } = event;
const session = await getSession(cookies.get(COOKIE) || '');
if (session && session.user) {
locals.user = {
id: session.user.id,
username: session.user.username,
email: session.user.email,
maxUploadMB: session.user.maxUploadMB,
role: session.user.role
};
} else {
if (event.route.id) {
if (event.route.id.includes('(app)')) return redirect(303, '/');
}
}
return await resolve(event);
}
/** @type {import('@sveltejs/kit').HandleServerError} */
export async function handleError({ error, event, status, message }) {
console.log(error);
return {
status,
message
};
}

52
src/hooks.server.ts Normal file
View file

@ -0,0 +1,52 @@
import { redirect } from '@sveltejs/kit';
import { getSession, getUserApiKey } from '$lib/server/database';
import { COOKIE } from '$lib/config';
export async function handle({ event, resolve }) {
const { cookies, locals, request } = event;
let cookie = cookies.get(COOKIE);
let bearer = request.headers.get('Authorization');
if (bearer) bearer = bearer.replace('Bearer ', '');
if (cookie) {
const session = await getSession(cookie);
if (session && session.user) {
locals.user = {
id: session.user.id,
username: session.user.username,
email: session.user.email,
maxUploadMB: session.user.maxUploadMB,
role: session.user.role
};
}
}
if (bearer && !locals.user) {
const apiKey = await getUserApiKey(bearer);
if (apiKey && apiKey.user) {
locals.user = {
id: apiKey.user.id,
username: apiKey.user.username,
email: apiKey.user.email,
maxUploadMB: apiKey.user.maxUploadMB,
role: apiKey.user.role
};
}
}
if (!locals.user && event.route.id) {
if (event.route.id.includes('(app)')) return redirect(303, '/');
}
return await resolve(event);
}
export async function handleError({ error, status, message }) {
console.log(error);
return {
status,
message
};
}

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) {
@ -58,7 +60,7 @@
type={'email'} type={'email'}
name={'email'} name={'email'}
id={'email'} id={'email'}
placeholder={'user@example.com'} placeholder={'john@doefamily.com'}
bind:value={email} bind:value={email}
required={true} required={true}
> >

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) {
@ -79,7 +80,7 @@
type={'email'} type={'email'}
name={'email'} name={'email'}
id={'email'} id={'email'}
placeholder={'user@example.com'} placeholder={'jane@doefamily.com'}
bind:value={email} bind:value={email}
required={true} required={true}
> >

View file

@ -20,7 +20,7 @@
<div class="flex my-auto space-x-3"> <div class="flex my-auto space-x-3">
<HeaderLink href="/dashboard">Dashboard</HeaderLink> <HeaderLink href="/dashboard">Dashboard</HeaderLink>
<HeaderLink href="/uploads">Uploads</HeaderLink> <HeaderLink href="/uploads">Uploads</HeaderLink>
<HeaderLink href="/links">Links</HeaderLink> <HeaderLink href="/documentation">Documentation</HeaderLink>
{#if $user?.role === 'ADMINISTRATOR'} {#if $user?.role === 'ADMINISTRATOR'}
<HeaderLink href="/admin">Admin</HeaderLink> <HeaderLink href="/admin">Admin</HeaderLink>
{/if} {/if}
@ -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-2 rounded-lg shadow-md bg-crust">
<div class="py-0.5 pr-1 border-r-2 border-overlay2"> <div class="py-0.5 pr-2 border-r-2 border-overlay0">
<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,20 +24,14 @@
{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-2 rounded-lg shadow-md bg-crust">
<div class="py-0.5 pr-1 border-r-2 border-overlay2"> <div class="py-0.5 pr-2 border-r-2 border-overlay0">
<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,20 +39,14 @@
{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-2 rounded-lg shadow-md bg-crust">
<div class="py-0.5 pr-1 border-r-2 border-overlay2"> <div class="py-0.5 pr-2 border-r-2 border-overlay0">
<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,15 +1,17 @@
<script> <script>
export let href, export let href,
download,
style = 'link'; style = 'link';
</script> </script>
{#if style === 'link'} {#if style === 'link'}
<a {href} class="text-blue-500"> <a {href} {download} class="text-blue-500">
<slot /> <slot />
</a> </a>
{:else if style === 'button'} {:else if style === 'button'}
<a <a
{href} {href}
{download}
class="flex p-2 space-x-2 rounded-lg border-b-2 hadow-md bg-crust border-overlay2 hover:border-overlay0 group-disabled/button:border-overlay0 group-disabled/button:hover:border-overlay0" class="flex p-2 space-x-2 rounded-lg border-b-2 hadow-md bg-crust border-overlay2 hover:border-overlay0 group-disabled/button:border-overlay0 group-disabled/button:hover:border-overlay0"
> >
<slot /> <slot />

View file

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

View file

@ -12,3 +12,25 @@ export const MAIL_WHITELIST = [
'pm.me', 'pm.me',
'proton.me' 'proton.me'
]; ];
export const DOMAINS = [
'cdn.cirroskais.xyz',
'cdn.madhouselabs.net',
'snep.lol',
'i.chadthundercock.com',
'doing-ya.mom',
'*.is-gay.zip'
];
export const DEV_DOMAINS = ['cdn.dev.madhouselabs.net'];
export const API_KEY_PERMISSIONS = {
CREATE_UPLOADS: 1 << 0,
READ_UPLOADS: 1 << 1,
UPDATE_UPLOADS: 1 << 2,
DELETE_UPLOADS: 1 << 3,
READ_ACCOUNT: 1 << 4,
UPDATE_ACCOUNT_ACCOUNT_SETTINGS: 1 << 5, // allows for updating username & email. bad idea? probably.
UPDATE_ACCOUNT_EMBED_SETTINGS: 1 << 6
};

View file

@ -1,7 +1,18 @@
import { COOKIE } from '$lib/config'; import { COOKIE } from '$lib/config';
import type { Cookies } from '@sveltejs/kit'; import type { Cookies } from '@sveltejs/kit';
import { getSession } from './database'; import { getSession, getUserApiKey } from './database';
import type { User, UserSettings } from '@prisma/client'; import type { Role, UserSettings } from '@prisma/client';
interface User {
id: number;
username: string;
email: string;
password: string;
role: Role;
createdAt: Date;
lastSeen: Date;
maxUploadMB: number;
}
interface UserAndMaybeSettings extends User { interface UserAndMaybeSettings extends User {
settings: UserSettings | null; settings: UserSettings | null;
@ -14,7 +25,10 @@ export async function authenticate(request: Request, cookies: Cookies) {
let user: UserAndMaybeSettings | false = false; let user: UserAndMaybeSettings | false = false;
if (bearer && !cookie) { if (bearer && !cookie) {
return false; const key = await getUserApiKey(bearer);
if (key) {
user = key.user;
}
} }
if (cookie && !bearer) { if (cookie && !bearer) {

View file

@ -138,3 +138,62 @@ export async function getSettings(id: number) {
} }
}); });
} }
export async function getUserApiKeys(userId: number) {
if (!userId) return false;
return await prisma.aPIKey.findMany({
where: {
userId: userId
}
});
}
export async function createUserApiKey(userId: number, permissions: number, expiresAt: Date) {
if (!userId) return false;
return await prisma.aPIKey.create({
data: {
id: randomBytes(42).toString('base64url'),
userId,
permissions,
expiresAt
}
});
}
export async function deleteUserApiKey(userId: number, id: string) {
if (!userId || !id) return false;
return await prisma.aPIKey.delete({
where: {
userId,
id
}
});
}
export async function getUserApiKey(id: string) {
if (!id) return false;
return await prisma.aPIKey.findFirst({
where: {
id
},
include: {
user: {
select: {
id: true,
username: true,
email: true,
password: true,
role: true,
createdAt: true,
lastSeen: true,
maxUploadMB: true,
settings: true
}
}
}
});
}

View file

@ -16,7 +16,7 @@ const ApiKeyLimits = {
setInterval(function resetMinute() {}, 1000 * 60); setInterval(function resetMinute() {}, 1000 * 60);
setInterval(function resetHour() {}, 1000 * 60 * 60); setInterval(function resetHour() {}, 1000 * 60 * 60);
function hash(input) { function hash(input: string) {
createHash('sha256').update(input).digest('hex'); createHash('sha256').update(input).digest('hex');
} }

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

@ -0,0 +1,43 @@
<script lang="ts">
import { page } from '$app/stores';
import Button from '$lib/components/Inputs/Button.svelte';
import Link from '$lib/components/Inputs/Link.svelte';
import { API_KEY_PERMISSIONS } from '$lib/config';
import { get } from 'svelte/store';
let awesome = '';
async function click() {
const response = await fetch('/api/v1/keys');
const body = (await response.json()) as { id: string; permissions: number }[];
const key = body.find((key) => key.permissions & API_KEY_PERMISSIONS.CREATE_UPLOADS);
if (!key) return (awesome = 'What the fuck did i tell you');
awesome = `{
"Version": "14.0.0",
"Name": "cirros file uploader",
"DestinationType": "ImageUploader, FileUploader",
"RequestMethod": "POST",
"RequestURL": "${get(page).url.origin}/api/v1/upload",
"Headers": {
"Authorization": "Bearer ${key.id}",
},
"Body": "MultipartFormData",
"FileFormName": "file",
"URL": "${get(page).url.origin}{response}",
}`;
}
</script>
<p>I'll make real documentation later but for now have this ShareX button</p>
<p class="mb-2">
MAKE SURE TO HAVE A VALID API KEY WITH THE CREATE_UPLOADS PERMISSION ( 1 &lt;&lt; 0 )
</p>
<Button {click}>The sharex button in question</Button>
<tt class="block mt-3 whitespace-pre-wrap">{awesome}</tt>
{#if awesome}
<Link href="data:text/plain;base64,{btoa(awesome)}" download={'uploader.sxcu'}>Download</Link>
{/if}

View file

@ -0,0 +1,7 @@
import { getUserApiKeys } from '$lib/server/database.js';
export async function load({ locals }) {
return {
keys: (await getUserApiKeys(locals.user.id)) || []
};
}

View file

@ -1,6 +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 { invalidateAll } from '$app/navigation';
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">
@ -18,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.
@ -26,13 +43,14 @@
</div> </div>
<div class="mb-1"> <div class="mb-1">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label class="text-lg font-bold" for="username">Email</label> <label class="text-lg font-bold" for="email">Email</label>
<input <input
class="px-2 h-8 rounded-lg ring-1 transition-all bg-mantle ring-surface2 focus-visible:outline-none focus-visible:outline-overlay0" class="px-2 h-8 rounded-lg ring-1 transition-all bg-mantle ring-surface2 focus-visible:outline-none focus-visible:outline-overlay0"
type="email" type="email"
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.
@ -47,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>
@ -60,16 +78,9 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> {#each data.keys as key}
<th> <ListedApiKey {key} />
<tt class="text-md">d8895a0c-daa5-4b6e-b66f-2494c039fe9e </tt> {/each}
</th>
<th class="flex">
<button class="m-auto hover:text-red">
<X></X>
</button>
</th>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@ -93,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>
@ -165,8 +178,8 @@
</div> </div>
<div> <div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-0.5">
<label class="text-lg font-bold" for="description">2FA</label> <p class="text-lg font-bold">2FA</p>
<p class="text-sm text-surface2"> <p class="text-sm text-surface2">
A One-Time Password will be required each time you login. A One-Time Password will be required each time you login.
</p> </p>
@ -184,7 +197,7 @@
<span class="w-full border-b-2 border-surface0" /> <span class="w-full border-b-2 border-surface0" />
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class=""> <div class="">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-0.5">
<div> <div>
<input type="checkbox" name="newPostsPublic" id="newPostsPublic" /> <input type="checkbox" name="newPostsPublic" id="newPostsPublic" />
<label class="" for="newPostsPublic">New Posts Public</label> <label class="" for="newPostsPublic">New Posts Public</label>
@ -195,10 +208,10 @@
</div> </div>
</div> </div>
<div class=""> <div class="">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-0.5">
<div> <div>
<input type="checkbox" name="newPostsPublic" id="newPostsPublic" /> <input type="checkbox" name="encryptUploads" id="encryptUploads" />
<label class="" for="newPostsPublic">Encrypt Uploads</label> <label class="" for="encryptUploads">Encrypt Uploads</label>
</div> </div>
<p class="text-sm text-surface2">Enable encryption for new uploads. (Client-side)</p> <p class="text-sm text-surface2">Enable encryption for new uploads. (Client-side)</p>
</div> </div>

View file

@ -3,13 +3,11 @@ import { error } from '@sveltejs/kit';
export async function load({ locals, url }) { export async function load({ locals, url }) {
if (!locals.user) return error(403, { status: 403, message: 'Forbidden' }); if (!locals.user) return error(403, { status: 403, message: 'Forbidden' });
if (+(url.searchParams.get('i') || 0) < 0) error(400, { status: 403, message: 'Invalid Index' }); if (+(url.searchParams.get('i') || 0) < 0) error(400, { status: 403, message: 'Invalid Index' });
const totalUploads = await prisma.upload.count(); const totalUploads = await prisma.upload.count({ where: { uploaderId: locals.user.id } });
if (!totalUploads) return { uploads: [], totalUploads: 0 }; if (!totalUploads) return { uploads: [], totalUploads: 0 };
if (+(url.searchParams.get('i') || 0) >= Math.ceil(totalUploads / 15)) if (+(url.searchParams.get('i') || 0) >= Math.ceil(totalUploads / 15))
error(400, { status: 403, message: 'Invalid Index' }); error(400, { status: 403, message: 'Invalid Index' });

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: '/', sameSite: 'strict' });
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: '/', sameSite: 'strict' });
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

@ -0,0 +1,29 @@
import { API_KEY_PERMISSIONS } from '$lib/config.js';
import { createUserApiKey, deleteUserApiKey, getUserApiKeys } from '$lib/server/database';
import { error, json } from '@sveltejs/kit';
export async function GET({ locals }) {
return json(await getUserApiKeys(locals.user.id));
}
export async function POST({ locals }) {
return json(
await createUserApiKey(
locals.user.id,
API_KEY_PERMISSIONS.CREATE_UPLOADS | API_KEY_PERMISSIONS.READ_ACCOUNT,
new Date('2099')
)
);
}
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

@ -4,7 +4,8 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
kit: { kit: {
adapter: adapter() adapter: adapter(),
csrf: { checkOrigin: false }
}, },
preprocess: vitePreprocess() preprocess: vitePreprocess()
}; };

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==