good grief

This commit is contained in:
cirroskais 2024-07-05 23:31:58 -04:00
parent b1555b7fd7
commit 64069bfc33
No known key found for this signature in database
GPG key ID: 5FC73EBF2678E33D
30 changed files with 1198 additions and 810 deletions

View file

@ -21,6 +21,8 @@
"prisma": "^5.11.0",
"svelte": "^4.2.7",
"tailwindcss": "^3.4.1",
"tslib": "^2.6.3",
"typescript": "^5.5.3",
"vite": "^5.0.3"
},
"type": "module",

View file

@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `role` to the `User` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE `User` ADD COLUMN `role` ENUM('ADMINISTRATOR', 'USER', 'BANNED') NOT NULL;

View file

@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE `Upload` MODIFY `fileName` LONGTEXT NOT NULL,
MODIFY `public` BOOLEAN NOT NULL DEFAULT true;
-- AlterTable
ALTER TABLE `UserSettings` MODIFY `newPostsPublic` BOOLEAN NOT NULL DEFAULT true;

View file

@ -12,6 +12,7 @@ model User {
username String @unique
email String @unique
password String
role Role
createdAt DateTime @default(now())
lastSeen DateTime @default(now())
settings UserSettings?
@ -37,7 +38,7 @@ model UserSettings {
user User @relation(fields: [userId], references: [id])
userId Int @unique
newPostsPublic Boolean @default(false)
newPostsPublic Boolean @default(true)
linkToRaw Boolean @default(false)
embedTitle String @default("{{file}}")
@ -50,7 +51,13 @@ model Upload {
uploader User @relation(fields: [uploaderId], references: [id])
uploaderId Int
fileName String
public Boolean @default(false)
fileName String @db.LongText
public Boolean @default(true)
uploaded DateTime @default(now())
}
enum Role {
ADMINISTRATOR
USER
BANNED
}

2
src/app.d.ts vendored
View file

@ -1,3 +1,5 @@
import type { User } from '@prisma/client';
declare global {
namespace App {
interface Error {

View file

@ -9,6 +9,15 @@
}
</script>
<svelte:body
on:mousedown={() => {
if (visible)
setTimeout(() => {
visible = false;
}, 150);
}}
/>
<div class="flex relative w-fit">
<button class="my-auto w-min hover:text-overlay2 {visible && 'text-overlay2'}" on:click={toggle}>
<Menu size="20"></Menu>

View file

@ -1,28 +1,41 @@
<script>
import { bytesToHumanReadable } from '$lib';
import { CircleAlert } from 'lucide-svelte';
<script lang="ts">
import { bytesToHumanReadable } from '$lib/index';
import { fileProgress } from '$lib/stores';
import { CircleAlert, X } from 'lucide-svelte';
import { fade } from 'svelte/transition';
/** @type {File} */
export let file;
export let file: File;
export let i: number;
export let remove: Function;
export let running = false;
/** @type {Number} */
export let i;
let percent = 0,
url = '',
error = false;
let percent = 0;
fileProgress.subscribe((_) => {
let fileProgress = _[file.name];
if (!fileProgress) return;
if (fileProgress.percent) percent = fileProgress.percent;
if (fileProgress.url) url = fileProgress.url;
if (fileProgress.error) error = fileProgress.error;
});
</script>
<div class="rounded-md transition-all bg-mantle">
<div
in:fade|global={{ delay: 100 * i }}
class="flex px-1.5 w-full h-14 rounded-md transition-all"
style="
background: linear-gradient(90deg, rgb(var(--ctp-surface0)) {percent}%, transparent
{percent}%);"
class="flex place-content-between px-1.5 w-full h-14 rounded-md transition-all {error
? 'bg-red-300 dark:bg-red-700/60'
: ''}"
style={error
? ''
: `background: linear-gradient(90deg, rgb(var(--ctp-surface0)) ${percent}%, transparent ${percent}%);`}
>
<div class="flex overflow-x-scroll flex-col my-auto overflow-y-clip">
{#if percent === 100}
<a href="?" class="text-blue">{file.name}</a>
{#if url}
<a href={url} class="text-blue">{file.name}</a>
{:else}
<p>{file.name}</p>
{/if}
@ -38,5 +51,15 @@
</p>
</div>
</div>
{#if !running}
<button
class="hover:text-red-500"
on:click={() => {
remove(file.name);
}}
>
<X size="20"></X></button
>
{/if}
</div>
</div>

View file

@ -10,8 +10,7 @@
{:else if style === 'button'}
<a
{href}
class="flex p-2 space-x-2 rounded-lg border-b-2 border-neutral-400
hover:border-neutral-500 hover:dark:border-neutral-500 dark:border-neutral-700 bg-neutral-200 dark:bg-neutral-900"
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 />
</a>

View file

@ -1,17 +0,0 @@
import { browser } from '$app/environment';
export function goBack() {
if (browser) {
history.back();
}
}
/** @param bytes {Number} */
export function bytesToHumanReadable(bytes) {
if (bytes === 0) {
return '0 B';
}
let e = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, e)).toFixed(2) + ' ' + ' KMGTP'.charAt(e) + 'B';
}

45
src/lib/index.ts Normal file
View file

@ -0,0 +1,45 @@
import { browser } from '$app/environment';
export function goBack() {
if (browser) {
history.back();
}
}
export function bytesToHumanReadable(bytes: number) {
if (bytes === 0) {
return '0 B';
}
let e = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, e)).toFixed(2) + ' ' + ' KMGTP'.charAt(e) + 'B';
}
export function request(
data: FormData,
progress: Function
): Promise<{ success: boolean; body: string }> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.addEventListener('loadend', () => {
resolve({
success: xhr.readyState === 4 && xhr.status === 200,
body: xhr.responseText
});
});
xhr.addEventListener('error', (event) => {
reject(event);
});
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
progress(Math.floor((event.loaded / event.total) * 100));
}
});
xhr.open('POST', '/api/upload', true);
xhr.send(data);
});
}

