Initial commit
This commit is contained in:
commit
b8ee1a58f7
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
HELP.md
|
||||||
|
.gradle
|
||||||
|
build/
|
||||||
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
|
!**/src/main/**/build/
|
||||||
|
!**/src/test/**/build/
|
||||||
|
|
||||||
|
### STS ###
|
||||||
|
.apt_generated
|
||||||
|
.classpath
|
||||||
|
.factorypath
|
||||||
|
.project
|
||||||
|
.settings
|
||||||
|
.springBeans
|
||||||
|
.sts4-cache
|
||||||
|
bin/
|
||||||
|
!**/src/main/**/bin/
|
||||||
|
!**/src/test/**/bin/
|
||||||
|
|
||||||
|
### IntelliJ IDEA ###
|
||||||
|
.idea
|
||||||
|
*.iws
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
out/
|
||||||
|
!**/src/main/**/out/
|
||||||
|
!**/src/test/**/out/
|
||||||
|
|
||||||
|
### NetBeans ###
|
||||||
|
/nbproject/private/
|
||||||
|
/nbbuild/
|
||||||
|
/dist/
|
||||||
|
/nbdist/
|
||||||
|
/.nb-gradle/
|
||||||
|
|
||||||
|
### VS Code ###
|
||||||
|
.vscode/
|
25
build.gradle.kts
Normal file
25
build.gradle.kts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
plugins {
|
||||||
|
java
|
||||||
|
id("org.springframework.boot") version "3.3.3"
|
||||||
|
id("io.spring.dependency-management") version "1.1.6"
|
||||||
|
id("org.graalvm.buildtools.native") version "0.10.2"
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "de.mattv"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
java {
|
||||||
|
toolchain {
|
||||||
|
languageVersion = JavaLanguageVersion.of(21)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("org.springframework.boot:spring-boot-starter-mail")
|
||||||
|
implementation("org.springframework.boot:spring-boot-starter-security")
|
||||||
|
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||||
|
}
|
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
3
frontend/README.md
Normal file
3
frontend/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
https://github.com/kpulkit29/svelte-tree-viewer
|
||||||
|
|
||||||
|
|
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>MFileserver</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
3581
frontend/package-lock.json
generated
Normal file
3581
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
frontend/package.json
Normal file
41
frontend/package.json
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@iconify/json": "^2.2.132",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^2.4.2",
|
||||||
|
"@tsconfig/svelte": "^5.0.0",
|
||||||
|
"@types/node": "^20.8.6",
|
||||||
|
"@types/qrcode-svg": "^1.1.2",
|
||||||
|
"autoprefixer": "^10.4.14",
|
||||||
|
"flowbite": "^1.8.1",
|
||||||
|
"flowbite-svelte": "^0.44.18",
|
||||||
|
"postcss": "^8.4.24",
|
||||||
|
"postcss-load-config": "^4.0.1",
|
||||||
|
"svelte": "^4.0.5",
|
||||||
|
"svelte-check": "^3.4.6",
|
||||||
|
"tailwindcss": "^3.3.2",
|
||||||
|
"tslib": "^2.6.0",
|
||||||
|
"typescript": "^5.0.2",
|
||||||
|
"unplugin-icons": "^0.17.1",
|
||||||
|
"vite": "^4.4.5",
|
||||||
|
"vite-plugin-html": "^3.2.0",
|
||||||
|
"vite-plugin-singlefile": "^0.13.5",
|
||||||
|
"vite-plugin-tailwind-purgecss": "^0.1.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@microsoft/fetch-event-source": "^2.0.1",
|
||||||
|
"filesize": "^10.1.0",
|
||||||
|
"qrcode-svg": "^1.1.0",
|
||||||
|
"svelte-spa-router": "^3.3.0",
|
||||||
|
"tailwind-merge": "^1.14.0"
|
||||||
|
}
|
||||||
|
}
|
13
frontend/postcss.config.cjs
Normal file
13
frontend/postcss.config.cjs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
const tailwindcss = require("tailwindcss");
|
||||||
|
const autoprefixer = require("autoprefixer");
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
plugins: [
|
||||||
|
//Some plugins, like tailwindcss/nesting, need to run before Tailwind,
|
||||||
|
tailwindcss(),
|
||||||
|
//But others, like autoprefixer, need to run after,
|
||||||
|
autoprefixer,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = config;
|
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32"><path d="M28 20h-2v2h2v6H4v-6h2v-2H4a2.002 2.002 0 0 0-2 2v6a2.002 2.002 0 0 0 2 2h24a2.002 2.002 0 0 0 2-2v-6a2.002 2.002 0 0 0-2-2z" fill="currentColor"></path><circle cx="7" cy="25" r="1" fill="currentColor"></circle><path d="M22.707 7.293l-5-5A1 1 0 0 0 17 2h-6a2.002 2.002 0 0 0-2 2v16a2.002 2.002 0 0 0 2 2h10a2.002 2.002 0 0 0 2-2V8a1 1 0 0 0-.293-.707zM20.586 8H17V4.414zM11 20V4h4v4a2.002 2.002 0 0 0 2 2h4v10z" fill="currentColor"></path></svg>
|
After Width: | Height: | Size: 557 B |
57
frontend/src/App.svelte
Normal file
57
frontend/src/App.svelte
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {error_banner, info_banner, rpc, session, show_working, token, workingWrapperO} from './store';
|
||||||
|
import {Banner, Navbar, NavBrand, Spinner} from 'flowbite-svelte';
|
||||||
|
import Router, {replace} from 'svelte-spa-router';
|
||||||
|
import {routes} from './routes';
|
||||||
|
import {FileStorage} from './icons';
|
||||||
|
import LinkButton from './components/LinkButton.svelte';
|
||||||
|
import A from './components/A.svelte';
|
||||||
|
|
||||||
|
const s = session.s;
|
||||||
|
|
||||||
|
async function leaveSudo() {
|
||||||
|
await workingWrapperO(() => rpc.Admin_unsudo($token ?? ''));
|
||||||
|
await session.update($token);
|
||||||
|
await replace('/admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
rpc.Auth_logout($token ?? '');
|
||||||
|
token.set(null);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="h-screen w-screen p-4 flex flex-col">
|
||||||
|
{#if $error_banner.length > 0}
|
||||||
|
<Banner dismissable position="absolute" divClass="z-10 flex justify-between p-4 !bg-red-50" on:close={() => error_banner.set('')}>
|
||||||
|
{$error_banner}
|
||||||
|
</Banner>
|
||||||
|
{/if}
|
||||||
|
{#if $info_banner.length > 0}
|
||||||
|
<Banner dismissable position="absolute" on:close={() => info_banner.set('')}>
|
||||||
|
{$info_banner}
|
||||||
|
</Banner>
|
||||||
|
{/if}
|
||||||
|
{#if $show_working}
|
||||||
|
<Banner position="absolute" dismissable={false}><Spinner size="5" class="mr-2" />Working</Banner>
|
||||||
|
{/if}
|
||||||
|
<Navbar class="flex-grow-0">
|
||||||
|
<NavBrand href={$token == null ? '#/login' : '#/view/0'}>
|
||||||
|
<FileStorage width="1.5em" height="1.5em"/>
|
||||||
|
<span id="navbar-text" class="ml-2">MFileserver</span>
|
||||||
|
</NavBrand>
|
||||||
|
|
||||||
|
{#if $token != null}
|
||||||
|
<div class="flex md:order-2 gap-x-2">
|
||||||
|
{#if $s?.sudo} <LinkButton on:click={leaveSudo}>Leave sudo</LinkButton> {/if}
|
||||||
|
{#if $s?.admin} <A href="#/admin">Admin</A> {/if}
|
||||||
|
<A href="#/view/0">Files</A>
|
||||||
|
<A href="#/profile">Profile</A>
|
||||||
|
<LinkButton on:click={logout}>Logout</LinkButton>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Navbar>
|
||||||
|
<span class="grid justify-items-center mt-10">
|
||||||
|
<Router {routes} />
|
||||||
|
</span>
|
||||||
|
</main>
|
311
frontend/src/api.ts
Normal file
311
frontend/src/api.ts
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
import { fetchEventSource } from '@microsoft/fetch-event-source';
|
||||||
|
|
||||||
|
|
||||||
|
export interface ResponseE {
|
||||||
|
e: string,
|
||||||
|
o: null
|
||||||
|
}
|
||||||
|
interface ResponseO<T> {
|
||||||
|
e: null,
|
||||||
|
o: T
|
||||||
|
}
|
||||||
|
export type Response<T> = ResponseE | ResponseO<T>;
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
otp_needed: boolean;
|
||||||
|
token: (string|null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
name: string;
|
||||||
|
tfa_enabled: boolean;
|
||||||
|
admin: boolean;
|
||||||
|
sudo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserInfo {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
tfa: boolean;
|
||||||
|
admin: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Node {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
file: boolean;
|
||||||
|
preview: boolean;
|
||||||
|
parent: (number|null);
|
||||||
|
size: (number|null);
|
||||||
|
children: (Node[]|null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateNodeInfo {
|
||||||
|
id: number;
|
||||||
|
exists: boolean;
|
||||||
|
file: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZipInfo {
|
||||||
|
done: boolean;
|
||||||
|
progress: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PathSegment {
|
||||||
|
name: string;
|
||||||
|
id: (number|null);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class MRPCConnector {
|
||||||
|
url: string;
|
||||||
|
|
||||||
|
private __create_msg(service: string, method: string, data: any) {
|
||||||
|
return {service, method, data};
|
||||||
|
}
|
||||||
|
|
||||||
|
public constructor(url: string) {
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public Auth_signup(username: string, password: string): Promise<(string|null)> {
|
||||||
|
const __msg = this.__create_msg('Auth', 'signup', {username,password});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Auth_login(username: string, password: string, otp: (string|null)): Promise<Response<LoginResponse>> {
|
||||||
|
const __msg = this.__create_msg('Auth', 'login', {username,password,otp});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Auth_send_recovery_key(username: string): Promise<void> {
|
||||||
|
const __msg = this.__create_msg('Auth', 'send_recovery_key', {username});
|
||||||
|
return fetch(this.url, {method: 'POST', body: JSON.stringify(__msg)}).then(__r => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Auth_reset_password(key: string, password: string): Promise<(string|null)> {
|
||||||
|
const __msg = this.__create_msg('Auth', 'reset_password', {key,password});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Auth_change_password(token: string, old_pw: string, new_pw: string): Promise<(string|null)> {
|
||||||
|
const __msg = this.__create_msg('Auth', 'change_password', {token,old_pw,new_pw});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Auth_logout(token: string): Promise<void> {
|
||||||
|
const __msg = this.__create_msg('Auth', 'logout', {token});
|
||||||
|
return fetch(this.url, {method: 'POST', body: JSON.stringify(__msg)}).then(__r => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Auth_logout_all(token: string): Promise<(string|null)> {
|
||||||
|
const __msg = this.__create_msg('Auth', 'logout_all', {token});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Auth_tfa_setup_mail(token: string): Promise<(string|null)> {
|
||||||
|
const __msg = this.__create_msg('Auth', 'tfa_setup_mail', {token});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Auth_tfa_setup_totp(token: string): Promise<Response<string>> {
|
||||||
|
const __msg = this.__create_msg('Auth', 'tfa_setup_totp', {token});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Auth_tfa_complete(token: string, otp: string): Promise<(string|null)> {
|
||||||
|
const __msg = this.__create_msg('Auth', 'tfa_complete', {token,otp});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Auth_tfa_disable(token: string): Promise<(string|null)> {
|
||||||
|
const __msg = this.__create_msg('Auth', 'tfa_disable', {token});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Auth_delete_user(token: string): Promise<(string|null)> {
|
||||||
|
const __msg = this.__create_msg('Auth', 'delete_user', {token});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Auth_session_info(token: string): Promise<Response<Session>> {
|
||||||
|
const __msg = this.__create_msg('Auth', 'session_info', {token});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Admin_list_users(token: string): Promise<Response<UserInfo[]>> {
|
||||||
|
const __msg = this.__create_msg('Admin', 'list_users', {token});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Admin_delete_user(token: string, user: number): Promise<(string|null)> {
|
||||||
|
const __msg = this.__create_msg('Admin', 'delete_user', {token,user});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Admin_logout(token: string, user: number): Promise<(string|null)> {
|
||||||
|
const __msg = this.__create_msg('Admin', 'logout', {token,user});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Admin_disable_tfa(token: string, user: number): Promise<(string|null)> {
|
||||||
|
const __msg = this.__create_msg('Admin', 'disable_tfa', {token,user});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Admin_set_admin(token: string, user: number, admin: boolean): Promise<(string|null)> {
|
||||||
|
const __msg = this.__create_msg('Admin', 'set_admin', {token,user,admin});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Admin_set_enabled(token: string, user: number, enabled: boolean): Promise<(string|null)> {
|
||||||
|
const __msg = this.__create_msg('Admin', 'set_enabled', {token,user,enabled});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Admin_sudo(token: string, user: number): Promise<(string|null)> {
|
||||||
|
const __msg = this.__create_msg('Admin', 'sudo', {token,user});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Admin_unsudo(token: string): Promise<(string|null)> {
|
||||||
|
const __msg = this.__create_msg('Admin', 'unsudo', {token});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Admin_shutdown(token: string): Promise<(string|null)> {
|
||||||
|
const __msg = this.__create_msg('Admin', 'shutdown', {token});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public FS_get_node(token: string, node: number): Promise<Response<Node>> {
|
||||||
|
const __msg = this.__create_msg('FS', 'get_node', {token,node});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public FS_get_path(token: string, node: number): Promise<Response<PathSegment[]>> {
|
||||||
|
const __msg = this.__create_msg('FS', 'get_path', {token,node});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public FS_get_nodes_size(token: string, nodes: number[]): Promise<Response<number>> {
|
||||||
|
const __msg = this.__create_msg('FS', 'get_nodes_size', {token,nodes});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public FS_create_node(token: string, file: boolean, parent: number, name: string): Promise<Response<CreateNodeInfo>> {
|
||||||
|
const __msg = this.__create_msg('FS', 'create_node', {token,file,parent,name});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public FS_move_nodes(token: string, nodes: number[], parent: number): Promise<(string|null)> {
|
||||||
|
const __msg = this.__create_msg('FS', 'move_nodes', {token,nodes,parent});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public FS_delete_nodes(token: string, nodes: number[], __cbk: (v: string|null) => void) {
|
||||||
|
const __msg = this.__create_msg('FS', 'delete_nodes', {token,nodes});
|
||||||
|
fetchEventSource(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg),
|
||||||
|
onmessage: __e => __cbk(JSON.parse(__e.data)),
|
||||||
|
onerror: __e => {throw __e;},
|
||||||
|
onclose: () => __cbk(null)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public FS_download_preview(token: string, node: number): Promise<Response<string>> {
|
||||||
|
const __msg = this.__create_msg('FS', 'download_preview', {token,node});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
public FS_get_mime(token: string, node: number): Promise<Response<string>> {
|
||||||
|
const __msg = this.__create_msg('FS', 'get_mime', {token,node});
|
||||||
|
return fetch(this.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(__msg)
|
||||||
|
}).then((__r) => __r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
20
frontend/src/app.pcss
Normal file
20
frontend/src/app.pcss
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
@tailwind base;
|
||||||
|
|
||||||
|
body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
font-family: Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-screen-90 {
|
||||||
|
height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
@tailwind components;
|
||||||
|
|
||||||
|
@tailwind utilities;
|
8
frontend/src/components/A.svelte
Normal file
8
frontend/src/components/A.svelte
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {A} from 'flowbite-svelte';
|
||||||
|
export let href: string;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<A {href} aClass="hover:text-primary-400 transition-colors">
|
||||||
|
<slot></slot>
|
||||||
|
</A>
|
55
frontend/src/components/DeleteModal.svelte
Normal file
55
frontend/src/components/DeleteModal.svelte
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {rpc, show_working, token} from '../store';
|
||||||
|
import {Button, ButtonGroup, Modal} from 'flowbite-svelte';
|
||||||
|
import {afterUpdate, createEventDispatcher} from 'svelte';
|
||||||
|
|
||||||
|
let show_confirm = false;
|
||||||
|
let show_modal = false;
|
||||||
|
let pre_element: HTMLElement|null = null;
|
||||||
|
let text = '';
|
||||||
|
let nodes: number[] = [];
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{reload_node: null}>();
|
||||||
|
|
||||||
|
async function real_delete() {
|
||||||
|
show_confirm = false;
|
||||||
|
show_modal = true;
|
||||||
|
text = '';
|
||||||
|
show_working.set(true);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
rpc.FS_delete_nodes($token ?? '', nodes, (v) => {
|
||||||
|
if (v == null)
|
||||||
|
resolve();
|
||||||
|
else {
|
||||||
|
text += v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
show_working.set(false);
|
||||||
|
show_modal = false;
|
||||||
|
dispatch('reload_node');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const del = async (n: number[]) => {
|
||||||
|
nodes = n;
|
||||||
|
show_confirm = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterUpdate(() => {
|
||||||
|
if (pre_element)
|
||||||
|
pre_element.scroll({ top: pre_element.scrollHeight, behavior: 'instant' });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal bind:open={show_confirm} dismissable={false} title="Do you really want to delete these files?">
|
||||||
|
<ButtonGroup class="w-full flex flex-nowrap">
|
||||||
|
<Button class="flex-1" color="green" on:click={() => show_confirm = false}>No</Button>
|
||||||
|
<Button class="flex-1" color="red" on:click={real_delete}>Yes</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal bind:open={show_modal} dismissable={false} size="xl" title="Deleting" bodyClass="h-full p-0 space-y-0" class="h-screen-90">
|
||||||
|
<pre bind:this={pre_element} class="bg-gray-200 text-gray-600 px-4 py-2 h-full overflow-y-auto overscroll-contain rounded-b">{text}</pre>
|
||||||
|
</Modal>
|
195
frontend/src/components/DirViewer.svelte
Normal file
195
frontend/src/components/DirViewer.svelte
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
<script context="module" lang="ts">
|
||||||
|
import {writable} from 'svelte/store';
|
||||||
|
const show_preview = writable<boolean>(false);
|
||||||
|
</script>
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
ButtonGroup,
|
||||||
|
Checkbox,
|
||||||
|
Dropdown,
|
||||||
|
DropdownItem,
|
||||||
|
Input,
|
||||||
|
InputAddon,
|
||||||
|
Modal,
|
||||||
|
Spinner,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableBodyCell,
|
||||||
|
TableBodyRow,
|
||||||
|
TableHead,
|
||||||
|
TableHeadCell,
|
||||||
|
Tooltip
|
||||||
|
} from 'flowbite-svelte';
|
||||||
|
import {filesize} from 'filesize';
|
||||||
|
import {Folder, FolderParent, DocumentBlank, CaretLeft, FolderAdd} from '../icons';
|
||||||
|
import {api, download, token, rpc, workingWrapperR, error_banner} from '../store';
|
||||||
|
import LinkButton from './LinkButton.svelte';
|
||||||
|
import DeleteModal from './DeleteModal.svelte';
|
||||||
|
import A from './A.svelte';
|
||||||
|
import {createEventDispatcher} from 'svelte';
|
||||||
|
|
||||||
|
export let node: api.Node;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{reload_node: null}>();
|
||||||
|
|
||||||
|
let selected: number[] = [];
|
||||||
|
let nodes: api.Node[], dirs: api.Node[], files: api.Node[], previews: {[key: number]: string|null} = {};
|
||||||
|
let total_size: number;
|
||||||
|
$: { nodes = node.children!; selectNone(); }
|
||||||
|
$: dirs = nodes.filter(v => !v.file).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
$: files = nodes.filter(v => v.file).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
$: total_size = files.map(v => v.size).reduce<number>((a, b) => (a + b!), 0);
|
||||||
|
$: {
|
||||||
|
if ($show_preview) {
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.preview) continue;
|
||||||
|
getPreview(file.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let show_new_folder = false, new_folder_name = '';
|
||||||
|
const new_folder_keyup = (e: KeyboardEvent) => { if(e.key == 'Enter') newFolder(); }
|
||||||
|
async function newFolder() {
|
||||||
|
if (new_folder_name.length === 0)
|
||||||
|
return error_banner.set('Folder name can\'t be empty');
|
||||||
|
|
||||||
|
show_new_folder = false;
|
||||||
|
const resp = await workingWrapperR<api.CreateNodeInfo>(() => rpc.FS_create_node($token ?? '', false, node.id, new_folder_name));
|
||||||
|
if (resp && resp.file)
|
||||||
|
return error_banner.set('Folder already exists as file');
|
||||||
|
|
||||||
|
dispatch('reload_node');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPreview(node: number) {
|
||||||
|
const resp = await rpc.FS_download_preview($token ?? '', node);
|
||||||
|
if (resp.o == null)
|
||||||
|
return;
|
||||||
|
previews[node] = 'data:image/png;base64,' + resp.o;
|
||||||
|
previews = previews;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ctx_node: api.Node;
|
||||||
|
let ctx_hidden = true;
|
||||||
|
let ctx_x = 0, ctx_y = 0;
|
||||||
|
let ctx_style: string;
|
||||||
|
$: ctx_style = `top: ${ctx_y}px; left: ${ctx_x}px; position: fixed;`;
|
||||||
|
|
||||||
|
function onCtxMenu(node: api.Node, e: MouseEvent) {
|
||||||
|
console.log(e);
|
||||||
|
e.preventDefault();
|
||||||
|
if (!ctx_hidden)
|
||||||
|
return ctx_hidden = true;
|
||||||
|
ctx_x = e.clientX;
|
||||||
|
ctx_y = e.clientY;
|
||||||
|
ctx_node = node;
|
||||||
|
ctx_hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectAll = () => selected = nodes.map(v => v.id);
|
||||||
|
const selectFolders = () => selected = dirs.map(v => v.id);
|
||||||
|
const selectFiles = () => selected = files.map(v => v.id);
|
||||||
|
const selectNone = () => selected = [];
|
||||||
|
const downloadSelected = () => download($token ?? '', nodes.filter(v => selected.includes(v.id)));
|
||||||
|
const deleteSelected = () => del(selected);
|
||||||
|
|
||||||
|
|
||||||
|
const onCtxDownload = () => download($token ?? '', [ctx_node]);
|
||||||
|
|
||||||
|
let del: (nodes: number[]) => Promise<void>;
|
||||||
|
const onCtxDelete = () => del([ctx_node.id]);
|
||||||
|
|
||||||
|
const onShowPreview = (e: Event) => { show_preview.set((e.target as HTMLInputElement).checked); }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:body on:click={() => (ctx_hidden = true)} />
|
||||||
|
|
||||||
|
<DeleteModal bind:del={del} on:reload_node />
|
||||||
|
|
||||||
|
<Table hoverable>
|
||||||
|
<TableHead theadClass="text-xs">
|
||||||
|
<TableHeadCell class="p-2 pl-4 w-0 h-0">
|
||||||
|
<CaretLeft id="dropdown-button" />
|
||||||
|
</TableHeadCell>
|
||||||
|
<TableHeadCell class="p-2 w-0"><Checkbox checked={$show_preview} on:change={onShowPreview} /><Tooltip>Show image previews</Tooltip></TableHeadCell>
|
||||||
|
<TableHeadCell>Name</TableHeadCell>
|
||||||
|
<TableHeadCell>Size</TableHeadCell>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{#if node.parent !== null}
|
||||||
|
<TableBodyRow>
|
||||||
|
<TableBodyCell class="!p-4"></TableBodyCell>
|
||||||
|
<TableBodyCell class="px-2 w-0"><FolderParent /></TableBodyCell>
|
||||||
|
<TableBodyCell class="pl-0"><A href={'#/view/' + node.parent}>..</A></TableBodyCell>
|
||||||
|
<TableBodyCell></TableBodyCell>
|
||||||
|
</TableBodyRow>
|
||||||
|
{/if}
|
||||||
|
{#each dirs as node}
|
||||||
|
<TableBodyRow on:contextmenu={onCtxMenu.bind(null, node)}>
|
||||||
|
<TableBodyCell class="p-2 pl-4 w-0 h-0"><Checkbox bind:group={selected} value={node.id}/></TableBodyCell>
|
||||||
|
<TableBodyCell class="px-2 w-0"><Folder /></TableBodyCell>
|
||||||
|
<TableBodyCell class="pl-0"><A href={'#/view/' + node.id}>{node.name}</A></TableBodyCell>
|
||||||
|
<TableBodyCell></TableBodyCell>
|
||||||
|
</TableBodyRow>
|
||||||
|
{/each}
|
||||||
|
{#each files as node}
|
||||||
|
<TableBodyRow on:contextmenu={onCtxMenu.bind(null, node)}>
|
||||||
|
<TableBodyCell class="p-2 pl-4 w-0 h-0"><Checkbox bind:group={selected} value={node.id}/></TableBodyCell>
|
||||||
|
<TableBodyCell class="px-2 min-w-0">
|
||||||
|
{#if $show_preview && node.preview}
|
||||||
|
{#if previews[node.id] !== undefined}
|
||||||
|
<img class="w-screen max-w-xs" alt="preview" src={previews[node.id]} />
|
||||||
|
{:else}
|
||||||
|
<Spinner size="4"/>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<DocumentBlank />
|
||||||
|
{/if}
|
||||||
|
</TableBodyCell>
|
||||||
|
<TableBodyCell class="pl-0"><A href={'#/view/' + node.id}>{node.name}</A></TableBodyCell>
|
||||||
|
<TableBodyCell>{filesize(node.size ?? 0, {base: 2, standard: 'jedec'})}</TableBodyCell>
|
||||||
|
</TableBodyRow>
|
||||||
|
{/each}
|
||||||
|
</TableBody>
|
||||||
|
<tfoot class="text-gray-700 bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-3" colspan="3">
|
||||||
|
<LinkButton on:click={() => (show_new_folder = true)} class="mr-3">New folder</LinkButton>
|
||||||
|
{#if selected.length > 0}
|
||||||
|
<LinkButton on:click={downloadSelected}>Download</LinkButton>
|
||||||
|
<LinkButton color="red" on:click={deleteSelected}>Delete</LinkButton>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-3">{filesize(total_size, {base: 2, standard: 'jedec'})}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<Modal bind:open={show_new_folder} outsideclose title="Create new folder">
|
||||||
|
<ButtonGroup class="w-full mb-4">
|
||||||
|
<InputAddon><FolderAdd /></InputAddon>
|
||||||
|
<Input type="text" placeholder="Name" bind:value={new_folder_name} on:keyup={new_folder_keyup}></Input>
|
||||||
|
</ButtonGroup>
|
||||||
|
<span class="w-full flex">
|
||||||
|
<span class="flex-1 mr-2"></span>
|
||||||
|
<Button outline on:click={newFolder}>Create folder</Button>
|
||||||
|
</span>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Dropdown triggeredBy="#dropdown-button" trigger="hover" placement="left">
|
||||||
|
<DropdownItem on:click={selectAll}>Select all</DropdownItem>
|
||||||
|
<DropdownItem on:click={selectFolders}>Select folders</DropdownItem>
|
||||||
|
<DropdownItem on:click={selectFiles}>Select files</DropdownItem>
|
||||||
|
<DropdownItem on:click={selectNone}>Select none</DropdownItem>
|
||||||
|
</Dropdown>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
|
<div style={ctx_style} hidden={ctx_hidden} class="z-50 shadow-md rounded-lg border-gray-100 bg-white" on:contextmenu={() => (ctx_hidden = true)}>
|
||||||
|
<ul class="py-1">
|
||||||
|
<li><button class="font-medium py-2 px-4 text-sm hover:bg-gray-100 w-full text-left" on:click={onCtxDownload}>Download</button></li>
|
||||||
|
<li><button class="font-medium py-2 px-4 text-sm hover:bg-gray-100 text-red-400 w-full text-left" on:click={onCtxDelete}>Delete</button></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
57
frontend/src/components/FileViewer.svelte
Normal file
57
frontend/src/components/FileViewer.svelte
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {Button, Spinner} from 'flowbite-svelte';
|
||||||
|
import {Download} from '../icons';
|
||||||
|
import {api, download, rpc, token, workingWrapperR} from '../store';
|
||||||
|
import {onDestroy} from 'svelte';
|
||||||
|
|
||||||
|
export let node: api.Node;
|
||||||
|
|
||||||
|
let src = '';
|
||||||
|
let loading = false;
|
||||||
|
|
||||||
|
let mime: string|null;
|
||||||
|
let image: boolean, video: boolean, audio: boolean, pdf: boolean, can_display: boolean;
|
||||||
|
$: workingWrapperR<string>(() => rpc.FS_get_mime($token ?? '', node.id)).then(v => mime = v);
|
||||||
|
$: image = mime?.startsWith('image/') ?? false;
|
||||||
|
$: video = mime?.startsWith('video/') ?? false;
|
||||||
|
$: audio = mime?.startsWith('audio/') ?? false;
|
||||||
|
$: pdf = mime === 'application/pdf';
|
||||||
|
$: can_display = image || video || audio || pdf;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
if (src.startsWith('blob'))
|
||||||
|
URL.revokeObjectURL(src);
|
||||||
|
const resp = await fetch('/download', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: `token=${$token ?? ''}&node=${node.id}`
|
||||||
|
});
|
||||||
|
if (resp.status != 200)
|
||||||
|
return;
|
||||||
|
src = URL.createObjectURL(await resp.blob());
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if (image || pdf) load();
|
||||||
|
|
||||||
|
onDestroy(() => { if (src.startsWith('blob')) URL.revokeObjectURL(src); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button class="w-full mb-6" on:click={() => download($token ?? '', [node])}><Download />Download</Button>
|
||||||
|
{#if can_display && !loading && src === ''}
|
||||||
|
<Button class="w-full" outline on:click={load}>Load</Button>
|
||||||
|
{:else if loading}
|
||||||
|
<Spinner class="w-full" />
|
||||||
|
{:else if can_display && src !== ''}
|
||||||
|
{#if image}
|
||||||
|
<img class="w-full" alt="img" src={src} />
|
||||||
|
{:else if video}
|
||||||
|
<!-- svelte-ignore a11y-media-has-caption -->
|
||||||
|
<video class="w-full" src={src} controls autoplay />
|
||||||
|
{:else if audio}
|
||||||
|
<audio class="w-full" src={src} controls autoplay />
|
||||||
|
{:else if pdf}
|
||||||
|
<embed class="w-full" style="height: 75vh" src={src} type="application/pdf" />
|
||||||
|
{/if}
|
||||||
|
{/if}
|
22
frontend/src/components/LinkButton.svelte
Normal file
22
frontend/src/components/LinkButton.svelte
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<script>
|
||||||
|
import {twMerge} from "tailwind-merge";
|
||||||
|
|
||||||
|
export let color = 'primary';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button class={twMerge('link-button transition-colors', `text-${color}-600`, `hover:text-${color}-400`, $$props.class)} on:click>
|
||||||
|
<slot>
|
||||||
|
<span class="text-primary-600 hover:text-primary-400"></span>
|
||||||
|
<span class="text-amber-600 hover:text-amber-400"></span>
|
||||||
|
<span class="text-red-600 hover:text-red-400"></span>
|
||||||
|
</slot>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.link-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
149
frontend/src/components/UploadModal.svelte
Normal file
149
frontend/src/components/UploadModal.svelte
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {token, type UploadFile} from '../store';
|
||||||
|
import {Button, Modal, Progressbar} from 'flowbite-svelte';
|
||||||
|
import {filesize} from 'filesize';
|
||||||
|
import {createEventDispatcher} from 'svelte';
|
||||||
|
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{reload_node: null}>();
|
||||||
|
|
||||||
|
interface MyFile extends UploadFile {
|
||||||
|
waiting: boolean,
|
||||||
|
done: boolean,
|
||||||
|
current: number,
|
||||||
|
total: number,
|
||||||
|
progress: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let aborting = false;
|
||||||
|
let done = false;
|
||||||
|
let show_modal = false;
|
||||||
|
let files: MyFile[] = [];
|
||||||
|
let current = 0, total = 0;
|
||||||
|
let progress: number;
|
||||||
|
$: progress = total == 0 ? 0 : (current / total * 100);
|
||||||
|
let not_done_files: MyFile[], done_files: MyFile[];
|
||||||
|
$: not_done_files = files.filter(f => !f.done);
|
||||||
|
$: done_files = files.filter(f => f.done);
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
show_modal = false;
|
||||||
|
dispatch('reload_node');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function realUpload(file: MyFile) {
|
||||||
|
if (file.done)
|
||||||
|
return;
|
||||||
|
file.waiting = false;
|
||||||
|
let load_progress = 0;
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.onloadend = resolve;
|
||||||
|
xhr.onerror = resolve;
|
||||||
|
xhr.upload.onprogress = ev => {
|
||||||
|
current += ev.loaded - load_progress;
|
||||||
|
load_progress = ev.loaded;
|
||||||
|
file.current = ev.loaded;
|
||||||
|
file.progress = file.current / file.total * 100;
|
||||||
|
files = files;
|
||||||
|
if (file.current == file.total)
|
||||||
|
resolve(null);
|
||||||
|
};
|
||||||
|
xhr.open('POST', '/upload', true);
|
||||||
|
xhr.setRequestHeader('X-Node', file.id.toString());
|
||||||
|
xhr.setRequestHeader('X-Token', $token ?? '');
|
||||||
|
xhr.send(file.file);
|
||||||
|
});
|
||||||
|
current += file.total - load_progress;
|
||||||
|
file.done = true;
|
||||||
|
files = files;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const upload = async (fs: UploadFile[]) => {
|
||||||
|
aborting = false;
|
||||||
|
done = false;
|
||||||
|
current = 0;
|
||||||
|
total = 0;
|
||||||
|
show_modal = true;
|
||||||
|
|
||||||
|
fs.forEach(f => (total += f.file.size));
|
||||||
|
files = fs.map(f => {
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
waiting: f.file.size != 0,
|
||||||
|
done: f.file.size == 0,
|
||||||
|
current: 0,
|
||||||
|
total: f.file.size,
|
||||||
|
progress: 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const in_flight = new Set();
|
||||||
|
for (const file of files) {
|
||||||
|
if (in_flight.size >= 15) {
|
||||||
|
await Promise.race(in_flight);
|
||||||
|
}
|
||||||
|
if (aborting)
|
||||||
|
break;
|
||||||
|
const p = realUpload(file);
|
||||||
|
in_flight.add(p);
|
||||||
|
const _ = (async () => { await p; in_flight.delete(p); })();
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(in_flight);
|
||||||
|
|
||||||
|
done = true;
|
||||||
|
if (!aborting && !files.some(v => v.overwrite))
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal bind:open={show_modal} dismissable={false} size="xl" title="Uploading">
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 flex justify-between">
|
||||||
|
<span>{filesize(current, {base: 2, standard: 'jedec'})} / {filesize(total, {base: 2, standard: 'jedec'})}</span>
|
||||||
|
<span>{progress.toFixed(2)}%</span>
|
||||||
|
</div>
|
||||||
|
<Progressbar class="mt-0" size="h-4" bind:progress={progress} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>File</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each not_done_files as file (file.id)}
|
||||||
|
<tr>
|
||||||
|
<td>{file.full_name}</td>
|
||||||
|
<td>
|
||||||
|
{#if file.waiting}
|
||||||
|
Waiting
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-nowrap flex-row">
|
||||||
|
<span class="mr-4">{filesize(file.current, {base: 2, standard: 'jedec'})} / {filesize(file.total, {base: 2, standard: 'jedec'})}</span>
|
||||||
|
<Progressbar class="flex-1" size="h-4" bind:progress={file.progress} labelInside />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{#each done_files as file (file.id)}
|
||||||
|
<tr>
|
||||||
|
<td>{file.full_name}</td>
|
||||||
|
<td>Done {#if file.overwrite}(Overwrote old file){/if}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<span slot="footer">
|
||||||
|
{#if done}
|
||||||
|
<Button class="w-full" on:click={close}>Close</Button>
|
||||||
|
{:else if !aborting}
|
||||||
|
<Button class="w-full" color="red" on:click={() => (aborting = true)}>Abort</Button>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</Modal>
|
15
frontend/src/icons.ts
Normal file
15
frontend/src/icons.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export {default as FileStorage} from '~icons/carbon/FileStorage';
|
||||||
|
export {default as Folder} from '~icons/carbon/Folder';
|
||||||
|
export {default as FolderAdd} from '~icons/carbon/FolderAdd';
|
||||||
|
export {default as FolderParent} from '~icons/carbon/FolderParent';
|
||||||
|
export {default as DocumentBlank} from '~icons/carbon/DocumentBlank';
|
||||||
|
export {default as Download} from '~icons/carbon/Download';
|
||||||
|
export {default as Email} from '~icons/carbon/Email';
|
||||||
|
export {default as EmailNew} from '~icons/carbon/EmailNew';
|
||||||
|
export {default as Password} from '~icons/carbon/Password';
|
||||||
|
export {default as CloudUpload} from '~icons/carbon/CloudUpload';
|
||||||
|
export {default as Checkmark} from '~icons/carbon/Checkmark';
|
||||||
|
export {default as Error} from '~icons/carbon/Error';
|
||||||
|
|
||||||
|
export {default as CaretLeft} from '~icons/ph/CaretLeft';
|
||||||
|
export {default as OTP} from '~icons/ph/Password';
|
14
frontend/src/main.ts
Normal file
14
frontend/src/main.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import "./app.pcss";
|
||||||
|
import App from "./App.svelte";
|
||||||
|
import {token} from './store';
|
||||||
|
import {replace} from 'svelte-spa-router';
|
||||||
|
|
||||||
|
token.subscribe(v => {
|
||||||
|
if (v == null) replace('/login').then()
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = new App({
|
||||||
|
target: document.getElementById("app") as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
86
frontend/src/pages/Admin.svelte
Normal file
86
frontend/src/pages/Admin.svelte
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {api, rpc, session, token, workingWrapperO, workingWrapperR} from '../store';
|
||||||
|
import {Checkbox, Table, TableBody, TableBodyCell, TableBodyRow, TableHead, TableHeadCell} from 'flowbite-svelte';
|
||||||
|
import {Checkmark, Error} from '../icons';
|
||||||
|
import LinkButton from '../components/LinkButton.svelte';
|
||||||
|
import {replace} from 'svelte-spa-router';
|
||||||
|
|
||||||
|
let users: api.UserInfo[] = [];
|
||||||
|
|
||||||
|
async function fetchUsers() {
|
||||||
|
const resp = await workingWrapperR<api.UserInfo[]>(() => rpc.Admin_list_users($token ?? ''));
|
||||||
|
if (resp != null)
|
||||||
|
users = resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeEnabled(user: number, target: boolean) {
|
||||||
|
await workingWrapperO(() => rpc.Admin_set_enabled($token ?? '', user, target));
|
||||||
|
await fetchUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeAdmin(user: number, target: boolean) {
|
||||||
|
await workingWrapperO(() => rpc.Admin_set_admin($token ?? '', user, target));
|
||||||
|
await fetchUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sudo(user: number) {
|
||||||
|
if (await workingWrapperO(() => rpc.Admin_sudo($token ?? '', user))) {
|
||||||
|
await session.update($token);
|
||||||
|
await replace('/view/0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout(user: number) {
|
||||||
|
await workingWrapperO(() => rpc.Admin_logout($token ?? '', user));
|
||||||
|
await fetchUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTfa(user: number) {
|
||||||
|
await workingWrapperO(() => rpc.Admin_disable_tfa($token ?? '', user));
|
||||||
|
await fetchUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(user: number) {
|
||||||
|
await workingWrapperO(() => rpc.Admin_delete_user($token ?? '', user));
|
||||||
|
await fetchUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shutdown() {
|
||||||
|
if (confirm('Do you really want to shutdown the server?')) {
|
||||||
|
await rpc.Admin_shutdown($token ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchUsers();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Table hoverable divClass="w-full max-w-4xl relative">
|
||||||
|
<TableHead>
|
||||||
|
<TableHeadCell>Name</TableHeadCell>
|
||||||
|
<TableHeadCell>Tfa</TableHeadCell>
|
||||||
|
<TableHeadCell>Enabled</TableHeadCell>
|
||||||
|
<TableHeadCell>Admin</TableHeadCell>
|
||||||
|
<TableHeadCell class="sr-only">Actions</TableHeadCell>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{#each users as user (user.id)}
|
||||||
|
<TableBodyRow>
|
||||||
|
<TableBodyCell>{user.name}</TableBodyCell>
|
||||||
|
<TableBodyCell>{#if user.tfa}<Checkmark/>{:else}<Error/>{/if}</TableBodyCell>
|
||||||
|
<TableBodyCell>
|
||||||
|
<Checkbox checked={user.enabled} on:change={changeEnabled.bind(null, user.id, !user.enabled)}></Checkbox>
|
||||||
|
</TableBodyCell>
|
||||||
|
<TableBodyCell>
|
||||||
|
<Checkbox checked={user.admin} on:change={changeAdmin.bind(null, user.id, !user.admin)}></Checkbox>
|
||||||
|
</TableBodyCell>
|
||||||
|
<TableBodyCell class="flex">
|
||||||
|
<LinkButton class="flex-auto" on:click={sudo.bind(null, user.id)}>Sudo</LinkButton>
|
||||||
|
<LinkButton class="flex-auto" on:click={logout.bind(null, user.id)}>Logout</LinkButton>
|
||||||
|
{#if user.tfa}<LinkButton class="flex-auto" color="amber" on:click={removeTfa.bind(null, user.id)}>Remove tfa</LinkButton>{/if}
|
||||||
|
<LinkButton class="flex-auto" color="red" on:click={deleteUser.bind(null, user.id)}>Delete</LinkButton>
|
||||||
|
</TableBodyCell>
|
||||||
|
</TableBodyRow>
|
||||||
|
{/each}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<LinkButton class="w-full max-w-4xl relative mt-8" color="red" on:click={shutdown}>Shutdown</LinkButton>
|
50
frontend/src/pages/Login.svelte
Normal file
50
frontend/src/pages/Login.svelte
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {Button, ButtonGroup, Card, Input, InputAddon} from 'flowbite-svelte';
|
||||||
|
import {Email, OTP, Password} from '../icons';
|
||||||
|
import {rpc, token, workingWrapperR, api} from '../store';
|
||||||
|
import {replace} from 'svelte-spa-router';
|
||||||
|
|
||||||
|
let ask_tfa = false;
|
||||||
|
let username = '', password = '', tfa = '';
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
const resp = await workingWrapperR<api.LoginResponse>(() => rpc.Auth_login(username, password, ask_tfa ? tfa : null));
|
||||||
|
if (!resp) return;
|
||||||
|
if (resp.otp_needed) {
|
||||||
|
ask_tfa = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
token.set(resp.token);
|
||||||
|
await replace('/view/0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyUp(e: KeyboardEvent) {
|
||||||
|
if (e.key == 'Enter') login();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card class="w-full max-w-lg">
|
||||||
|
{#if ask_tfa}
|
||||||
|
<h3>Two factor authentication</h3>
|
||||||
|
<ButtonGroup class="w-full mb-4">
|
||||||
|
<InputAddon><OTP /></InputAddon>
|
||||||
|
<Input type="text" placeholder="Code" bind:value={tfa} on:keyup={keyUp}></Input>
|
||||||
|
</ButtonGroup>
|
||||||
|
<Button class="w-full" color="primary" on:click={login}>Login</Button>
|
||||||
|
{:else}
|
||||||
|
<h3 class="mb-6">Sign in</h3>
|
||||||
|
<ButtonGroup class="w-full mb-2">
|
||||||
|
<InputAddon><Email /></InputAddon>
|
||||||
|
<Input type="email" placeholder="Email" bind:value={username} on:keyup={keyUp}></Input>
|
||||||
|
</ButtonGroup>
|
||||||
|
<ButtonGroup class="w-full mb-4">
|
||||||
|
<InputAddon><Password /></InputAddon>
|
||||||
|
<Input type="password" placeholder="Password" bind:value={password} on:keyup={keyUp}></Input>
|
||||||
|
</ButtonGroup>
|
||||||
|
<ButtonGroup class="w-full flex flex-nowrap">
|
||||||
|
<Button class="flex-1 flex-grow" color="primary" outline href="#/signup">Signup</Button>
|
||||||
|
<Button class="flex-1 flex-grow" color="primary" on:click={login}>Login</Button>
|
||||||
|
<Button class="flex-1 flex-grow" color="primary" outline href="#/reset_pw">Forget password</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
82
frontend/src/pages/Profile.svelte
Normal file
82
frontend/src/pages/Profile.svelte
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {error_banner, rpc, session, token, workingWrapperO} from '../store';
|
||||||
|
import {Accordion, AccordionItem, Button, ButtonGroup, Input, InputAddon} from 'flowbite-svelte';
|
||||||
|
import {Password} from '../icons';
|
||||||
|
import {info_banner} from '../store.js';
|
||||||
|
|
||||||
|
const s = session.s;
|
||||||
|
const tfa_enabled: boolean = $s?.tfa_enabled ?? false;
|
||||||
|
const change_pw_data = {o: '', n: '', n2: ''}
|
||||||
|
|
||||||
|
async function changePw() {
|
||||||
|
const { o: old, n: password, n2: password2 } = change_pw_data;
|
||||||
|
if (password != password2) {
|
||||||
|
error_banner.set('Password doesn\'t match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await workingWrapperO(() => rpc.Auth_change_password($token ?? '', old, password));
|
||||||
|
if (resp) {
|
||||||
|
info_banner.set('Changed password');
|
||||||
|
change_pw_data.o = '';
|
||||||
|
change_pw_data.n = '';
|
||||||
|
change_pw_data.n2 = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableTfa() {
|
||||||
|
await workingWrapperO(() => rpc.Auth_tfa_disable($token ?? ''));
|
||||||
|
token.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logoutAll() {
|
||||||
|
await workingWrapperO(() => rpc.Auth_logout_all($token ?? ''));
|
||||||
|
token.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAccount() {
|
||||||
|
if (confirm("Do your really want to delete your account?")) {
|
||||||
|
await workingWrapperO(() => rpc.Auth_delete_user($token ?? ''));
|
||||||
|
token.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyUp(e: KeyboardEvent) {
|
||||||
|
if (e.key == 'Enter') changePw();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<Accordion class="w-full max-w-lg text-">
|
||||||
|
<AccordionItem>
|
||||||
|
<span slot="header">Change password</span>
|
||||||
|
<ButtonGroup class="w-full mb-4">
|
||||||
|
<InputAddon><Password /></InputAddon>
|
||||||
|
<Input type="password" placeholder="Old password" bind:value={change_pw_data.o} on:keyup={keyUp}></Input>
|
||||||
|
</ButtonGroup>
|
||||||
|
<ButtonGroup class="w-full mb-2">
|
||||||
|
<InputAddon><Password /></InputAddon>
|
||||||
|
<Input type="password" placeholder="New password" bind:value={change_pw_data.n} on:keyup={keyUp}></Input>
|
||||||
|
</ButtonGroup>
|
||||||
|
<ButtonGroup class="w-full mb-4">
|
||||||
|
<InputAddon><Password /></InputAddon>
|
||||||
|
<Input type="password" placeholder="Repeat new password" bind:value={change_pw_data.n2} on:keyup={keyUp}></Input>
|
||||||
|
</ButtonGroup>
|
||||||
|
<Button class="w-full" color="primary" on:click={changePw}>Change password</Button>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem>
|
||||||
|
<span slot="header">Two factor authentication: <span class={tfa_enabled ? 'text-green-600' : 'text-red-600'}>{tfa_enabled ? 'enabled': 'disabled'}</span></span>
|
||||||
|
{#if tfa_enabled}
|
||||||
|
<Button class="w-full" color="red" on:click={disableTfa}>Disable</Button>
|
||||||
|
{:else}
|
||||||
|
<Button class="w-full" color="green" href="#/tfa">Enable</Button>
|
||||||
|
{/if}
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem>
|
||||||
|
<span slot="header">Account actions</span>
|
||||||
|
<ButtonGroup class="w-full">
|
||||||
|
<Button class="w-full" color="red" on:click={logoutAll}>Logout everywhere</Button>
|
||||||
|
<Button class="w-full" color="red" on:click={deleteAccount}>Delete account</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
70
frontend/src/pages/ResetPassword.svelte
Normal file
70
frontend/src/pages/ResetPassword.svelte
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {Button, ButtonGroup, Card, Input, InputAddon} from 'flowbite-svelte';
|
||||||
|
import {Email, EmailNew, Password} from '../icons';
|
||||||
|
import {error_banner, info_banner, rpc, workingWrapper, workingWrapperO} from '../store';
|
||||||
|
import {replace} from 'svelte-spa-router';
|
||||||
|
|
||||||
|
let enter_key = false;
|
||||||
|
let username = '', key = '', password = '', password2 = '';
|
||||||
|
|
||||||
|
async function sendKey() {
|
||||||
|
await workingWrapper(() => rpc.Auth_send_recovery_key(username));
|
||||||
|
info_banner.set('A message has been sent');
|
||||||
|
enter_key = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePw() {
|
||||||
|
if (password != password2) {
|
||||||
|
error_banner.set('Password doesn\'t match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await workingWrapperO(() => rpc.Auth_reset_password(key, password)))
|
||||||
|
await replace('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyUp(e: KeyboardEvent) {
|
||||||
|
if (e.key == 'Enter') {
|
||||||
|
if (enter_key)
|
||||||
|
changePw();
|
||||||
|
else
|
||||||
|
sendKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card class="w-full max-w-lg">
|
||||||
|
<h3 class="mb-6">Reset password</h3>
|
||||||
|
{#if enter_key}
|
||||||
|
<ButtonGroup class="w-full mb-4">
|
||||||
|
<InputAddon><EmailNew /></InputAddon>
|
||||||
|
<Input type="text" placeholder="Recovery key" bind:value={key} on:keyup={keyUp}></Input>
|
||||||
|
</ButtonGroup>
|
||||||
|
<ButtonGroup class="w-full mb-2">
|
||||||
|
<InputAddon><Password /></InputAddon>
|
||||||
|
<Input type="password" placeholder="Password" bind:value={password} on:keyup={keyUp}></Input>
|
||||||
|
</ButtonGroup>
|
||||||
|
<ButtonGroup class="w-full mb-4">
|
||||||
|
<InputAddon><Password /></InputAddon>
|
||||||
|
<Input type="password" placeholder="Repeat password" bind:value={password2} on:keyup={keyUp}></Input>
|
||||||
|
</ButtonGroup>
|
||||||
|
<ButtonGroup class="w-full flex flex-nowrap">
|
||||||
|
<Button class="flex-1 flex-grow" color="primary" outline href="#/login">Login</Button>
|
||||||
|
<Button class="flex-1 flex-grow" color="primary" on:click={changePw}>Change password</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
{:else}
|
||||||
|
<ButtonGroup class="w-full mb-4">
|
||||||
|
<InputAddon><Email /></InputAddon>
|
||||||
|
<Input type="email" placeholder="Email" bind:value={username} on:keyup={keyUp}></Input>
|
||||||
|
</ButtonGroup>
|
||||||
|
<ButtonGroup class="w-full flex flex-nowrap">
|
||||||
|
<Button class="flex-1 flex-grow" color="primary" outline href="#/login">Login</Button>
|
||||||
|
<Button class="flex-1 flex-grow" color="primary" on:click={sendKey}>Send recovery key</Button>
|
||||||
|
<Button class="flex-1 flex-grow" color="primary" outline on:click={() => (enter_key = true)}>Enter key</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
|
||||||
|
</style>
|
55
frontend/src/pages/Signup.svelte
Normal file
55
frontend/src/pages/Signup.svelte
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {Button, ButtonGroup, Card, Input, InputAddon} from 'flowbite-svelte';
|
||||||
|
import {Email, Password} from '../icons';
|
||||||
|
import {error_banner, info_banner, rpc, workingWrapperO} from '../store';
|
||||||
|
import {replace} from 'svelte-spa-router';
|
||||||
|
|
||||||
|
let username = '', username2 = '', password = '', password2 = '';
|
||||||
|
|
||||||
|
async function signup() {
|
||||||
|
error_banner.set('');
|
||||||
|
if (username != username2) {
|
||||||
|
error_banner.set('Email doesn\'t match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password != password2) {
|
||||||
|
error_banner.set('Password doesn\'t match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await workingWrapperO(() => rpc.Auth_signup(username, password));
|
||||||
|
|
||||||
|
if (resp) {
|
||||||
|
info_banner.set('Account created, please wait till an administrator approves it');
|
||||||
|
await replace('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyUp(e: KeyboardEvent) {
|
||||||
|
if (e.key == 'Enter') signup();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card class="w-full max-w-lg">
|
||||||
|
<h3 class="mb-6">Sign in</h3>
|
||||||
|
<ButtonGroup class="w-full mb-2">
|
||||||
|
<InputAddon><Email /></InputAddon>
|
||||||
|
<Input type="email" placeholder="Email" bind:value={username} on:keyup={keyUp}></Input>
|
||||||
|
</ButtonGroup>
|
||||||
|
<ButtonGroup class="w-full mb-4">
|
||||||
|
<InputAddon><Email /></InputAddon>
|
||||||
|
<Input type="email" placeholder="Repeat email" bind:value={username2} on:keyup={keyUp}></Input>
|
||||||
|
</ButtonGroup>
|
||||||
|
<ButtonGroup class="w-full mb-2">
|
||||||
|
<InputAddon><Password /></InputAddon>
|
||||||
|
<Input type="password" placeholder="Password" bind:value={password} on:keyup={keyUp}></Input>
|
||||||
|
</ButtonGroup>
|
||||||
|
<ButtonGroup class="w-full mb-4">
|
||||||
|
<InputAddon><Password /></InputAddon>
|
||||||
|
<Input type="password" placeholder="Repeat password" bind:value={password2} on:keyup={keyUp}></Input>
|
||||||
|
</ButtonGroup>
|
||||||
|
<ButtonGroup class="w-full flex flex-nowrap">
|
||||||
|
<Button class="flex-1 flex-grow" color="primary" outline href="#/login">Login</Button>
|
||||||
|
<Button class="flex-1 flex-grow" color="primary" on:click={signup}>Singup</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</Card>
|
74
frontend/src/pages/TfaSetup.svelte
Normal file
74
frontend/src/pages/TfaSetup.svelte
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {Button, ButtonGroup, Card, Input, InputAddon, StepIndicator, Tooltip} from 'flowbite-svelte';
|
||||||
|
import {OTP} from '../icons';
|
||||||
|
import {info_banner, rpc, session, token, workingWrapperO, workingWrapperR} from '../store';
|
||||||
|
import QRCode from 'qrcode-svg';
|
||||||
|
import {replace} from 'svelte-spa-router';
|
||||||
|
|
||||||
|
const s = session.s;
|
||||||
|
|
||||||
|
let step = 1;
|
||||||
|
let code = '', secret_qr_code = '';
|
||||||
|
let secret: string | null = null;
|
||||||
|
let show_manual = false;
|
||||||
|
|
||||||
|
async function startSetup(mail: boolean) {
|
||||||
|
if (mail) {
|
||||||
|
const resp = await workingWrapperO(() => rpc.Auth_tfa_setup_mail($token ?? ''));
|
||||||
|
if (resp) {
|
||||||
|
secret = null;
|
||||||
|
step = 2;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const resp = await workingWrapperR<string>(() => rpc.Auth_tfa_setup_totp($token ?? ''));
|
||||||
|
if (resp != null) {
|
||||||
|
secret = resp.replaceAll('=', '');
|
||||||
|
secret_qr_code = new QRCode({
|
||||||
|
content: `otpauth://totp/MFileserver:${$s?.name ?? ''}?secret=${secret}&issuer=MFileserver`,
|
||||||
|
container: 'none',
|
||||||
|
padding: 0
|
||||||
|
}).svg();
|
||||||
|
step = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeSetup() {
|
||||||
|
if (await workingWrapperO(() => rpc.Auth_tfa_complete($token ?? '', code))) {
|
||||||
|
info_banner.set("Successfully set up two factor authentication");
|
||||||
|
token.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyUp(e: KeyboardEvent) {
|
||||||
|
if (e.key == 'Enter') completeSetup();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card class="w-full max-w-lg">
|
||||||
|
<StepIndicator steps={["Select type", "Finish setup"]} currentStep={step} size="h-2" class="mb-8"/>
|
||||||
|
{#if step === 1}
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button class="flex-1 flex-grow" color="primary" outline on:click={startSetup.bind(null, true)}>Mail</Button>
|
||||||
|
<Tooltip>Receive a code via email when you want to log in</Tooltip>
|
||||||
|
<Button class="flex-1 flex-grow" color="primary" outline on:click={startSetup.bind(null, false)}>Authenticator</Button>
|
||||||
|
<Tooltip>Use a program like "Google Authenticator" to get a code when you want to log in</Tooltip>
|
||||||
|
</ButtonGroup>
|
||||||
|
{:else if step === 2}
|
||||||
|
{#if secret == null}
|
||||||
|
<p>A code has been sent to your mailbox</p>
|
||||||
|
{:else}
|
||||||
|
<svg class="w-full mb-4" viewBox="0 0 256 256" height="100%">{@html secret_qr_code}</svg>
|
||||||
|
{#if show_manual}
|
||||||
|
<pre class="w-full mb-4 bg-gray-100">{secret}</pre>
|
||||||
|
{:else}
|
||||||
|
<button class="w-full mb-4 bg-gray-200 rounded-lg" on:click={() => (show_manual = true)}>Click for manual input code</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
<ButtonGroup class="w-full mb-4">
|
||||||
|
<InputAddon><OTP /></InputAddon>
|
||||||
|
<Input type="text" placeholder="Code" bind:value={code} on:keyup={keyUp}></Input>
|
||||||
|
</ButtonGroup>
|
||||||
|
<Button class="w-full" on:click={completeSetup}>Complete setup</Button>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
162
frontend/src/pages/View.svelte
Normal file
162
frontend/src/pages/View.svelte
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {Breadcrumb, Dropzone, Modal, Progressbar} from 'flowbite-svelte';
|
||||||
|
import {writable} from 'svelte/store';
|
||||||
|
import {CloudUpload} from '../icons';
|
||||||
|
import {api, rpc, token, type UploadFile, workingWrapperR} from '../store';
|
||||||
|
import DirViewer from '../components/DirViewer.svelte';
|
||||||
|
import UploadModal from '../components/UploadModal.svelte';
|
||||||
|
import FileViewer from '../components/FileViewer.svelte';
|
||||||
|
import A from '../components/A.svelte';
|
||||||
|
|
||||||
|
interface Data {
|
||||||
|
node: api.Node|null,
|
||||||
|
segments: api.PathSegment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export let params: {id?: string}|undefined = {};
|
||||||
|
$: {
|
||||||
|
let id = 0;
|
||||||
|
if (params && params.id) {
|
||||||
|
id = parseInt(params.id);
|
||||||
|
if (id >= 0)
|
||||||
|
updateData(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = writable<Data>({node: null, segments: []});
|
||||||
|
async function updateData(id: number) {
|
||||||
|
let node = await workingWrapperR<api.Node>(() => rpc.FS_get_node($token ?? '', id));
|
||||||
|
if (!node)
|
||||||
|
return;
|
||||||
|
let segments = await workingWrapperR<api.PathSegment[]>(() => rpc.FS_get_path($token ?? '', id));
|
||||||
|
if (!segments)
|
||||||
|
return;
|
||||||
|
data.set({node: node as Data['node'], segments });
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFile = async (entry: FileSystemEntry) => new Promise<File>((o, e) => (entry as FileSystemFileEntry).file(o, e));
|
||||||
|
|
||||||
|
async function handleEntry(parent: number, parent_name: string, entry: FileSystemEntry): Promise<UploadFile[]> {
|
||||||
|
if (entry.isFile) {
|
||||||
|
try {
|
||||||
|
return [{
|
||||||
|
id: parent,
|
||||||
|
name: entry.name,
|
||||||
|
full_name: parent_name + entry.name,
|
||||||
|
file: await getFile(entry),
|
||||||
|
overwrite: false
|
||||||
|
}];
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const resp = await workingWrapperR<api.CreateNodeInfo>(() => rpc.FS_create_node($token ?? '', false, parent, entry.name));
|
||||||
|
if (!resp) return [];
|
||||||
|
if (resp.file) return [];
|
||||||
|
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||||
|
const files: UploadFile[] = [];
|
||||||
|
const name = parent_name + entry.name + '/';
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const entries: FileSystemEntry[] = await new Promise((o, e) => reader.readEntries(o, e));
|
||||||
|
if (entries.length == 0) break;
|
||||||
|
for (const e of entries)
|
||||||
|
files.push(...await handleEntry(resp.id, name, e));
|
||||||
|
} catch (e) { break; }
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onChange(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
if (!input.files)
|
||||||
|
return
|
||||||
|
console.log(input.files);
|
||||||
|
const files: UploadFile[] = [];
|
||||||
|
for (const f of input.files)
|
||||||
|
files.push({
|
||||||
|
id: $data.node?.id ?? 0,
|
||||||
|
name: f.name,
|
||||||
|
full_name: f.name,
|
||||||
|
file: f,
|
||||||
|
overwrite: false
|
||||||
|
});
|
||||||
|
await upload(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!e.dataTransfer)
|
||||||
|
return;
|
||||||
|
const files: UploadFile[] = [];
|
||||||
|
for (const i of e.dataTransfer.items) {
|
||||||
|
const entry = i.webkitGetAsEntry();
|
||||||
|
if (!entry)
|
||||||
|
console.error("Failed to get entry for: ", i);
|
||||||
|
else
|
||||||
|
files.push(...await handleEntry($data.node?.id ?? 0, '', entry));
|
||||||
|
}
|
||||||
|
await upload(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const upload_progress_data = { total: 0, current: 0 }
|
||||||
|
let upload_progress: number;
|
||||||
|
$: upload_progress = upload_progress_data.total == 0 ? 0 : (upload_progress_data.current / upload_progress_data.total * 100);
|
||||||
|
let real_upload: (files: UploadFile[]) => Promise<void>;
|
||||||
|
async function upload(files: UploadFile[]) {
|
||||||
|
upload_progress_data.total = files.length;
|
||||||
|
upload_progress_data.current = 0;
|
||||||
|
const upload_files: UploadFile[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const resp = await workingWrapperR<api.CreateNodeInfo>(() => rpc.FS_create_node($token ?? '', true, file.id, file.name));
|
||||||
|
if (resp && resp.file)
|
||||||
|
upload_files.push({ ...file, id: resp.id, overwrite: resp.exists });
|
||||||
|
upload_progress_data.current++;
|
||||||
|
}
|
||||||
|
await real_upload(upload_files);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="w-full max-w-4xl">
|
||||||
|
<div class="w-full flex mb-2 h-16">
|
||||||
|
<Breadcrumb>
|
||||||
|
{#each $data.segments as segment, i}
|
||||||
|
{#if i > 0}<li class="inline-flex items-center">/</li>{/if}
|
||||||
|
<li class="inline-flex items-center">
|
||||||
|
{#if segment.id !== null}
|
||||||
|
<A href={'#/view/' + segment.id}>{segment.id === 0 ? 'Files' : segment.name}</A>
|
||||||
|
{:else}
|
||||||
|
<span style="padding: 0 0.25em;">{segment.name}</span>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</Breadcrumb>
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
{#if $data.node?.file === false}
|
||||||
|
<Dropzone class="h-full w-64 cursor-pointer" on:drop={onDrop} on:dragover={e => e.preventDefault()} on:change={onChange} multiple>
|
||||||
|
<span class="flex flex-row">
|
||||||
|
<CloudUpload class="fill-gray-500 h-12 cursor-pointer" width="40%"/>
|
||||||
|
<span class="inline text-gray-600 w-48 cursor-pointer">Click here or drag<br>to upload files</span>
|
||||||
|
</span>
|
||||||
|
</Dropzone>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if $data.node === null}
|
||||||
|
<!-- Waiting for data -->
|
||||||
|
{:else if $data.node.file}
|
||||||
|
<FileViewer node={$data.node} />
|
||||||
|
{:else}
|
||||||
|
<DirViewer node={$data.node} on:reload_node={() => updateData($data.node?.id ?? 0)} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<UploadModal bind:upload={real_upload} on:reload_node={() => updateData($data.node?.id ?? 0)} />
|
||||||
|
{#if upload_progress_data.current !== upload_progress_data.total}
|
||||||
|
<Modal open dismissable={false} title="Creating files">
|
||||||
|
<div class="mb-1 flex justify-between">
|
||||||
|
<span>{upload_progress_data.current} / {upload_progress_data.total}</span>
|
||||||
|
<span>{upload_progress.toFixed(2)}%</span>
|
||||||
|
</div>
|
||||||
|
<Progressbar class="!mt-0" size="h-4" bind:progress={upload_progress} />
|
||||||
|
</Modal>
|
||||||
|
{/if}
|
17
frontend/src/routes.ts
Normal file
17
frontend/src/routes.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import Login from './pages/Login.svelte';
|
||||||
|
import Signup from './pages/Signup.svelte';
|
||||||
|
import ResetPassword from './pages/ResetPassword.svelte';
|
||||||
|
import Profile from './pages/Profile.svelte';
|
||||||
|
import TfaSetup from './pages/TfaSetup.svelte';
|
||||||
|
import Admin from './pages/Admin.svelte';
|
||||||
|
import View from './pages/View.svelte';
|
||||||
|
|
||||||
|
export const routes = {
|
||||||
|
'/login': Login,
|
||||||
|
'/signup': Signup,
|
||||||
|
'/reset_pw': ResetPassword,
|
||||||
|
'/profile': Profile,
|
||||||
|
'/tfa': TfaSetup,
|
||||||
|
'/admin': Admin,
|
||||||
|
'/view/:id': View
|
||||||
|
}
|
97
frontend/src/store.ts
Normal file
97
frontend/src/store.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import {MRPCConnector, type Session, type Response} from './api';
|
||||||
|
import {type Writable, writable} from 'svelte/store';
|
||||||
|
import {filesize} from 'filesize';
|
||||||
|
|
||||||
|
export * as api from './api';
|
||||||
|
|
||||||
|
export interface UploadFile {
|
||||||
|
id: number,
|
||||||
|
name: string,
|
||||||
|
full_name: string,
|
||||||
|
file: File,
|
||||||
|
overwrite: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const show_working = writable<boolean>(false);
|
||||||
|
export const info_banner = writable<string>('');
|
||||||
|
export const error_banner = writable<string>('');
|
||||||
|
|
||||||
|
export const rpc = new MRPCConnector('/mrpc');
|
||||||
|
|
||||||
|
export const token = writable<string|null>(localStorage.getItem('token'));
|
||||||
|
export const session: { s: Writable<Session|null>, update: (token: string|null) => Promise<void> } = {
|
||||||
|
s: writable(null),
|
||||||
|
update: async (t: string|null) => {
|
||||||
|
if (t == null) {
|
||||||
|
session.s.set(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const s = await rpc.Auth_session_info(t)
|
||||||
|
if (s.e)
|
||||||
|
token.set(null);
|
||||||
|
else
|
||||||
|
session.s.set(s.o);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
token.subscribe((t) => session.update(t));
|
||||||
|
|
||||||
|
token.subscribe(v => {
|
||||||
|
if (v == null)
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
else
|
||||||
|
localStorage.setItem('token', v);
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function workingWrapper<T>(fn: () => Promise<T>): Promise<T|null> {
|
||||||
|
let r = null;
|
||||||
|
error_banner.set('');
|
||||||
|
show_working.set(true);
|
||||||
|
try {
|
||||||
|
r = await fn();
|
||||||
|
} catch (e) {
|
||||||
|
error_banner.set(`Error while making request: ${e}`);
|
||||||
|
}
|
||||||
|
show_working.set(false);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function workingWrapperO(fn: () => Promise<string|null>): Promise<boolean> {
|
||||||
|
const resp = await workingWrapper(fn);
|
||||||
|
if (resp)
|
||||||
|
error_banner.set(resp);
|
||||||
|
if (resp === 'Unauthorized')
|
||||||
|
token.set(null);
|
||||||
|
return resp == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function workingWrapperR<T>(fn: () => Promise<Response<T>>): Promise<T|null> {
|
||||||
|
const resp = await workingWrapper(fn);
|
||||||
|
if (!resp)
|
||||||
|
return null;
|
||||||
|
if (resp.e === 'Unauthorized')
|
||||||
|
token.set(null);
|
||||||
|
else if (resp.e != null)
|
||||||
|
error_banner.set(resp.e);
|
||||||
|
return resp.o;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function download<T extends {id:number, file:boolean}>(token: string, nodes: T[]) {
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.target = '_blank';
|
||||||
|
form.innerHTML = `<input type="hidden" name="token" value="${token}">`;
|
||||||
|
if (nodes.length == 1 && nodes[0].file) {
|
||||||
|
form.action = '/download';
|
||||||
|
form.innerHTML += `<input type="hidden" name="node" value="${nodes[0].id}}">`;
|
||||||
|
} else {
|
||||||
|
form.action = '/download_multi';
|
||||||
|
form.innerHTML += `<input type="hidden" name="nodes" value="${nodes.map(n => n.id).join('.')}">`;
|
||||||
|
const resp = await workingWrapperR<number>(() => rpc.FS_get_nodes_size(token, nodes.map(v => v.id)));
|
||||||
|
if (!resp)
|
||||||
|
return;
|
||||||
|
info_banner.set(`Estimated size: ${filesize(resp, {base: 2, standard: 'jedec'})}`);
|
||||||
|
}
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
document.body.removeChild(form);
|
||||||
|
}
|
3
frontend/src/vite-env.d.ts
vendored
Normal file
3
frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
/// <reference types="svelte" />
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="unplugin-icons/types/svelte" />
|
7
frontend/svelte.config.js
Normal file
7
frontend/svelte.config.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
|
||||||
|
// for more information about preprocessors
|
||||||
|
preprocess: [vitePreprocess({})],
|
||||||
|
};
|
28
frontend/tailwind.config.cjs
Normal file
28
frontend/tailwind.config.cjs
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
/** @type {import('tailwindcss').Config}*/
|
||||||
|
const config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
content: ["./src/**/*.{html,js,svelte,ts}", './node_modules/flowbite-svelte/**/*.{html,js,svelte,ts}'],
|
||||||
|
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#f7fee7',
|
||||||
|
100: '#ecfccb',
|
||||||
|
200: '#d9f99d',
|
||||||
|
300: '#bef264',
|
||||||
|
400: '#a3e635',
|
||||||
|
500: '#84cc16',
|
||||||
|
600: '#65a30d',
|
||||||
|
700: '#4d7c0f',
|
||||||
|
800: '#3f6212',
|
||||||
|
900: '#365314'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = config;
|
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
/**
|
||||||
|
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||||
|
* Disable checkJs if you'd like to use dynamic types in JS.
|
||||||
|
* Note that setting allowJs false does not prevent the use
|
||||||
|
* of JS in `.svelte` files.
|
||||||
|
*/
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"isolatedModules": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
9
frontend/tsconfig.node.json
Normal file
9
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
61
frontend/vite.config.ts
Normal file
61
frontend/vite.config.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import {defineConfig} from 'vite'
|
||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
import {viteSingleFile} from 'vite-plugin-singlefile';
|
||||||
|
import {createHtmlPlugin} from 'vite-plugin-html';
|
||||||
|
import purgeCss from 'vite-plugin-tailwind-purgecss';
|
||||||
|
import Icons from 'unplugin-icons/vite';
|
||||||
|
import {NormalizedInputOptions, PluginContext} from 'rollup';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as child_process from 'child_process';
|
||||||
|
|
||||||
|
const response_replacement =
|
||||||
|
'interface ResponseE {\n' +
|
||||||
|
' e: string,\n' +
|
||||||
|
' o: null\n' +
|
||||||
|
'}\n' +
|
||||||
|
'interface ResponseO<T> {\n' +
|
||||||
|
' e: null,\n' +
|
||||||
|
' o: T\n' +
|
||||||
|
'}\n' +
|
||||||
|
'export type Response<T> = ResponseE | ResponseO<T>;'
|
||||||
|
|
||||||
|
function checkMrpc(this: PluginContext, _options: NormalizedInputOptions) {
|
||||||
|
const src_ts = fs.statSync('../fileserver.rs').mtimeMs;
|
||||||
|
const update = !fs.existsSync('src/api.ts') || fs.statSync('src/api.ts').mtimeMs <= src_ts;
|
||||||
|
if (!update)
|
||||||
|
return;
|
||||||
|
child_process.spawnSync(
|
||||||
|
'../mrpc',
|
||||||
|
['-n', 'src/api', '-c', 'ts', '../fileserver.rs'],
|
||||||
|
{ stdio: 'inherit' }
|
||||||
|
);
|
||||||
|
let api_content = fs.readFileSync('src/api.ts', 'utf8');
|
||||||
|
api_content = api_content.replace(
|
||||||
|
'interface Response<T> {\n e: (string|null);\n o: (T|null);\n}',
|
||||||
|
response_replacement
|
||||||
|
);
|
||||||
|
fs.writeFileSync('src/api.ts', api_content, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
{ name: 'mrpc', buildStart: checkMrpc },
|
||||||
|
svelte(),
|
||||||
|
Icons({ compiler: 'svelte' }),
|
||||||
|
purgeCss(),
|
||||||
|
viteSingleFile({removeViteModuleLoader: true}),
|
||||||
|
createHtmlPlugin({minify: true})
|
||||||
|
],
|
||||||
|
build: {
|
||||||
|
minify: false
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
proxy: {
|
||||||
|
'/mrpc': 'http://127.0.0.1:2121',
|
||||||
|
'/download': 'http://127.0.0.1:2121',
|
||||||
|
'/download_multi': 'http://127.0.0.1:2121',
|
||||||
|
'/upload': 'http://127.0.0.1:2121'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
249
gradlew
vendored
Executable file
249
gradlew
vendored
Executable file
@ -0,0 +1,249 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
92
gradlew.bat
vendored
Normal file
92
gradlew.bat
vendored
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
1
settings.gradle.kts
Normal file
1
settings.gradle.kts
Normal file
@ -0,0 +1 @@
|
|||||||
|
rootProject.name = "fileserver"
|
11
src/main/java/de/mattv/fileserver/FileServerApplication.java
Normal file
11
src/main/java/de/mattv/fileserver/FileServerApplication.java
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package de.mattv.fileserver;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
public class FileServerApplication {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(FileServerApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
1
src/main/resources/application.properties
Normal file
1
src/main/resources/application.properties
Normal file
@ -0,0 +1 @@
|
|||||||
|
spring.application.name=fileserver
|
Loading…
Reference in New Issue
Block a user