Initial commit
This commit is contained in:
		
							
								
								
									
										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'
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
})
 | 
			
		||||
		Reference in New Issue
	
	Block a user