27
src/lib/server/auth.ts Normal file
View file

@ -0,0 +1,27 @@
import { COOKIE } from '$lib/config';
import type { Cookies } from '@sveltejs/kit';
import { getSession } from './database';
import type { User, UserSettings } from '@prisma/client';
interface UserAndMaybeSettings extends User {
settings: UserSettings | null;
}
export async function authenticate(request: Request, cookies: Cookies) {
const bearer = request.headers.get('Authorization')?.replace('Bearer ', '');
const cookie = cookies.get(COOKIE);
let user: UserAndMaybeSettings | false = false;
if (bearer && !cookie) {
return false;
}
if (cookie && !bearer) {
const session = await getSession(cookie);
if (!session) return false;
user = session.user;
}
return user;
}

View file

@ -1,9 +0,0 @@
import { hash, verify } from 'argon2';
export async function createHash(input) {
return await hash(input);
}
export async function verifyHash(hash, input) {
return await verify(hash, input);
}

23
src/lib/server/crypto.ts Normal file
View file

@ -0,0 +1,23 @@
import { hash, verify } from 'argon2';
export const letterIdCharacters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split('');
export const catIdCharacters = ['🐱', '😻', '😿', '😹', '😽', '😾', '🙀', '😸', '😺', '😼', '🐈'];
export async function createHash(input: string) {
return await hash(input);
}
export async function verifyHash(hash: string, input: string) {
return await verify(hash, input);
}
export function generateId(characters: String[] = letterIdCharacters, length: number) {
length = Math.max(length, 6);
let id = '';
for (let i = 0; length > i; i++) {
id += characters[Math.floor(Math.random() * characters.length)];
}
return id;
}

View file

@ -1,71 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { randomBytes } from 'node:crypto';
import { createHash } from './crypto';
const prisma = new PrismaClient();
export default prisma;
export async function createUser(username, email, password) {
const user = await prisma.user.create({
data: {
username,
email,
password: await createHash(password)
}
});
await prisma.userSettings.create({
data: {
userId: user.id
}
});
return user;
}
export async function createSession(userId, needsMfa = false) {
const session = await prisma.session.create({
data: {
id: randomBytes(64).toString('base64'),
userId,
authorized: !needsMfa,
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 12)
}
});
return session;
}
export async function findUser({ email, username }) {
if (!email && !username) return false;
const user = await prisma.user.findFirst({
where: {
email,
username
}
});
return user;
}
export async function getSession(id) {
if (!id) return false;
const session = await prisma.session.findFirst({
where: { id },
include: {
user: true
}
});
return session;
}
export async function deleteSession(id) {
if (!id) return false;
return await prisma.session.delete({
where: { id }
});
}

