good grief
This commit is contained in:
parent
b1555b7fd7
commit
64069bfc33
30 changed files with 1198 additions and 810 deletions
|
@ -21,6 +21,8 @@
|
||||||
"prisma": "^5.11.0",
|
"prisma": "^5.11.0",
|
||||||
"svelte": "^4.2.7",
|
"svelte": "^4.2.7",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
|
"tslib": "^2.6.3",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
"vite": "^5.0.3"
|
"vite": "^5.0.3"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
8
prisma/migrations/20240705200803_role_key/migration.sql
Normal file
8
prisma/migrations/20240705200803_role_key/migration.sql
Normal 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;
|
|
@ -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;
|
|
@ -12,6 +12,7 @@ model User {
|
||||||
username String @unique
|
username String @unique
|
||||||
email String @unique
|
email String @unique
|
||||||
password String
|
password String
|
||||||
|
role Role
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
lastSeen DateTime @default(now())
|
lastSeen DateTime @default(now())
|
||||||
settings UserSettings?
|
settings UserSettings?
|
||||||
|
@ -37,7 +38,7 @@ model UserSettings {
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
userId Int @unique
|
userId Int @unique
|
||||||
|
|
||||||
newPostsPublic Boolean @default(false)
|
newPostsPublic Boolean @default(true)
|
||||||
linkToRaw Boolean @default(false)
|
linkToRaw Boolean @default(false)
|
||||||
|
|
||||||
embedTitle String @default("{{file}}")
|
embedTitle String @default("{{file}}")
|
||||||
|
@ -50,7 +51,13 @@ model Upload {
|
||||||
uploader User @relation(fields: [uploaderId], references: [id])
|
uploader User @relation(fields: [uploaderId], references: [id])
|
||||||
uploaderId Int
|
uploaderId Int
|
||||||
|
|
||||||
fileName String
|
fileName String @db.LongText
|
||||||
public Boolean @default(false)
|
public Boolean @default(true)
|
||||||
uploaded DateTime @default(now())
|
uploaded DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
ADMINISTRATOR
|
||||||
|
USER
|
||||||
|
BANNED
|
||||||
|
}
|
||||||
|
|
2
src/app.d.ts
vendored
2
src/app.d.ts
vendored
|
@ -1,3 +1,5 @@
|
||||||
|
import type { User } from '@prisma/client';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
interface Error {
|
interface Error {
|
||||||
|
|
|
@ -9,6 +9,15 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:body
|
||||||
|
on:mousedown={() => {
|
||||||
|
if (visible)
|
||||||
|
setTimeout(() => {
|
||||||
|
visible = false;
|
||||||
|
}, 150);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="flex relative w-fit">
|
<div class="flex relative w-fit">
|
||||||
<button class="my-auto w-min hover:text-overlay2 {visible && 'text-overlay2'}" on:click={toggle}>
|
<button class="my-auto w-min hover:text-overlay2 {visible && 'text-overlay2'}" on:click={toggle}>
|
||||||
<Menu size="20"></Menu>
|
<Menu size="20"></Menu>
|
||||||
|
|
|
@ -1,28 +1,41 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
import { bytesToHumanReadable } from '$lib';
|
import { bytesToHumanReadable } from '$lib/index';
|
||||||
import { CircleAlert } from 'lucide-svelte';
|
import { fileProgress } from '$lib/stores';
|
||||||
|
import { CircleAlert, X } from 'lucide-svelte';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
/** @type {File} */
|
export let file: File;
|
||||||
export let file;
|
export let i: number;
|
||||||
|
export let remove: Function;
|
||||||
|
export let running = false;
|
||||||
|
|
||||||
/** @type {Number} */
|
let percent = 0,
|
||||||
export let i;
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="rounded-md transition-all bg-mantle">
|
<div class="rounded-md transition-all bg-mantle">
|
||||||
<div
|
<div
|
||||||
in:fade|global={{ delay: 100 * i }}
|
in:fade|global={{ delay: 100 * i }}
|
||||||
class="flex px-1.5 w-full h-14 rounded-md transition-all"
|
class="flex place-content-between px-1.5 w-full h-14 rounded-md transition-all {error
|
||||||
style="
|
? 'bg-red-300 dark:bg-red-700/60'
|
||||||
background: linear-gradient(90deg, rgb(var(--ctp-surface0)) {percent}%, transparent
|
: ''}"
|
||||||
{percent}%);"
|
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">
|
<div class="flex overflow-x-scroll flex-col my-auto overflow-y-clip">
|
||||||
{#if percent === 100}
|
{#if url}
|
||||||
<a href="?" class="text-blue">{file.name}</a>
|
<a href={url} class="text-blue">{file.name}</a>
|
||||||
{:else}
|
{:else}
|
||||||
<p>{file.name}</p>
|
<p>{file.name}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -38,5 +51,15 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{#if !running}
|
||||||
|
<button
|
||||||
|
class="hover:text-red-500"
|
||||||
|
on:click={() => {
|
||||||
|
remove(file.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size="20"></X></button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -10,8 +10,7 @@
|
||||||
{:else if style === 'button'}
|
{:else if style === 'button'}
|
||||||
<a
|
<a
|
||||||
{href}
|
{href}
|
||||||
class="flex p-2 space-x-2 rounded-lg border-b-2 border-neutral-400
|
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"
|
||||||
hover:border-neutral-500 hover:dark:border-neutral-500 dark:border-neutral-700 bg-neutral-200 dark:bg-neutral-900"
|
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -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
45
src/lib/index.ts
Normal 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
27
src/lib/server/auth.ts
Normal 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;
|
||||||
|
}
|
|
@ -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
23
src/lib/server/crypto.ts
Normal 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;
|
||||||
|
}
|
|
@ -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
121
src/lib/server/database.ts
Normal 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
28
src/lib/server/minio.ts
Normal 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);
|
|
@ -1,4 +0,0 @@
|
||||||
import { writable } from 'svelte/store';
|
|
||||||
|
|
||||||
export const darkMode = writable();
|
|
||||||
export const user = writable();
|
|
7
src/lib/stores.ts
Normal file
7
src/lib/stores.ts
Normal 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({});
|
5
src/lib/types/user.d.ts
vendored
5
src/lib/types/user.d.ts
vendored
|
@ -1,5 +0,0 @@
|
||||||
interface User {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
}
|
|
|
@ -6,18 +6,19 @@
|
||||||
import Footer from '$lib/components/Footer.svelte';
|
import Footer from '$lib/components/Footer.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container my-2 w-full min-h-screen">
|
<div class="container pt-2 w-full min-h-screen">
|
||||||
<div class="w-full h-12">
|
<div class="mb-2 w-full h-12">
|
||||||
<Header></Header>
|
<Header></Header>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#key $page.url}
|
{#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 />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
{/key}
|
{/key}
|
||||||
|
|
||||||
|
<!--
|
||||||
<div class="w-full h-20">
|
<div class="w-full h-20">
|
||||||
<Footer></Footer>
|
<Footer></Footer>
|
||||||
</div>
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,25 +1,68 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
import { Check, Upload } from 'lucide-svelte';
|
import { Check, Upload } from 'lucide-svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { user } from '$lib/stores';
|
import { fileProgress, user } from '$lib/stores';
|
||||||
import { fade, slide } from 'svelte/transition';
|
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;
|
export let data;
|
||||||
user.set(data?.user);
|
user.set(data?.user);
|
||||||
|
|
||||||
/** @type {HTMLInputElement} */
|
let input: HTMLInputElement,
|
||||||
let input;
|
files: FileList,
|
||||||
|
fileMap: Map<string, File> = new Map();
|
||||||
/** @type {FileList} */
|
|
||||||
let files;
|
|
||||||
|
|
||||||
let running = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<form class="hidden" action="">
|
<input class="hidden" type="file" multiple={true} bind:this={input} bind:files on:change={change} />
|
||||||
<input type="file" name="file" id="file" multiple={true} bind:this={input} bind:files />
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="w-[23rem] h-[calc(100vh-4.5rem)] flex mx-auto">
|
<div class="w-[23rem] h-[calc(100vh-4.5rem)] flex mx-auto">
|
||||||
<div class="flex flex-col gap-2 my-auto w-full">
|
<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>
|
<p class="text-overlay1">Your max upload size is <span class="font-bold">100 MB</span>.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2 p-2 mx-auto w-full rounded-lg shadow-lg bg-crust">
|
<div class="flex flex-col gap-2 p-2 mx-auto w-full rounded-lg shadow-lg bg-crust">
|
||||||
{#if files?.length}
|
{#key fileMap}
|
||||||
{#each Array.from(files) as file, i}
|
{#each fileMap.values() as file, i}
|
||||||
<File {file} {i}></File>
|
<FileComponent {file} {i} {remove} {running}></FileComponent>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/key}
|
||||||
|
|
||||||
{#if !running}
|
{#if !running}
|
||||||
<div out:slide class="flex gap-2">
|
<div out:slide class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
class="flex w-full {!files?.length
|
class="flex w-full {!fileMap.size
|
||||||
? 'h-36'
|
? 'h-36'
|
||||||
: 'h-14'} rounded-md outline-2 outline-dotted outline-surface2 bg-mantle group"
|
: 'h-14'} rounded-md outline-2 outline-dotted outline-surface2 bg-mantle group"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
|
@ -47,12 +91,10 @@
|
||||||
<Upload></Upload>
|
<Upload></Upload>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{#if files?.length}
|
{#if fileMap.size}
|
||||||
<button
|
<button
|
||||||
class="flex w-[25%] h-14 rounded-md transition-all outline-2 outline-dotted outline-surface2 bg-mantle group"
|
class="flex w-[25%] h-14 rounded-md transition-all outline-2 outline-dotted outline-surface2 bg-mantle group"
|
||||||
on:click={() => {
|
on:click={upload}
|
||||||
running = true;
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div class="flex m-auto text-lg text-surface2 group-hover:text-green">
|
<div class="flex m-auto text-lg text-surface2 group-hover:text-green">
|
||||||
<Check class=""></Check>
|
<Check class=""></Check>
|
||||||
|
|
|
@ -3,12 +3,9 @@
|
||||||
|
|
||||||
import { Toaster } from 'svelte-sonner';
|
import { Toaster } from 'svelte-sonner';
|
||||||
import { CircleAlert, TriangleAlert, Info, Check, Loader } from 'lucide-svelte';
|
import { CircleAlert, TriangleAlert, Info, Check, Loader } from 'lucide-svelte';
|
||||||
import { page } from '$app/stores';
|
import { user } from '$lib/stores';
|
||||||
|
|
||||||
import { user } from '$lib/stores.js';
|
|
||||||
import ThemeHandler from '$lib/components/ThemeHandler.svelte';
|
import ThemeHandler from '$lib/components/ThemeHandler.svelte';
|
||||||
import PageMeta from '$lib/components/PageMeta.svelte';
|
import PageMeta from '$lib/components/PageMeta.svelte';
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
|
|
||||||
export let data;
|
export let data;
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,6 @@ import { COOKIE } from '$lib/config';
|
||||||
import { createUser, createSession } from '$lib/server/database';
|
import { createUser, createSession } from '$lib/server/database';
|
||||||
import { email } from '$lib/server/validator';
|
import { email } from '$lib/server/validator';
|
||||||
|
|
||||||
/** @type {import('./$types').RequestHandler} */
|
|
||||||
export async function POST(event) {
|
export async function POST(event) {
|
||||||
const { request, cookies, locals } = event;
|
const { request, cookies, locals } = event;
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
|
@ -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 { json } from '@sveltejs/kit';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
/** @type {import("@sveltejs/kit").RequestHandler} */
|
/** @type {import("@sveltejs/kit").RequestHandler} */
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
return json({
|
return json({
|
||||||
users: ':3',
|
users: await prisma.user.count({}),
|
||||||
files: 'dick',
|
files: await prisma.upload.count({}),
|
||||||
storage: '100 gigafarts'
|
storage: bytesToHumanReadable(get(USAGE))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
41
src/routes/api/upload/+server.ts
Normal file
41
src/routes/api/upload/+server.ts
Normal 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}`);
|
||||||
|
};
|
34
src/routes/download/[id]/+server.ts
Normal file
34
src/routes/download/[id]/+server.ts
Normal 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()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
23
src/routes/file/[id]/+page.server.ts
Normal file
23
src/routes/file/[id]/+page.server.ts
Normal 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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
34
src/routes/file/[id]/+page.svelte
Normal file
34
src/routes/file/[id]/+page.svelte
Normal 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>
|
Loading…
Reference in a new issue