yap #1

Merged
cirroskais merged 55 commits from development into master 2024-07-06 16:12:27 +00:00
84 changed files with 2715 additions and 1188 deletions

View file

@ -0,0 +1 @@
DATABASE_URL=

View file

@ -3,33 +3,32 @@ FROM node:lts-alpine AS base
WORKDIR /usr/src/app
FROM base AS install
RUN mkdir -p /temp/dev
COPY package.json yarn.lock /temp/dev/
RUN cd /temp/dev && yarn install
RUN mkdir dev
COPY package.json dev/
COPY yarn.lock dev/
RUN cd /usr/src/app/dev && yarn install
RUN mkdir -p /temp/prod
COPY package.json yarn.lock /temp/prod/
RUN cd /temp/prod && yarn install --omit=dev
# Not needed as of now since adapter-node
# packs all of our dependencies for us.
# RUN mkdir prod
# COPY package.json prod/
# COPY yarn.lock prod/
# RUN cd /usr/src/app/prod && yarn install --production
FROM base AS build
COPY --from=install /usr/src/app/dev/node_modules node_modules
FROM base AS prisma
COPY --from=install /temp/prod/node_modules node_modules
COPY . .
RUN yarn build
RUN npx prisma generate
FROM base AS app
FROM base AS prerelease
COPY --from=install /temp/dev/node_modules node_modules
COPY . .
# COPY --from=install /usr/src/app/prod/node_modules node_modules
COPY --from=build /usr/src/app/package.json .
COPY --from=build /usr/src/app/build/ .
RUN npx prisma generate
RUN npm run build
FROM base AS release
COPY --from=prisma /usr/src/app/prisma prisma
COPY --from=prisma /usr/src/app/node_modules node_modules
COPY --from=prerelease /usr/src/app/build/ ./
COPY --from=prerelease /usr/src/app/package.json .
EXPOSE 3000/tcp
CMD [ "node", "index.js" ]
CMD [ "node", "index.js" ]

View file