121
src/lib/server/database.ts Normal file
View file

@ -0,0 +1,121 @@
import { PrismaClient } from '@prisma/client';
import { randomBytes } from 'node:crypto';
import { createHash } from './crypto';
const prisma = new PrismaClient();
export default prisma;
interface FindUserQuery {
username?: string;
email?: string;
}
export async function createUser(username: string, email: string, password: string) {
const user = await prisma.user.create({
data: {
username,
email,
password: await createHash(password),
role: 'USER'
}
});
await prisma.userSettings.create({
data: {
userId: user.id
}
});
return user;
}
export async function createSession(userId: number, needsMfa = false) {
const session = await prisma.session.create({
data: {
id: randomBytes(64).toString('base64'),
userId,
authorized: !needsMfa,
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 12)
}
});
return session;
}
export async function findUser({ email, username }: FindUserQuery) {
if (!email && !username) return false;
const user = await prisma.user.findFirst({
where: {
email,
username
}
});
return user;
}
export async function getSession(id: string) {
if (!id) return false;
const session = await prisma.session.findFirst({
where: { id },
include: {
user: {
select: {
id: true,
username: true,
email: true,
password: true,
role: true,
createdAt: true,
lastSeen: true,
maxUploadMB: true,
settings: true
}
}
}
});
return session;
}
export async function deleteSession(id: string) {
if (!id) return false;
return await prisma.session.delete({
where: { id }
});
}
export async function createUpload(id: string, uploaderId: number, fileName: string) {
const settings = await prisma.userSettings.findFirst({
where: { id: uploaderId }
});
return await prisma.upload.create({
data: {
id,
uploaderId,
fileName,
public: settings?.newPostsPublic
}
});
}
export async function getUpload(id: string) {
if (!id) return false;
return await prisma.upload.findFirst({
where: {
id
},
select: {
id: true,
fileName: true,
public: true,
uploaded: true,
uploader: true
}
});
}

28
src/lib/server/minio.ts Normal file
View file

@ -0,0 +1,28 @@
import { building } from '$app/environment';
import { env } from '$env/dynamic/private';
import * as Minio from 'minio';
import { get, writable } from 'svelte/store';
const minio = new Minio.Client({
endPoint: building ? 'building.local' : env.MINIO_URL,
useSSL: true,
accessKey: building ? 'building' : env.MINIO_ACCESS_KEY,
secretKey: building ? 'building' : env.MINIO_SECRET_KEY
});
export default minio;
export const BUCKET = env.MINIO_BUCKET;
export let USAGE = writable(0);
function du() {
let usage = 0;
const stream = minio.listObjects(BUCKET, undefined, true);
stream.on('data', (object) => (usage += object.size));
stream.on('end', () => USAGE.set(usage));
}
du();
setTimeout(du, 1000 * 60 * 10);

View file

@ -1,4 +0,0 @@
import { writable } from 'svelte/store';
export const darkMode = writable();
export const user = writable();

7
src/lib/stores.ts Normal file
View file

@ -0,0 +1,7 @@
import { writable, type Writable } from 'svelte/store';
export const darkMode = writable();
export const user = writable();
// too lazy to do types for this
export const fileProgress: Writable<{ [key: string]: any }> = writable({});

View file

@ -1,5 +0,0 @@
interface User {
id: number;
username: string;
email: string;
}

View file

