Merge pull request 'api keys & better validator' (#6) from development into master
Reviewed-on: #6
This commit is contained in:
commit
cadbd32474
36 changed files with 496 additions and 224 deletions
|
@ -33,6 +33,6 @@
|
|||
"mime": "^4.0.4",
|
||||
"minio": "^7.1.4",
|
||||
"svelte-sonner": "^0.3.27",
|
||||
"validator": "^13.12.0"
|
||||
"zod": "^3.23.8"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
|
@ -12,6 +12,7 @@ model User {
|
|||
username String @unique
|
||||
email String @unique
|
||||
password String
|
||||
totp String?
|
||||
role Role
|
||||
createdAt DateTime @default(now())
|
||||
lastSeen DateTime @default(now())
|
||||
|
@ -20,6 +21,7 @@ model User {
|
|||
maxUploadMB Int @default(100)
|
||||
uploads Upload[]
|
||||
sessions Session[]
|
||||
apiKeys APIKey[]
|
||||
}
|
||||
|
||||
model Session {
|
||||
|
@ -58,6 +60,15 @@ model Upload {
|
|||
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 {
|
||||
ADMINISTRATOR
|
||||
USER
|
||||
|
|
|
@ -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
52
src/hooks.server.ts
Normal 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
|
||||
};
|
||||
}
|
|
@ -25,7 +25,7 @@
|
|||
{#if visible}
|
||||
<div
|
||||
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 />
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<script>
|
||||
// @ts-ignore
|
||||
import { fade } from 'svelte/transition';
|
||||
import { Mail, SquareAsterisk, LogIn, Undo } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
@ -35,6 +36,7 @@
|
|||
method: 'POST',
|
||||
body: JSON.stringify({ email, password })
|
||||
}).catch((_) => toast.error(_.message));
|
||||
// @ts-ignore
|
||||
const body = await response.json().catch((_) => toast.error(_.message));
|
||||
|
||||
if (!body?.success) {
|
||||
|
@ -58,7 +60,7 @@
|
|||
type={'email'}
|
||||
name={'email'}
|
||||
id={'email'}
|
||||
placeholder={'user@example.com'}
|
||||
placeholder={'john@doefamily.com'}
|
||||
bind:value={email}
|
||||
required={true}
|
||||
>
|
||||
|
|
|
@ -46,6 +46,7 @@
|
|||
method: 'POST',
|
||||
body: JSON.stringify({ username, email, password })
|
||||
}).catch((_) => toast.error(_.message));
|
||||
// @ts-ignore
|
||||
const body = await response.json().catch((_) => toast.error(_.message));
|
||||
|
||||
if (!body?.success) {
|
||||
|
@ -79,7 +80,7 @@
|
|||
type={'email'}
|
||||
name={'email'}
|
||||
id={'email'}
|
||||
placeholder={'user@example.com'}
|
||||
placeholder={'jane@doefamily.com'}
|
||||
bind:value={email}
|
||||
required={true}
|
||||
>
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
<div class="flex my-auto space-x-3">
|
||||
<HeaderLink href="/dashboard">Dashboard</HeaderLink>
|
||||
<HeaderLink href="/uploads">Uploads</HeaderLink>
|
||||
<HeaderLink href="/links">Links</HeaderLink>
|
||||
<HeaderLink href="/documentation">Documentation</HeaderLink>
|
||||
{#if $user?.role === 'ADMINISTRATOR'}
|
||||
<HeaderLink href="/admin">Admin</HeaderLink>
|
||||
{/if}
|
||||
|
@ -47,7 +47,7 @@
|
|||
<DropdownButton href="/settings">
|
||||
<p class="my-auto">Settings</p>
|
||||
</DropdownButton>
|
||||
<DropdownButton href="/api/auth/logout">
|
||||
<DropdownButton href="/api/v1/auth/logout">
|
||||
<p class="my-auto text-red-500">Logout</p>
|
||||
</DropdownButton>
|
||||
</Dropdown>
|
||||
|
|
|
@ -1,18 +1,22 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
import { CircleAlert, Check } from 'lucide-svelte';
|
||||
|
||||
export let type, name, id, placeholder, value, required;
|
||||
export let type, name, id, placeholder, value: string, required;
|
||||
</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'}
|
||||
<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="flex p-2 space-x-2 rounded-lg shadow-md bg-crust">
|
||||
<div class="py-0.5 pr-2 border-r-2 border-overlay0">
|
||||
<slot />
|
||||
</div>
|
||||
<input
|
||||
class="py-0.5 bg-crust peer placeholder:text-overlay1"
|
||||
class="py-0.5 w-64 bg-crust placeholder:text-overlay1"
|
||||
type="username"
|
||||
{name}
|
||||
{id}
|
||||
|
@ -20,20 +24,14 @@
|
|||
{required}
|
||||
bind:value
|
||||
/>
|
||||
<div class="hidden my-auto peer-invalid:flex">
|
||||
<CircleAlert />
|
||||
</div>
|
||||
<div class="my-auto peer-invalid:hidden">
|
||||
<Check />
|
||||
</div>
|
||||
</div>
|
||||
{:else if type === 'email'}
|
||||
<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="flex p-2 space-x-2 rounded-lg shadow-md bg-crust">
|
||||
<div class="py-0.5 pr-2 border-r-2 border-overlay0">
|
||||
<slot />
|
||||
</div>
|
||||
<input
|
||||
class="py-0.5 bg-crust peer placeholder:text-overlay1"
|
||||
class="py-0.5 w-64 bg-crust placeholder:text-overlay1"
|
||||
type="email"
|
||||
{name}
|
||||
{id}
|
||||
|
@ -41,20 +39,14 @@
|
|||
{required}
|
||||
bind:value
|
||||
/>
|
||||
<div class="hidden my-auto peer-invalid:flex">
|
||||
<CircleAlert />
|
||||
</div>
|
||||
<div class="my-auto peer-invalid:hidden">
|
||||
<Check />
|
||||
</div>
|
||||
</div>
|
||||
{:else if type === 'password'}
|
||||
<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="flex p-2 space-x-2 rounded-lg shadow-md bg-crust">
|
||||
<div class="py-0.5 pr-2 border-r-2 border-overlay0">
|
||||
<slot />
|
||||
</div>
|
||||
<input
|
||||
class="py-0.5 bg-crust peer placeholder:text-overlay1"
|
||||
class="py-0.5 w-64 bg-crust placeholder:text-overlay1"
|
||||
type="password"
|
||||
{name}
|
||||
{id}
|
||||
|
@ -62,11 +54,5 @@
|
|||
{required}
|
||||
bind:value
|
||||
/>
|
||||
<div class="hidden my-auto peer-invalid:flex">
|
||||
<CircleAlert />
|
||||
</div>
|
||||
<div class="my-auto peer-invalid:hidden">
|
||||
<Check />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
@ -1,15 +1,17 @@
|
|||
<script>
|
||||
export let href,
|
||||
download,
|
||||
style = 'link';
|
||||
</script>
|
||||
|
||||
{#if style === 'link'}
|
||||
<a {href} class="text-blue-500">
|
||||
<a {href} {download} class="text-blue-500">
|
||||
<slot />
|
||||
</a>
|
||||
{:else if style === 'button'}
|
||||
<a
|
||||
{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"
|
||||
>
|
||||
<slot />
|
||||
|
|
24
src/lib/components/ListedAPIKey.svelte
Normal file
24
src/lib/components/ListedAPIKey.svelte
Normal 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>
|
|
@ -12,3 +12,25 @@ export const MAIL_WHITELIST = [
|
|||
'pm.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
|
||||
};
|
||||
|
|
|
@ -1,7 +1,18 @@
|
|||
import { COOKIE } from '$lib/config';
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import { getSession } from './database';
|
||||
import type { User, UserSettings } from '@prisma/client';
|
||||
import { getSession, getUserApiKey } from './database';
|
||||
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 {
|
||||
settings: UserSettings | null;
|
||||
|
@ -14,7 +25,10 @@ export async function authenticate(request: Request, cookies: Cookies) {
|
|||
let user: UserAndMaybeSettings | false = false;
|
||||
|
||||
if (bearer && !cookie) {
|
||||
return false;
|
||||
const key = await getUserApiKey(bearer);
|
||||
if (key) {
|
||||
user = key.user;
|
||||
}
|
||||
}
|
||||
|
||||
if (cookie && !bearer) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ const ApiKeyLimits = {
|
|||
setInterval(function resetMinute() {}, 1000 * 60);
|
||||
setInterval(function resetHour() {}, 1000 * 60 * 60);
|
||||
|
||||
function hash(input) {
|
||||
function hash(input: string) {
|
||||
createHash('sha256').update(input).digest('hex');
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
42
src/lib/server/validator.ts
Normal file
42
src/lib/server/validator.ts
Normal 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;
|
43
src/routes/(app)/documentation/+page.svelte
Normal file
43
src/routes/(app)/documentation/+page.svelte
Normal 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 << 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}
|
7
src/routes/(app)/settings/+page.server.ts
Normal file
7
src/routes/(app)/settings/+page.server.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { getUserApiKeys } from '$lib/server/database.js';
|
||||
|
||||
export async function load({ locals }) {
|
||||
return {
|
||||
keys: (await getUserApiKeys(locals.user.id)) || []
|
||||
};
|
||||
}
|
|
@ -1,6 +1,21 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
import { Info, X, Plus } from 'lucide-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>
|
||||
|
||||
<div class="grid grid-cols-1 gap-2 mx-auto xl:grid-cols-3 w-fit">
|
||||
|
@ -18,6 +33,8 @@
|
|||
name="username"
|
||||
id="username"
|
||||
placeholder="cirro"
|
||||
maxlength="16"
|
||||
bind:value={username}
|
||||
/>
|
||||
<p class="text-sm text-surface2">
|
||||
Your username is used to identify you around the site. You can change it at any time.
|
||||
|
@ -26,13 +43,14 @@
|
|||
</div>
|
||||
<div class="mb-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
|
||||
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"
|
||||
name="email"
|
||||
id="email"
|
||||
placeholder="c*******@madhouselabs.net"
|
||||
bind:value={email}
|
||||
/>
|
||||
<p class="text-sm text-surface2">
|
||||
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">
|
||||
<p class="flex text-xl font-bold">
|
||||
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>
|
||||
</button>
|
||||
</p>
|
||||
|
@ -60,16 +78,9 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>
|
||||
<tt class="text-md">d8895a0c-daa5-4b6e-b66f-2494c039fe9e </tt>
|
||||
</th>
|
||||
<th class="flex">
|
||||
<button class="m-auto hover:text-red">
|
||||
<X></X>
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
{#each data.keys as key}
|
||||
<ListedApiKey {key} />
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
@ -93,23 +104,25 @@
|
|||
name="title"
|
||||
id="title"
|
||||
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 class="flex flex-col gap-1">
|
||||
<label class="text-lg font-bold" for="description">Desciption</label>
|
||||
<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"
|
||||
id="description"
|
||||
placeholder={`Uploaded by {{ username }} at {{ time }}`}
|
||||
maxlength="2000"
|
||||
bind:value={description}
|
||||
></textarea>
|
||||
|
||||
<p class="text-sm text-surface2">
|
||||
The description of the embed. Can have up to 2000 characters.
|
||||
</p>
|
||||
<p class="text-sm text-surface2">The description of the embed. Max 2000 characters.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -165,8 +178,8 @@
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-lg font-bold" for="description">2FA</label>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<p class="text-lg font-bold">2FA</p>
|
||||
<p class="text-sm text-surface2">
|
||||
A One-Time Password will be required each time you login.
|
||||
</p>
|
||||
|
@ -184,7 +197,7 @@
|
|||
<span class="w-full border-b-2 border-surface0" />
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div>
|
||||
<input type="checkbox" name="newPostsPublic" id="newPostsPublic" />
|
||||
<label class="" for="newPostsPublic">New Posts Public</label>
|
||||
|
@ -195,10 +208,10 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div>
|
||||
<input type="checkbox" name="newPostsPublic" id="newPostsPublic" />
|
||||
<label class="" for="newPostsPublic">Encrypt Uploads</label>
|
||||
<input type="checkbox" name="encryptUploads" id="encryptUploads" />
|
||||
<label class="" for="encryptUploads">Encrypt Uploads</label>
|
||||
</div>
|
||||
<p class="text-sm text-surface2">Enable encryption for new uploads. (Client-side)</p>
|
||||
</div>
|
||||
|
|
|
@ -3,13 +3,11 @@ import { error } from '@sveltejs/kit';
|
|||
|
||||
export async function load({ locals, url }) {
|
||||
if (!locals.user) return error(403, { status: 403, message: 'Forbidden' });
|
||||
|
||||
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 (+(url.searchParams.get('i') || 0) >= Math.ceil(totalUploads / 15))
|
||||
error(400, { status: 403, message: 'Invalid Index' });
|
||||
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
/** @type {import('./$types').RequestHandler} */
|
||||
export function GET() {
|
||||
return new Response('OK');
|
||||
}
|
|
@ -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 }
|
||||
);
|
||||
}
|
47
src/routes/api/v1/auth/login/+server.ts
Normal file
47
src/routes/api/v1/auth/login/+server.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,9 +2,11 @@ import { redirect } from '@sveltejs/kit';
|
|||
import { getSession, deleteSession } from '$lib/server/database';
|
||||
import { COOKIE } from '$lib/config';
|
||||
|
||||
/** @type {import('./$types').RequestHandler} */
|
||||
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) {
|
||||
cookies.delete(COOKIE, { path: '/' });
|
||||
return redirect(302, '/');
|
|
@ -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 }
|
||||
);
|
||||
}
|
42
src/routes/api/v1/auth/register/+server.ts
Normal file
42
src/routes/api/v1/auth/register/+server.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
29
src/routes/api/v1/keys/+server.ts
Normal file
29
src/routes/api/v1/keys/+server.ts
Normal 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.' })
|
||||
)
|
||||
);
|
||||
}
|
|
@ -1,10 +1,9 @@
|
|||
import { bytesToHumanReadable } from '$lib';
|
||||
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 { get } from 'svelte/store';
|
||||
|
||||
/** @type {import("@sveltejs/kit").RequestHandler} */
|
||||
export async function GET() {
|
||||
return json({
|
||||
users: await prisma.user.count({}),
|
|
@ -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);
|
||||
}
|
5
src/routes/api/v1/user/+server.ts
Normal file
5
src/routes/api/v1/user/+server.ts
Normal 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
BIN
static/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
|
@ -4,7 +4,8 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
|||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
adapter: adapter(),
|
||||
csrf: { checkOrigin: false }
|
||||
},
|
||||
preprocess: vitePreprocess()
|
||||
};
|
||||
|
|
10
yarn.lock
10
yarn.lock
|
@ -1787,11 +1787,6 @@ util@^0.12.3:
|
|||
is-typed-array "^1.1.3"
|
||||
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:
|
||||
version "5.3.5"
|
||||
resolved "https://registry.yarnpkg.com/vite/-/vite-5.3.5.tgz#b847f846fb2b6cb6f6f4ed50a830186138cb83d8"
|
||||
|
@ -1870,3 +1865,8 @@ yaml@^2.3.4:
|
|||
version "2.5.0"
|
||||
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.5.0.tgz#c6165a721cf8000e91c36490a41d7be25176cf5d"
|
||||
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==
|
||||
|
|
Loading…
Reference in a new issue