@ -5,3 +5,11 @@
[![forthebadge](https://forthebadge.com/images/badges/license-mit.svg)](https://forthebadge.com) [![forthebadge](https://forthebadge.com/images/badges/designed-in-ms-paint.svg)](https://forthebadge.com) [![forthebadge](https://forthebadge.com/images/badges/gluten-free.svg)](https://forthebadge.com) [![forthebadge](https://forthebadge.com/images/badges/powered-by-black-magic.svg)](https://forthebadge.com)
A file uploading website.
## Developing
You can start a development SQL server with Docker:
```sh
sudo docker run -e MYSQL_ROOT_PASSWORD=development -e MYSQL_DATABASE=default -d mysql:latest
```

9
docker-compose.dev.yml Normal file
View file

@ -0,0 +1,9 @@
services:
db:
image: mariadb
restart: always
environment:
MARIADB_DATABASE: file-uploader
MARIADB_ROOT_PASSWORD: development
ports:
- '3306:3306'

17
docker-compose.yml Normal file
View file

@ -0,0 +1,17 @@
services:
mariadb:
container_name: 'file-uploader-db'
image: 'mariadb'
restart: always
environment:
MARIADB_DATABASE: file-uploader
MARIADB_ROOT_PASSWORD: file-uploader
file-uploader:
container_name: 'file-uploader'
image: file-uploader
build: .
restart: always
depends_on:
- mariadb
environment:
DATABASE_URL: mysql://root:file-uploader@mariadb:3306/file-uploader

19
jsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
// except $lib which is handled by https://kit.svelte.dev/docs/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

View file

@ -4,30 +4,35 @@
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"build": " vite build",
"preview": "vite preview",
"lint": "prettier --check .",
"format": "prettier --write ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@catppuccin/tailwindcss": "^0.1.6",
"@sveltejs/adapter-node": "^5.0.1",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"autoprefixer": "^10.4.18",
"drizzle-kit": "^0.20.14",
"postcss": "^8.4.35",
"prettier": "^3.1.1",
"prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.1.2",
"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",
"dependencies": {
"drizzle-orm": "^0.30.2",
"@prisma/client": "5.11.0",
"argon2": "^0.40.1",
"lucide-svelte": "^0.358.0",
"mysql2": "^3.9.2",
"svelte-sonner": "^0.3.19"
"mime": "^4.0.4",
"minio": "^7.1.3",
"svelte-sonner": "^0.3.19",
"validator": "^13.11.0"
}
}

View file

@ -0,0 +1,57 @@
-- CreateTable
CREATE TABLE `User` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`username` VARCHAR(191) NOT NULL,
`email` VARCHAR(191) NOT NULL,
`password` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`lastSeen` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `User_username_key`(`username`),
UNIQUE INDEX `User_email_key`(`email`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Session` (
`id` VARCHAR(191) NOT NULL,
`userId` INTEGER NOT NULL,
`authorized` BOOLEAN NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`expiresAt` DATETIME(3) NOT NULL,
`remoteAddress` VARCHAR(191) NULL,
UNIQUE INDEX `Session_id_key`(`id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `UserSettings` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`userId` INTEGER NOT NULL,
`newPostsPublic` BOOLEAN NOT NULL DEFAULT false,
UNIQUE INDEX `UserSettings_userId_key`(`userId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Upload` (
`id` VARCHAR(191) NOT NULL,
`uploaderId` INTEGER NOT NULL,
`fileName` VARCHAR(191) NOT NULL,
`public` BOOLEAN NOT NULL DEFAULT false,
`uploaded` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `Upload_fileName_key`(`fileName`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `Session` ADD CONSTRAINT `Session_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `UserSettings` ADD CONSTRAINT `UserSettings_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Upload` ADD CONSTRAINT `Upload_uploaderId_fkey` FOREIGN KEY (`uploaderId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;

View file

@ -0,0 +1,16 @@
/*
Warnings:
- Added the required column `embedColor` to the `UserSettings` table without a default value. This is not possible if the table is not empty.
- Added the required column `embedDescription` to the `UserSettings` table without a default value. This is not possible if the table is not empty.
- Added the required column `embedTitle` to the `UserSettings` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE `User` ADD COLUMN `maxUploadMB` INTEGER NOT NULL DEFAULT 100;
-- AlterTable
ALTER TABLE `UserSettings` ADD COLUMN `embedColor` INTEGER NOT NULL,
ADD COLUMN `embedDescription` VARCHAR(191) NOT NULL,
ADD COLUMN `embedTitle` VARCHAR(191) NOT NULL,
ADD COLUMN `linkToRaw` BOOLEAN NOT NULL DEFAULT false;

View file

@ -0,0 +1,7 @@
-- DropIndex
DROP INDEX `Upload_fileName_key` ON `Upload`;
-- AlterTable
ALTER TABLE `UserSettings` MODIFY `embedColor` INTEGER NOT NULL DEFAULT 0,
MODIFY `embedDescription` VARCHAR(191) NOT NULL DEFAULT 'Uploaded by {{username}} at {{time}}',
MODIFY `embedTitle` VARCHAR(191) NOT NULL DEFAULT '{{file}}';

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

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `UserSettings` MODIFY `embedColor` INTEGER NOT NULL DEFAULT 3159110;

View file

@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `internalName` to the `Upload` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE `Upload` ADD COLUMN `internalName` LONGTEXT NOT NULL;

View file

@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `size` to the `Upload` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE `Upload` ADD COLUMN `size` BIGINT NOT NULL;

View file

@ -0,0 +1,8 @@
/*
Warnings:
- You are about to alter the column `size` on the `Upload` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Int`.
*/
-- AlterTable
ALTER TABLE `Upload` MODIFY `size` INTEGER NOT NULL;

View file

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "mysql"

65
prisma/schema.prisma Normal file
View file

@ -0,0 +1,65 @@
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id Int @id @default(autoincrement())
username String @unique
email String @unique
password String
role Role
createdAt DateTime @default(now())
lastSeen DateTime @default(now())
settings UserSettings?
// STORED AS MEGABYTES !!
maxUploadMB Int @default(100)
uploads Upload[]
sessions Session[]
}
model Session {
id String @id @unique
user User @relation(fields: [userId], references: [id])
userId Int
authorized Boolean
createdAt DateTime @default(now())
expiresAt DateTime
remoteAddress String?
}
model UserSettings {
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id])
userId Int @unique
newPostsPublic Boolean @default(true)
linkToRaw Boolean @default(false)
embedTitle String @default("{{file}}")
embedDescription String @default("Uploaded by {{username}} at {{time}}")
embedColor Int @default(3159110)
}
model Upload {
id String @id
uploader User @relation(fields: [uploaderId], references: [id])
uploaderId Int
fileName String @db.LongText
internalName String @db.LongText
size Int
public Boolean @default(true)
uploaded DateTime @default(now())
}
enum Role {
ADMINISTRATOR
USER
BANNED
}

View file

@ -1,14 +1,22 @@
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@200..1000&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
@apply dark:bg-neutral-950;
@apply dark:text-neutral-200;
@apply transition-colors;
html,
body {
@apply bg-base;
@apply text-text;
@apply transition-all;
font-family: 'Nunito', sans-serif;
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
}
strong {
@apply dark:text-white;
* {
@apply transition-colors;
}

26
src/app.d.ts vendored Normal file
View file

@ -0,0 +1,26 @@
import type { Role } from '@prisma/client';
export interface UserSafe {
id: number;
username: string;
email: string;
maxUploadMB: number;
role: Role;
}
declare global {
namespace App {
interface Error {
status: number;
message: string;
}
interface Locals {
user: UserSafe;
}
interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View file

@ -28,14 +28,14 @@
<div class="flex space-x-2">
<button on:click="history.back();">
<div
class="flex p-2 space-x-2 rounded-lg border-b-2 transition-colors 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 border-neutral-400 hover:border-neutral-500 hover:dark:border-neutral-500 dark:border-neutral-700 bg-neutral-200 dark:bg-neutral-900"
>
<p>Go Back</p>
</div>
</button>
<a
href="/"
class="flex p-2 space-x-2 rounded-lg border-b-2 transition-colors 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 border-neutral-400 hover:border-neutral-500 hover:dark:border-neutral-500 dark:border-neutral-700 bg-neutral-200 dark:bg-neutral-900"
>
<p>Go Home</p>
</a>

View file

@ -1,12 +1,43 @@
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)
const id = crypto.randomUUID();
console.log(error);
return {
id,
status,
message
};

View file

@ -1,11 +0,0 @@
<script>
export let click;
</script>
<button type="button" on:click={click}>
<div
class="flex p-2 space-x-2 rounded-lg border-b-2 transition-colors border-neutral-400 hover:border-neutral-500 hover:dark:border-neutral-500 dark:border-neutral-700 bg-neutral-200 dark:bg-neutral-900"
>
<slot />
</div>
</button>

View file

@ -0,0 +1,33 @@
<script>
import { slide } from 'svelte/transition';
import { Menu } from 'lucide-svelte';
let visible = false;
function toggle() {
visible = !visible;
}
</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>
</button>
{#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"
>
<slot />
</div>
{/if}
</div>

View file

@ -0,0 +1,7 @@
<script>
export let href = '';
</script>
<a {href} class="flex px-3 py-1.5 space-x-1 transition-all hover:bg-overlay0">
<slot />
</a>

View file

@ -0,0 +1,73 @@
<script lang="ts">
import { bytesToHumanReadable } from '$lib/index';
import { fileProgress, user } from '$lib/stores';
import { CircleAlert, X } from 'lucide-svelte';
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
export let file: File;
export let i: number;
export let remove: Function;
export let running = false;
let percent = 0,
url = '',
error = false;
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 place-content-between px-1.5 w-full h-14 rounded-md transition-all
{url ? 'bg-blue/30' : ''} {error ? 'bg-red/30' : ''}"
style={error || url
? ''
: `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 url}
<a
href={url}
class="font-bold overflow-ellipsis whitespace-nowrap text-blue overflow-x-clip"
>{file.name}</a
>
{:else}
<p class="overflow-ellipsis whitespace-nowrap overflow-x-clip">{file.name}</p>
{/if}
<div class="flex gap-0.5">
{#if file.size > get(user).maxUploadMB * 1048576}
<p class="font-bold text-red">
<CircleAlert class="w-4 h-4"></CircleAlert>
</p>
{/if}
<p
class="text-xs my-auto {file.size > get(user).maxUploadMB * 1048576
? 'text-red font-bold'
: 'text-overlay1'}"
>
{bytesToHumanReadable(file.size)}
</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

@ -0,0 +1,3 @@
<div
class="flex place-content-between px-4 w-full h-full rounded-xl shadow-md py-auto bg-crust"
></div>

View file

@ -1,16 +0,0 @@
<script>
export let type, name, id, placeholder;
</script>
<div class="flex p-2 space-x-1 rounded-lg transition-colors bg-neutral-200 dark:bg-neutral-900">
<div class="py-0.5 pr-1 border-r-2 transition-colors border-neutral-400 dark:border-neutral-700">
<slot />
</div>
<input
class="py-0.5 transition-colors bg-neutral-200 dark:bg-neutral-900"
{type}
{name}
{id}
{placeholder}
/>
</div>

View file

@ -0,0 +1,90 @@
<script>
import { fade } from 'svelte/transition';
import { Mail, SquareAsterisk, LogIn, Undo } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import Logo from '$lib/components/Logo.svelte';
import FormInput from '$lib/components/Inputs/FormInput.svelte';
import Button from '$lib/components/Inputs/Button.svelte';
import ButtonText from '$lib/components/Inputs/ButtonText.svelte';
import ButtonIcon from '$lib/components/Inputs/ButtonIcon.svelte';
export let callback = () => {};
let disabled = false;
let email = '',
password = '';
async function login() {
disabled = true;
const id = toast.loading('Logging in...');
if (!email) {
toast.error('Missing email.', { id });
return (disabled = false);
}
if (!password) {
toast.error('Missing password.', { id });
return (disabled = false);
}
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password })
}).catch((_) => toast.error(_.message));
const body = await response.json().catch((_) => toast.error(_.message));
if (!body?.success) {
toast.error(body?.error || 'Unexpected Error', { id });
return (disabled = false);
}
toast.success('Welcome, ' + body.data.username, { id });
goto('/dashboard');
}
</script>
<div class="flex justify-center items-center h-full">
<div class="flex flex-col space-y-2">
<div class=" fill-text">
<Logo />
</div>
<form on:submit|preventDefault>
<div class="flex flex-col space-y-2">
<FormInput
type={'email'}
name={'email'}
id={'email'}
placeholder={'user@example.com'}
bind:value={email}
required={true}
>
<Mail />
</FormInput>
<FormInput
type={'password'}
name={'password'}
id={'password'}
placeholder={'•'.repeat(16)}
bind:value={password}
required={true}
>
<SquareAsterisk />
</FormInput>
<div class="flex place-content-between">
<Button click={callback} {disabled}>
<ButtonIcon><Undo /></ButtonIcon>
<ButtonText><p>Go Back</p></ButtonText>
</Button>
<Button click={login} {disabled} pulse={disabled}>
<ButtonIcon><LogIn /></ButtonIcon>
<ButtonText><p>Login</p></ButtonText>
</Button>
</div>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,126 @@
<script>
import { Mail, SquareAsterisk, Undo, User, UserPlus } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import Logo from '$lib/components/Logo.svelte';
import FormInput from '$lib/components/Inputs/FormInput.svelte';
import Button from '$lib/components/Inputs/Button.svelte';
import ButtonText from '$lib/components/Inputs/ButtonText.svelte';
import ButtonIcon from '$lib/components/Inputs/ButtonIcon.svelte';
export let callback = () => {};
let disabled = false;
let username = '',
email = '',
password = '',
cpassword = '';
async function register() {
disabled = true;
const id = toast.loading('Registering...');
if (!username) {
toast.error('Missing username.', { id });
return (disabled = false);
}
if (!email) {
toast.error('Missing email.', { id });
return (disabled = false);
}
if (!password || !cpassword) {
toast.error('Missing password.', { id });
return (disabled = false);
}
if (password !== cpassword) {
toast.error('Your passwords do not match.', { id });
return (disabled = false);
}
const response = await fetch('/api/auth/register', {
method: 'POST',
body: JSON.stringify({ username, email, password })
}).catch((_) => toast.error(_.message));
const body = await response.json().catch((_) => toast.error(_.message));
if (!body?.success) {
toast.error(body?.error || 'Unexpected Error', { id });
return (disabled = false);
}
toast.success('Welcome, ' + body.data.username, { id });
goto('/dashboard');
}
</script>
<div class="flex justify-center items-center h-full">
<div class="flex flex-col space-y-2">
<div class=" fill-text">
<Logo />
</div>
<form on:submit|preventDefault>
<div class="flex flex-col space-y-2">
<FormInput
type={'username'}
name={'username'}
id={'username'}
placeholder={'Username'}
bind:value={username}
required={true}
>
<User />
</FormInput>
<FormInput
type={'email'}
name={'email'}
id={'email'}
placeholder={'user@example.com'}
bind:value={email}
required={true}
>
<Mail />
</FormInput>
<FormInput
type={'password'}
name={'password'}
id={'password'}
placeholder={'•'.repeat(16)}
bind:value={password}
required={true}
>
<SquareAsterisk />
</FormInput>
<FormInput
type={'password'}
name={'cpassword'}
id={'cpassword'}
placeholder={'•'.repeat(16)}
bind:value={cpassword}
required={true}
>
<SquareAsterisk />
</FormInput>
<div class="flex place-content-between">
<Button click={callback} {disabled}>
<ButtonIcon><Undo /></ButtonIcon>
<ButtonText>Go Back</ButtonText>
</Button>
<Button click={register} {disabled} pulse={disabled}>
<ButtonIcon><UserPlus /></ButtonIcon>
<ButtonText>Register</ButtonText>
</Button>
</div>
<p class="text-xs text-center text-overlay1">
By registering an account you agree to the <br />
<a class="underline" href="/terms" data-sveltekit-reload>Terms of Service</a> and
<a class="underline" href="/privacy" data-sveltekit-reload>Privacy Policy</a>.
</p>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,55 @@
<script>
import { Sun, Moon } from 'lucide-svelte';
import { user, darkMode } from '$lib/stores';
import HeaderLink from '$lib/components/HeaderLink.svelte';
import Dropdown from '$lib/components/Dropdown.svelte';
import DropdownButton from '$lib/components/DropdownButton.svelte';
import Logo from '$lib/components/Logo.svelte';
</script>
<div class="flex place-content-between px-4 w-full h-full rounded-xl shadow-md py-auto bg-crust">
<div class="flex my-auto md:space-x-6">
<a
href="/dashboard"
class="hidden flex-none my-auto w-20 text-xl transition-all md:block fill-text hover:scale-105 focus:scale-105 active:scale-95"
>
<Logo></Logo>
</a>
<div class="flex my-auto space-x-3">
<HeaderLink href="/dashboard">Dashboard</HeaderLink>
<HeaderLink href="/uploads">Uploads</HeaderLink>
<HeaderLink href="/links">Links</HeaderLink>
{#if $user?.role === 'ADMINISTRATOR'}
<HeaderLink href="/admin">Admin</HeaderLink>
{/if}
</div>
</div>
<div class="flex my-auto space-x-2">
<p class="font-bold">{$user?.username}</p>
<Dropdown>
<div>
<button
class="flex px-3 py-1.5 space-x-1.5 w-full transition-all hover:bg-overlay0"
on:click={() => {
$darkMode = !$darkMode;
}}
>
{#if $darkMode}
<Sun class="my-auto w-5 h-5" />
{:else}
<Moon class="my-auto w-5 h-5" />
{/if}
<p>Theme</p>
</button>
</div>
<DropdownButton href="/settings">
<p class="my-auto">Settings</p>
</DropdownButton>
<DropdownButton href="/api/auth/logout">
<p class="my-auto text-red-500">Logout</p>
</DropdownButton>
</Dropdown>
</div>
</div>

View file

@ -0,0 +1,19 @@
<script>
import { page } from '$app/stores';
export let href;
let selected = false;
page.subscribe((pg) => {
selected = pg.url.pathname === href;
});
</script>
<a
{href}
class="my-auto md:text-lg transition-all {selected
? 'font-bold tracking-wider'
: 'hover:text-overlay2 focus:text-overlay2'}"
>
<slot />
</a>

View file

@ -0,0 +1,18 @@
<script>
export let click,
disabled = false,
pulse = false;
</script>
<button
class="group/button {pulse ? 'animate-pulse cursor-wait' : ''} "
type="button"
on:click={click}
{disabled}
>
<div
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 />
</div>
</button>

View file

@ -0,0 +1,3 @@
<div class="group-disabled/button:text-overlay1">
<slot />
</div>

View file

@ -0,0 +1,3 @@
<p class="group-disabled/button:text-overlay1">
<slot />
</p>

View file

@ -0,0 +1,72 @@
<script>
import { CircleAlert, Check } from 'lucide-svelte';
export let type, name, id, placeholder, value, required;
</script>
<!-- insane that i have to do this because -->
<!-- 'type' attribute 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">
<slot />
</div>
<input
class="py-0.5 bg-crust peer placeholder:text-overlay1"
type="username"
{name}
{id}
{placeholder}
{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">
<slot />
</div>
<input
class="py-0.5 bg-crust peer placeholder:text-overlay1"
type="email"
{name}
{id}
{placeholder}
{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">
<slot />
</div>
<input
class="py-0.5 bg-crust peer placeholder:text-overlay1"
type="password"
{name}
{id}
{placeholder}
{required}
bind:value
/>
<div class="hidden my-auto peer-invalid:flex">
<CircleAlert />
</div>
<div class="my-auto peer-invalid:hidden">
<Check />
</div>
</div>
{/if}

View file

@ -0,0 +1,17 @@
<script>
export let href,
style = 'link';
</script>
{#if style === 'link'}
<a {href} class="text-blue-500">
<slot />
</a>
{:else if style === 'button'}
<a
{href}
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>
{/if}

View file

@ -1,17 +0,0 @@
<script>
export let href,
style = 'link';
</script>
{#if style === 'link'}
<a {href} class="text-blue-500">
<slot />
</a>
{:else if style === 'button'}
<a
{href}
class="flex p-2 space-x-2 rounded-lg border-b-2 transition-colors border-neutral-400 hover:border-neutral-500 hover:dark:border-neutral-500 dark:border-neutral-700 bg-neutral-200 dark:bg-neutral-900"
>
<slot />
</a>
{/if}

View file

@ -2,6 +2,7 @@
viewBox="0 0 1200 628"
version="1.1"
id="svg1"
class=" fill-inherit"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:
xmlns="http://www.w3.org/2000/svg"

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 291 KiB

View file

@ -1,5 +1,6 @@
<script>
import { browser } from '$app/environment';
import { get } from 'svelte/store';
import { darkMode } from '../stores';
if (browser) {
@ -8,16 +9,20 @@
$darkMode = localStorage.getItem('darkMode') === 'true';
darkMode.subscribe(() => {
console.log(`[ThemeHandler.svelte] Current theme is ${$darkMode ? 'dark' : 'light'} mode`);
console.log(
`[ThemeHandler.svelte] Current theme is ${get(darkMode) ? 'dark' : 'light'} mode`
);
localStorage.setItem('darkMode', $darkMode);
localStorage.setItem('darkMode', get(darkMode).toString());
if ($darkMode) {
console.log('[ThemeHandler.svelte] Setting dark mode from store');
html.classList.add('dark');
html.classList.add('frappe');
} else {
console.log('[ThemeHandler.svelte] Setting light mode from store');
html.classList.remove('dark');
html.classList.remove('frappe');
}
});
}
@ -31,9 +36,11 @@
if (darkMode) {
console.log('[ThemeHandler.svelte] Setting dark mode from localStorage');
html.classList.add('dark');
html.classList.add('frappe');
} else {
console.log('[ThemeHandler.svelte] Setting light mode from localStorage');
html.classList.remove('dark');
html.classList.remove('frappe');
}
</script>
</svelte:head>

View file

@ -1,7 +1,7 @@
<script>
import { Sun, Moon } from 'lucide-svelte';
import { darkMode } from '../stores';
import Button from './Button.svelte';
import Button from '$lib/components/Inputs/Button.svelte';
function toggleTheme() {
$darkMode = !$darkMode;

10
src/lib/config.js Normal file
View file

@ -0,0 +1,10 @@
export const COOKIE = '.FILE-UPLOADER-SESSION';
export const MAIL_WHITELIST = [
'gmail.com',
'outlook.com',
'madhouselabs.net',
'dfuser.xyz',
'liloandstit.ch',
'vea.st'
];

View file

@ -1,7 +0,0 @@
import { browser } from '$app/environment';
export function goBack() {
if (browser) {
history.back();
}
}

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

@ -0,0 +1,43 @@
import { browser } from '$app/environment';
export function goBack() {
if (browser) {
history.back();
}
}
export function bytesToHumanReadable(bytes: number) {
let i = bytes == 0 ? 0 : Math.floor(Math.log(bytes) / Math.log(1024));
return (
+(bytes / Math.pow(1024, i)).toFixed(2) * 1 + ' ' + ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB'][i]
);
}
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;
}

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;
}

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

@ -0,0 +1,140 @@
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,
internalName: string,
size: number
) {
const settings = await prisma.userSettings.findFirst({
where: { id: uploaderId }
});
return await prisma.upload.create({
data: {
id,
uploaderId,
fileName,
internalName,
size,
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,
internalName: true,
public: true,
uploaded: true,
uploader: true
}
});
}
export async function getSettings(id: number) {
if (!id) return false;
return await prisma.userSettings.findFirst({
where: {
userId: id
}
});
}

View file

@ -1,10 +0,0 @@
import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise';
const connection = await mysql.createConnection({
host: process.env.DATABASE_HOST,
user: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD
});
export default drizzle(connection);

View file

@ -1,7 +0,0 @@
import { int, mysqlTable, text } from 'drizzle-orm/mysql-core';
export const users = mysqlTable('users', {
id: int('id').autoincrement().primaryKey(),
username: text('username').unique().notNull(),
password: text('password').notNull()
});

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

@ -0,0 +1,29 @@
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 = building ? 'building' : 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));
}
if (!building) {
du();
setTimeout(du, 1000 * 60 * 5);
}

View file

@ -0,0 +1,30 @@
import { createHash } from 'node:crypto';
import { COOKIE } from '$lib/config.js';
const limits = new Map();
const CookieLimits = {
Minute: 100,
Hour: 1000
};
const ApiKeyLimits = {
Minute: 0,
Hour: 0
};
setInterval(function resetMinute() {}, 1000 * 60);
setInterval(function resetHour() {}, 1000 * 60 * 60);
function hash(input) {
createHash('sha256').update(input).digest('hex');
}
export async function cookie({ cookies }) {
const hashed = hash(cookies.get(COOKIE));
console.log(hashed);
}
export async function apiKey(event) {}
export async function handle(event) {}

View file

@ -0,0 +1,8 @@
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

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

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

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

View file

@ -0,0 +1,24 @@
<script>
import { page } from '$app/stores';
import { fade } from 'svelte/transition';
import Header from '$lib/components/Header.svelte';
import Footer from '$lib/components/Footer.svelte';
</script>
<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-4rem)] pb-2">
<slot />
</div>
{/key}
<!--
<div class="w-full h-20">
<Footer></Footer>
</div> -->
</div>

View file

@ -0,0 +1 @@
adminnn

View file

@ -0,0 +1,11 @@
/** @type {import("@sveltejs/kit").ServerLoad} */
export function load({ locals, fetch }) {
const statistics = fetch('/api/statistics').then((response) => response.json());
return {
user: locals?.user,
streamed: {
statistics
}
};
}

View file

@ -0,0 +1,149 @@
<script lang="ts">
import { Check, Upload } from 'lucide-svelte';
import { page } from '$app/stores';
import { fileProgress, user } from '$lib/stores';
import { fade, slide } from 'svelte/transition';
import FileComponent from '$lib/components/File.svelte';
import { get } from 'svelte/store';
import { request } from '$lib';
import { toast } from 'svelte-sonner';
import { onMount } from 'svelte';
export let data;
user.set(data?.user);
let input: HTMLInputElement,
files: FileList,
fileMap: Map<string, File> = new Map();
let running = false;
onMount(() => {
fileProgress.set({});
});
// 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(() => {
toast.error(k + ' failed to upload.');
progress(k, { error: true });
});
if (response && response.success) progress(k, { url: response.body });
else {
if (response && response.body)
try {
let body = JSON.parse(response.body);
toast.error(k + ' failed: ' + body.message);
} catch (_) {
toast.error(k + ' failed to upload.');
}
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>
<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">
<div>
<h1 class="text-2xl font-bold">Welcome, {data.user.username}.</h1>
<p class="text-overlay1">
Your max upload size is <span class="font-bold">{data.user.maxUploadMB} MiB</span>.
</p>
</div>
<div class="flex flex-col gap-2 p-2 mx-auto w-full rounded-lg shadow-lg bg-crust">
{#key fileMap}
{#each fileMap.values() as file, i}
<FileComponent {file} {i} {remove} {running}></FileComponent>
{/each}
{/key}
{#if !running}
<div out:slide class="flex gap-2">
<button
class="flex w-full {!fileMap.size
? 'h-36'
: 'h-14'} rounded-md outline-2 outline-dotted outline-surface2 bg-mantle group"
on:click={() => {
input.click();
}}
>
<div class="flex m-auto text-lg text-surface2 group-hover:text-overlay1">
<Upload></Upload>
</div>
</button>
{#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={upload}
>
<div class="flex m-auto text-lg text-surface2 group-hover:text-green">
<Check class=""></Check>
</div>
</button>
{/if}
</div>
{/if}
</div>
<div class="p-2 mx-auto mb-auto w-full rounded-lg shadow-lg bg-crust">
<table class="mx-auto w-full text-sm table-auto">
<tbody>
{#await data?.streamed?.statistics}
<div class="h-[66px]"></div>
{:then statistics}
<tr in:fade={{ delay: 60 * 1 }}>
<td class="font-bold">Registered Users</td>
<td class="text-right">{statistics?.users}</td>
</tr>
<tr in:fade={{ delay: 60 * 3 }}>
<td class="font-bold">Files Hosted</td>
<td class="text-right">{statistics?.files}</td>
</tr>
<tr in:fade={{ delay: 60 * 6 }}>
<td class="font-bold">File Storage</td>
<td class="text-right">{statistics?.storage}</td>
</tr>
{/await}
</tbody>
</table>
</div>
</div>
</div>

View file

View file

@ -0,0 +1,25 @@
import prisma from '$lib/server/database.js';
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();
if (+(url.searchParams.get('i') || 0) >= Math.ceil(totalUploads / 15))
error(400, { status: 403, message: 'Invalid Index' });
const uploads = prisma.upload.findMany({
skip: +(url.searchParams.get('i') || 0) * 15,
take: 15,
where: {
uploaderId: locals.user.id
},
orderBy: {
uploaded: 'desc'
}
});
return { uploads, totalUploads };
}

View file

@ -0,0 +1,60 @@
<script lang="ts">
import { File, ChevronLeft, ChevronRight } from 'lucide-svelte';
import { bytesToHumanReadable } from '$lib';
import { fade } from 'svelte/transition';
import { writable, get } from 'svelte/store';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { page } from '$app/stores';
export let data;
let pageIndex = writable(Number($page.url.searchParams.get('i')) || 0);
pageIndex.subscribe((_) => {
if (browser) {
goto('/uploads?i=' + get(pageIndex));
}
});
</script>
{#await data.uploads then uploads}
<div class="grid grid-cols-5 grid-rows-3 gap-2">
{#each uploads as upload, i}
<a
in:fade|global={{ duration: 500, delay: 100 * i }}
href="/file/{upload.id}"
class="flex flex-col gap-2 p-2 mx-auto w-full rounded-lg shadow-lg bg-crust"
>
<div class="flex w-full rounded-lg aspect-video bg-mantle">
<File size="32" class="m-auto text-surface2"></File>
</div>
<div>
<p class="font-bold overflow-ellipsis whitespace-nowrap overflow-x-clip">
{upload.fileName}
</p>
<p class="text-sm text-overlay1">{bytesToHumanReadable(upload.size)}</p>
</div>
</a>
{/each}
</div>
<div class="flex mx-auto mt-2 space-x-1 rounded-md bg-crust w-fit">
<button
class="p-2 my-auto hover:text-overlay2"
on:click={() => {
if ($pageIndex <= 0) return;
$pageIndex -= 1;
}}
>
<ChevronLeft></ChevronLeft>
</button>
<p class="p-2 my-auto">{$pageIndex + 1} / {Math.ceil(data.totalUploads / 15)}</p>
<button
class="p-2 my-auto hover:text-overlay2"
on:click={() => {
if ($pageIndex >= Math.ceil(data.totalUploads / 15) - 1) return;
$pageIndex += 1;
}}
><ChevronRight></ChevronRight>
</button>
</div>
{/await}

View file

@ -0,0 +1,10 @@
<script>
import { page } from '$app/stores';
import { fade } from 'svelte/transition';
</script>
{#key $page.url}
<div in:fade class="container">
<slot />
</div>
{/key}

View file

@ -0,0 +1,10 @@
/** @type {import("@sveltejs/kit").ServerLoad} */
export function load({ locals, fetch }) {
const statistics = fetch('/api/statistics').then((response) => response.json());
return {
streamed: {
statistics
}
};
}

View file

@ -0,0 +1,65 @@
<script>
import { fade } from 'svelte/transition';
import { LogIn, UserPlus } from 'lucide-svelte';
import { writable } from 'svelte/store';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte';
import Button from '$lib/components/Inputs/Button.svelte';
import Logo from '$lib/components/Logo.svelte';
import LoginForm from '$lib/components/Forms/LoginForm.svelte';
import RegisterForm from '$lib/components/Forms/RegisterForm.svelte';
export let data;
let state = writable('/landing');
if ($page.url.hash.replace('#', '') !== '') {
state.set($page.url.hash.replace('#', ''));
}
state.subscribe((value) => {
if (browser) {
goto('#' + value);
}
});
</script>
<div class="h-[85vh] md:h-screen">
{#if $state === '/landing'}
<div class="flex justify-center items-center h-full">
<div class="flex flex-col space-y-1.5">
<div class="text-text fill-text">
<Logo />
{#await data.streamed.statistics}
<p>Currently hosting <strong>...</strong> files.</p>
{:then stats}
<p>Currently hosting <strong>{stats.files}</strong> files.</p>
{/await}
<p class="italic">The best file uploader <strong>ever!!!</strong></p>
</div>
<div class="flex place-content-around mx-auto space-x-2">
<ThemeSwitcher />
<Button click={() => ($state = '/login')}>
<LogIn />
<p>Login</p>
</Button>
<Button click={() => ($state = '/register')}>
<UserPlus />
<p>Register</p>
</Button>
</div>
</div>
</div>
{:else if $state === '/login'}
<LoginForm callback={() => ($state = '/landing')} />
{:else if $state === '/register'}
<RegisterForm callback={() => ($state = '/landing')} />
{/if}
</div>

View file

View file

@ -4,8 +4,8 @@
import { goBack } from '$lib/';
import Button from '$lib/components/Button.svelte';
import Link from '$lib/components/Link.svelte';
import Button from '$lib/components/Inputs/Button.svelte';
import Link from '$lib/components/Inputs/Link.svelte';
</script>
<div class="flex justify-center items-center h-screen">
@ -16,11 +16,9 @@
</div>
<div class="flex space-x-2">
<Button click={goBack}>
<Undo />
<p>Go Back</p>
</Button>
<Link style="button" href="/">
<Home />
<p>Go Home</p>
</Link>
</div>

View file

@ -0,0 +1,6 @@
/** @type {import('@sveltejs/kit').ServerLoad} */
export const load = async ({ locals }) => {
return {
user: locals.user
};
};

View file

@ -2,16 +2,36 @@
import '../app.css';
import { Toaster } from 'svelte-sonner';
import { darkMode } from '$lib/stores.js';
import { CircleAlert, TriangleAlert, Info, Check, Loader } from 'lucide-svelte';
import { user } from '$lib/stores';
import ThemeHandler from '$lib/components/ThemeHandler.svelte';
import PageMeta from '$lib/components/PageMeta.svelte';
export let data;
user.set(data?.user);
</script>
<PageMeta title="cirro's file uploader" />
<ThemeHandler />
<Toaster theme={$darkMode ? 'dark' : 'light'} />
<Toaster
position={'bottom-center'}
toastOptions={{
classes: {
toast: '!bg-crust !fill-text !border-crust',
title: '!text-text',
description: '!text-subtext0',
actionButton: 'bg-zinc-400',
cancelButton: 'bg-orange-400',
closeButton: 'bg-lime-400'
}
}}
>
<Loader size="20" class="animate-spin text-text" slot="loading-icon" />
<Check size="20" class="text-text" slot="success-icon" />
<CircleAlert size="20" class="text-text" slot="error-icon" />
<Info size="20" class="text-text" slot="info-icon" />
<TriangleAlert size="20" class="text-text" slot="warning-icon" />
</Toaster>
<div class="container">
<slot />
</div>
<slot />

View file

@ -1,45 +0,0 @@
<script>
import { toast } from 'svelte-sonner';
import { LogIn, UserPlus } from 'lucide-svelte';
import ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte';
import Link from '$lib/components/Link.svelte';
import Button from '$lib/components/Button.svelte';
import Logo from '$lib/components/Logo.svelte';
function fuckYou() {
toast.error('Not Implemented');
}
</script>
<div class="flex justify-center items-center h-screen">
<div class="flex flex-col space-y-1.5">
<div>
<div class="transition-colors fill-black dark:fill-white">
<Logo />
</div>
<p>Currently hosting <strong>0</strong> files.</p>
<p>Elon musk <strong>found dead</strong> in a <strong>pool</strong></p>
</div>
<div class="flex space-x-2">
<ThemeSwitcher />
<Link style="button" href="/login">
<LogIn />
<p>Login</p>
</Link>
<Link style="button" href="/register">
<UserPlus />
<p>Register</p>
</Link>
</div>
<div class="flex flex-col">
<Button click={fuckYou}>
<p class="w-full text-center">Keycloak Login</p>
</Button>
</div>
</div>
</div>

View file

@ -0,0 +1,39 @@
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,17 @@
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));
if (!session) {
cookies.delete(COOKIE, { path: '/' });
return redirect(302, '/');
}
await deleteSession(session.id);
cookies.delete(COOKIE, { path: '/' });
redirect(302, '/');
}

View file

@ -0,0 +1,45 @@
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,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: await prisma.user.count({}),
files: await prisma.upload.count({}),
storage: bytesToHumanReadable(get(USAGE))
});
}

View file

@ -0,0 +1,44 @@
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;
if (file.size / 1048576 >= user.maxUploadMB)
return error(413, { status: 413, message: 'Content Too Large' });
let id = generateId(undefined, 10);
let internalName = `${Date.now()}-${file.name}`;
const object = await minio
.putObject(
BUCKET,
`${user.id}/${internalName}`,
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, internalName, file.size);
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,8 @@
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,43 @@
import { getUpload } from '$lib/server/database.js';
import minio, { BUCKET } from '$lib/server/minio';
import { error } from '@sveltejs/kit';
export const GET = async ({ params, locals }) => {
let id: any = params.id.split('.');
if (id.length > 1) id.pop();
id = id.join('');
const file = await getUpload(id);
if (!file) throw error(404, { status: 404, message: 'File Not Found' });
if (!file.public && locals?.user?.id !== file.uploader.id)
throw error(403, { status: 403, message: 'Forbidden' });
const object = await minio.getObject(BUCKET, `${file.uploader.id}/${file.internalName}`);
const metadata = await minio.statObject(BUCKET, `${file.uploader.id}/${file.internalName}`);
const ac = new AbortController();
ac.signal.onabort = () => object.destroy;
const stream = new ReadableStream({
start(controller) {
object.on('data', (chunk) => {
controller.enqueue(chunk);
});
object.on('end', () => {
controller.close();
});
},
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,43 @@
import { getSettings, getUpload } from '$lib/server/database';
import minio, { BUCKET } from '$lib/server/minio';
import { error } from '@sveltejs/kit';
export async function load({ params, locals }) {
const file = await getUpload(params.id);
if (!file) throw error(404, { status: 404, message: 'File Not Found' });
if (!file.public && locals?.user?.id !== file.uploader.id)
throw error(403, { status: 403, message: 'Forbidden' });
const settings = await getSettings(file.uploader.id);
if (!settings) throw error(500, { status: 500, message: 'Internal Server Error' });
const metadata = await minio.statObject(BUCKET, `${file.uploader.id}/${file.internalName}`);
function formatString(input: string) {
if (file && metadata)
return input
.replaceAll('{{file}}', file.fileName)
.replaceAll('{{username}}', file.uploader.username)
.replaceAll('{{time}}', file.uploaded.toUTCString());
}
return {
file: {
id: file.id,
fileName: file.fileName,
uploaded: file.uploaded,
size: metadata.size,
type: metadata.metaData['content-type']
},
uploader: {
username: file.uploader.username
},
settings: {
title: formatString(settings.embedTitle),
description: formatString(settings.embedDescription),
color: '#' + settings.embedColor.toString(16),
large: true
}
};
}

View file

@ -0,0 +1,56 @@
<script>
import { page } from '$app/stores';
import { bytesToHumanReadable } from '$lib';
import mime from 'mime';
import Link from '$lib/components/Inputs/Link.svelte';
export let data;
const ext = `.${mime.getExtension(data.file.type)}`;
</script>
<svelte:head>
<title>{data.file.fileName}</title>
<meta property="og:title" content={data.settings.title} />
<meta property="og:description" content={data.settings.description} />
<meta property="og:url" content="{$page.url.origin}/file/{data.file.id}" />
<meta property="og:site_name" content="cirro's file uploader" />
<meta name="theme-color" content={data.settings.color} />
{#if data.file.type.includes('video')}
<meta property="og:type" content="video.other" />
<meta property="og:video:url" content="{$page.url.origin}/download/{data.file.id}{ext}" />
{:else if data.file.type.includes('image')}
{#if data.settings.large}
<meta property="twitter:card" content="summary_large_image" />
{/if}
<meta property="og:image" content="{$page.url.origin}/download/{data.file.id}{ext}" />
{/if}
</svelte:head>
<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 mx-auto rounded-lg shadow-lg w-fit bg-crust">
{#if data.file.type.includes('video')}
<!-- svelte-ignore a11y-media-has-caption -->
<video class="h-[36rem]" src="/download/{data.file.id}{ext}" controls></video>
{:else if data.file.type.includes('image')}
<img class="h-[36rem]" src="/download/{data.file.id}{ext}" alt={data.file.id} />
{/if}
</div>
<Link style="button" href="/download/{data.file.id}{ext}">
<p class="w-full font-bold text-center">
Download ({bytesToHumanReadable(data.file.size)})
</p>
</Link>
</div>
</div>
</div>
</div>

View file

@ -1,36 +0,0 @@
<script>
import { Mail, SquareAsterisk, LogIn, Undo } from 'lucide-svelte';
import { goBack } from '$lib/';
import Logo from '$lib/components/Logo.svelte';
import FormInput from '$lib/components/FormInput.svelte';
import Button from '$lib/components/Button.svelte';
</script>
<div class="flex justify-center items-center h-screen">
<div class="flex flex-col space-y-2">
<div class="transition-colors fill-black dark:fill-white">
<Logo />
</div>
<form action="">
<div class="flex flex-col space-y-2">
<FormInput type={'email'} name={'email'} id={'email'} placeholder={'user@example.com'}>
<Mail />
</FormInput>
<FormInput type={'password'} name={'password'} id={'password'} placeholder={'•'.repeat(16)}>
<SquareAsterisk />
</FormInput>
<div class="flex place-content-between">
<Button click={goBack}>
<Undo />
<p>Go Back</p>
</Button>
<Button>
<LogIn />
<p>Login</p>
</Button>
</div>
</div>
</form>
</div>
</div>

View file

@ -1,47 +0,0 @@
<script>
import { Mail, SquareAsterisk, LogIn, Undo, User, UserPlus } from 'lucide-svelte';
import { goBack } from '$lib/';
import Logo from '$lib/components/Logo.svelte';
import FormInput from '$lib/components/FormInput.svelte';
import Button from '$lib/components/Button.svelte';
</script>
<div class="flex justify-center items-center h-screen">
<div class="flex flex-col space-y-2">
<div class="transition-colors fill-black dark:fill-white">
<Logo />
</div>
<form action="">
<div class="flex flex-col space-y-2">
<FormInput type={'username'} name={'username'} id={'username'} placeholder={'Username'}>
<User />
</FormInput>
<FormInput type={'email'} name={'email'} id={'email'} placeholder={'user@example.com'}>
<Mail />
</FormInput>
<FormInput type={'password'} name={'password'} id={'password'} placeholder={'•'.repeat(16)}>
<SquareAsterisk />
</FormInput>
<FormInput
type={'password'}
name={'cpassword'}
id={'cpassword'}
placeholder={'•'.repeat(16)}
>
<SquareAsterisk />
</FormInput>
<div class="flex place-content-between">
<Button click={goBack}>
<Undo />
<p>Go Back</p>
</Button>
<Button>
<UserPlus />
<p>Register</p>
</Button>
</div>
</div>
</form>
</div>
</div>

View file

@ -6,5 +6,9 @@ export default {
extend: {},
container: { center: true }
},
plugins: []
plugins: [
require('@catppuccin/tailwindcss')({
defaultFlavour: 'latte'
})
]
};

1744
yarn.lock

File diff suppressed because it is too large Load diff