@ -6,18 +6,19 @@
import Footer from '$lib/components/Footer.svelte';
</script>
<div class="container my-2 w-full min-h-screen">
<div class="w-full h-12">
<div class="container pt-2 w-full min-h-screen">
<div class="mb-2 w-full h-12">
<Header></Header>
</div>
{#key $page.url}
<div in:fade|global class="w-full min-h-[calc(100vh-4.5rem)] my-2">
<div in:fade|global class="w-full min-h-[calc(100vh-4rem)] pb-2">
<slot />
</div>
{/key}
<!--
<div class="w-full h-20">
<Footer></Footer>
</div>
</div> -->
</div>

View file

@ -1,25 +1,68 @@
<script>
<script lang="ts">
import { Check, Upload } from 'lucide-svelte';
import { page } from '$app/stores';
import { user } from '$lib/stores';
import { fileProgress, user } from '$lib/stores';
import { fade, slide } from 'svelte/transition';
import File from '$lib/components/File.svelte';
import FileComponent from '$lib/components/File.svelte';
import { get } from 'svelte/store';
import { request } from '$lib';
export let data;
user.set(data?.user);
/** @type {HTMLInputElement} */
let input;
/** @type {FileList} */
let files;
let input: HTMLInputElement,
files: FileList,
fileMap: Map<string, File> = new Map();
let running = false;
// lazy again
function progress(name: string, data: any) {
let _ = get(fileProgress);
fileProgress.set(
Object.assign(_, {
[name]: data
})
);
}
async function upload() {
if (running) return;
running = true;
fileMap.forEach(async (v, k) => {
const body = new FormData();
body.append('file', v);
const response = await request(body, (percent: number) => {
progress(k, { percent });
}).catch(() => {
progress(k, { error: true });
});
if (response && response.success) progress(k, { url: response.body });
else progress(k, { error: true });
});
}
function remove(name: string) {
fileMap.delete(name);
fileMap = fileMap;
}
function change() {
fileMap = new Map();
for (let i = 0; files.length > i; i++) {
let file = files.item(i);
if (!file) return;
fileMap.set(file.name, file);
}
}
</script>
<form class="hidden" action="">
<input type="file" name="file" id="file" multiple={true} bind:this={input} bind:files />
</form>
<input class="hidden" type="file" multiple={true} bind:this={input} bind:files on:change={change} />
<div class="w-[23rem] h-[calc(100vh-4.5rem)] flex mx-auto">
<div class="flex flex-col gap-2 my-auto w-full">
@ -28,15 +71,16 @@
<p class="text-overlay1">Your max upload size is <span class="font-bold">100 MB</span>.</p>
</div>
<div class="flex flex-col gap-2 p-2 mx-auto w-full rounded-lg shadow-lg bg-crust">
{#if files?.length}
{#each Array.from(files) as file, i}
<File {file} {i}></File>
{#key fileMap}
{#each fileMap.values() as file, i}
<FileComponent {file} {i} {remove} {running}></FileComponent>
{/each}
{/if}
{/key}
{#if !running}
<div out:slide class="flex gap-2">
<button
class="flex w-full {!files?.length
class="flex w-full {!fileMap.size
? 'h-36'
: 'h-14'} rounded-md outline-2 outline-dotted outline-surface2 bg-mantle group"
on:click={() => {
@ -47,12 +91,10 @@
<Upload></Upload>
</div>
</button>
{#if files?.length}
{#if fileMap.size}
<button
class="flex w-[25%] h-14 rounded-md transition-all outline-2 outline-dotted outline-surface2 bg-mantle group"
on:click={() => {
running = true;
}}
on:click={upload}
>
<div class="flex m-auto text-lg text-surface2 group-hover:text-green">
<Check class=""></Check>

View file

@ -3,12 +3,9 @@
import { Toaster } from 'svelte-sonner';
import { CircleAlert, TriangleAlert, Info, Check, Loader } from 'lucide-svelte';
import { page } from '$app/stores';
import { user } from '$lib/stores.js';
import { user } from '$lib/stores';
import ThemeHandler from '$lib/components/ThemeHandler.svelte';
import PageMeta from '$lib/components/PageMeta.svelte';
import { fade } from 'svelte/transition';
export let data;

View file

@ -3,7 +3,6 @@ import { COOKIE } from '$lib/config';
import { createUser, createSession } from '$lib/server/database';
import { email } from '$lib/server/validator';
/** @type {import('./$types').RequestHandler} */
export async function POST(event) {
const { request, cookies, locals } = event;
const body = await request.json();

View file

@ -1,10 +1,14 @@
import { bytesToHumanReadable } from '$lib';
import prisma from '$lib/server/database';
import minio, { 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: ':3',
files: 'dick',
storage: '100 gigafarts'
users: await prisma.user.count({}),
files: await prisma.upload.count({}),
storage: bytesToHumanReadable(get(USAGE))
});
}

View file

@ -0,0 +1,41 @@
import { authenticate } from '$lib/server/auth';
import { catIdCharacters, generateId } from '$lib/server/crypto.js';
import { createUpload } from '$lib/server/database';
import minio, { BUCKET } from '$lib/server/minio';
import { error } from '@sveltejs/kit';
export const POST = async ({ request, cookies }) => {
const contentType = request.headers.get('Content-Type');
if (!contentType || !contentType.includes('multipart/form-data'))
return error(400, { status: 400, message: 'Improper Content-Type' });
const user = await authenticate(request, cookies);
if (!user) return error(403, { status: 403, message: 'Forbidden' });
const data = await request.formData();
const file = data.get('file') as File;
let id = generateId(undefined, 10);
console.log(id);
const object = await minio
.putObject(
BUCKET,
`${user.id}/${file.name}`,
Buffer.from(await file.arrayBuffer()),
file.size,
{
'Content-Type': file.type
}
)
.catch((e) => console.log(e));
if (!object)
return error(500, { status: 500, message: 'Internal Server Error - Contact Administrator' });
const objectRecord = await createUpload(id, user.id, file.name);
if (!objectRecord)
return error(500, { status: 500, message: 'Internal Server Error - Contact Administrator' });
if (user.settings?.linkToRaw) return new Response(`/download/${id}`);
return new Response(`/file/${id}`);
};

View file

@ -0,0 +1,34 @@
import { getUpload } from '$lib/server/database.js';
import minio, { BUCKET } from '$lib/server/minio';
import { error } from '@sveltejs/kit';
export const GET = async ({ params }) => {
const id = params.id;
const file = await getUpload(id);
if (!file) throw error(404, { status: 404, message: 'File Not Found' });
const object = await minio.getObject(BUCKET, `${file.uploader.id}/${file.fileName}`);
const metadata = await minio.statObject(BUCKET, `${file.uploader.id}/${file.fileName}`);
const ac = new AbortController();
const stream = new ReadableStream({
start(controller) {
object.on('data', (chunk) => {
controller.enqueue(chunk);
});
},
cancel() {
ac.abort();
}
});
return new Response(stream, {
headers: {
'Content-Disposition': `attachment; filename="${file.fileName}"`,
'Content-Type': metadata.metaData['content-type'],
'Content-Length': metadata.size.toString()
}
});
};

View file

@ -0,0 +1,23 @@
import { getUpload } from '$lib/server/database';
import minio, { BUCKET } from '$lib/server/minio';
import { error } from '@sveltejs/kit';
export async function load({ params }) {
const file = await getUpload(params.id);
if (!file) throw error(404, { status: 404, message: 'File Not Found' });
const metadata = await minio.statObject(BUCKET, `${file.uploader.id}/${file.fileName}`);
return {
file: {
id: file.id,
fileName: file.fileName,
uploaded: file.uploaded,
size: metadata.size,
type: metadata.metaData['content-type']
},
uploader: {
username: file.uploader.username
}
};
}

View file

@ -0,0 +1,34 @@
<script>
import { bytesToHumanReadable } from '$lib';
import Link from '$lib/components/Inputs/Link.svelte';
export let data;
</script>
<div class="container">
<div class="h-[85vh] md:h-screen">
<div class="flex justify-center items-center h-full">
<div class="flex flex-col gap-2">
<div>
<h1 class="text-2xl font-bold">{data.file.fileName}</h1>
<p class="text-overlay1">
Uploaded by <span class="font-bold">{data.uploader.username}</span>.
</p>
</div>
<div class="p-2.5 rounded-lg shadow-lg bg-crust h-[50vh]">
{#if data.file.type.includes('video')}
<!-- svelte-ignore a11y-media-has-caption -->
<video class="h-full" src="/download/{data.file.id}" controls></video>
{:else if data.file.type.includes('image')}
<img class="h-full" src="/download/{data.file.id}" alt={data.file.id} />
{/if}
</div>
<Link style="button" href="/download/{data.file.id}">
<p class="w-full font-bold text-center">
Download ({bytesToHumanReadable(data.file.size)})
</p>
</Link>
</div>
</div>
</div>
</div>

1318
yarn.lock

File diff suppressed because it is too large Load diff