Implemented share link generation and viewing
All checks were successful
/ Build the server (push) Successful in 2m35s

Closes #60
This commit is contained in:
2025-06-13 19:12:02 +02:00
parent 0306a98936
commit 6c2b73dbd0
47 changed files with 1527 additions and 958 deletions

View File

@@ -12,32 +12,32 @@
"fetch-api": "openapi-typescript http://127.0.0.1:2121/openapi.json -o ./src/api/schema.d.ts"
},
"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",
"@iconify/json": "^2.2.324",
"@sveltejs/vite-plugin-svelte": "^2.5.3",
"@tsconfig/svelte": "^5.0.4",
"@types/node": "^20.17.30",
"@types/qrcode-svg": "^1.1.5",
"autoprefixer": "^10.4.21",
"flowbite": "^1.8.1",
"flowbite-svelte": "^0.44.18",
"openapi-typescript": "^7.3.3",
"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",
"flowbite-svelte": "^0.44.24",
"openapi-typescript": "^7.6.1",
"postcss": "^8.5.3",
"postcss-load-config": "^4.0.2",
"svelte": "^4.2.19",
"svelte-check": "^3.8.6",
"tailwindcss": "^3.4.17",
"tslib": "^2.8.1",
"typescript": "^5.8.3",
"unplugin-icons": "^0.17.4",
"vite": "^4.5.12",
"vite-plugin-html": "^3.2.2",
"vite-plugin-singlefile": "^0.13.5",
"vite-plugin-tailwind-purgecss": "^0.1.3"
"vite-plugin-tailwind-purgecss": "^0.1.4"
},
"dependencies": {
"@microsoft/fetch-event-source": "^2.0.1",
"filesize": "^10.1.0",
"openapi-fetch": "^0.12.0",
"filesize": "^10.1.6",
"openapi-fetch": "^0.12.5",
"qrcode-svg": "^1.1.0",
"svelte-spa-router": "^3.3.0",
"tailwind-merge": "^1.14.0"

1242
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import {error_banner, info_banner, rpc, session, show_working, token, workingWrapper} from './store';
import {error_banner, info_banner, globalRpc, session, show_working, token, workingWrapper} from './store';
import {Banner, Navbar, NavBrand, Spinner} from 'flowbite-svelte';
import Router, {replace} from 'svelte-spa-router';
import Router, {push, replace} from 'svelte-spa-router';
import {routes} from './routes';
import {FileStorage} from './icons';
import LinkButton from './components/LinkButton.svelte';
@@ -10,14 +10,15 @@
const s = session.s;
async function leaveSudo() {
await workingWrapper(() => rpc.admin.unSudo());
await workingWrapper(() => globalRpc.admin.unSudo());
await session.update($token);
await replace('/admin');
}
async function logout() {
await rpc.logout();
await globalRpc.logout();
token.set(null);
await push('/login');
}
</script>
@@ -49,6 +50,10 @@
<A href="#/profile">Profile</A>
<LinkButton on:click={logout}>Logout</LinkButton>
</div>
{:else}
<div class="flex md:order-2 gap-x-2">
<A href="#/login">Login</A>
</div>
{/if}
</Navbar>
<span class="grid justify-items-center mt-10">

View File

@@ -1,17 +1,8 @@
import type {paths, components} from './schema';
import createClient from 'openapi-fetch';
import {fetchEventSource} from '@microsoft/fetch-event-source';
const client = createClient<paths>();
client.use({
onRequest({ schemaPath, request }) {
if (schemaPath.startsWith('/api/public') || rpc.token == '')
return;
request.headers.set('Authorization', `Bearer ${rpc.token}`);
return request;
}
})
import {token} from '../store';
import { replace } from 'svelte-spa-router';
export type Session = components['schemas']['de.mattv.fileserver.Response$Session'];
export type Node = components['schemas']['de.mattv.fileserver.Response$Node'];
@@ -19,64 +10,99 @@ export type PathSegment = components['schemas']['de.mattv.fileserver.Response$Pa
export type CreateNodeInfo = components['schemas']['de.mattv.fileserver.Response$CreateNodeInfo'];
export type UserInfo = components['schemas']['de.mattv.fileserver.Response$UserInfo'];
export const rpc = {
token: '',
const _getRpcClient = () => {
let obj = {
token: '',
client: createClient<paths>(),
share_root: null,
signup: (username: string, password: string) =>
client.POST('/api/public/auth/signup', { body: { username, password } }).then(v => v.data),
login: (username: string, password: string, otp?: string) =>
client.POST('/api/public/auth/login', { body: { username, password, otp } }).then(v => v.data),
signup: (username: string, password: string) =>
obj.client.POST('/api/public/auth/signup', { body: { username, password } }).then(v => v.data),
login: (username: string, password: string, otp?: string) =>
obj.client.POST('/api/public/auth/login', { body: { username, password, otp } }).then(v => v.data),
send_recovery_key: (username: string) =>
client.POST('/api/public/auth/send_recovery_key', { body: username }).then(v => v.data),
reset_password: (key: string, password: string) =>
client.POST('/api/public/auth/reset_password', { body: { key, password } }).then(v => v.data),
send_recovery_key: (username: string) =>
obj.client.POST('/api/public/auth/send_recovery_key', { body: username }).then(v => v.data),
reset_password: (key: string, password: string) =>
obj.client.POST('/api/public/auth/reset_password', { body: { key, password } }).then(v => v.data),
change_password: (oldPassword: string, newPassword: string) =>
client.POST('/api/user/auth/change_password', { body: { oldPassword, newPassword } }).then(v => v.data),
change_password: (oldPassword: string, newPassword: string) =>
obj.client.POST('/api/user/auth/change_password', { body: { oldPassword, newPassword } }).then(v => v.data),
logout: () => client.POST('/api/user/auth/logout').then(v => v.data),
logoutAll: () => client.POST('/api/user/auth/logout_all').then(v => v.data),
deleteAccount: () => client.POST('/api/user/auth/delete').then(v => v.data),
sessionInfo: () => client.POST('/api/user/session').then(v => v.data),
logout: () => obj.client.POST('/api/user/auth/logout').then(v => v.data),
logoutAll: () => obj.client.POST('/api/user/auth/logout_all').then(v => v.data),
deleteAccount: () => obj.client.POST('/api/user/auth/delete').then(v => v.data),
sessionInfo: () => obj.client.POST('/api/user_share/session').then(v => v.data),
tfaSetupMail: () => client.POST('/api/user/tfa/setup_mail').then(v => v.data),
tfaSetupTotp: () => client.POST('/api/user/tfa/setup_totp').then(v => v.data),
tfaComplete: (code: string) => client.POST('/api/user/tfa/complete', { body: code }).then(v => v.data),
tfaDisable: () => client.POST('/api/user/tfa/disable').then(v => v.data),
tfaSetupMail: () => obj.client.POST('/api/user/tfa/setup_mail').then(v => v.data),
tfaSetupTotp: () => obj.client.POST('/api/user/tfa/setup_totp').then(v => v.data),
tfaComplete: (code: string) => obj.client.POST('/api/user/tfa/complete', { body: code }).then(v => v.data),
tfaDisable: () => obj.client.POST('/api/user/tfa/disable').then(v => v.data),
getNode: (node: number) => client.POST('/api/user/fs/node', { body: node }).then(v => v.data),
getPath: (node: number) => client.POST('/api/user/fs/path', { body: node }).then(v => v.data),
getNodesSize: (nodes: number[]) => client.POST('/api/user/fs/size', { body: nodes }).then(v => v.data),
getMime: (node: number) => client.POST('/api/user/fs/mime', { body: node }).then(v => v.data),
downloadPreview: (node: number) => client.POST('/api/user/fs/preview', { body: node }).then(v => v.data),
getNode: (node: number) => obj.client.POST('/api/user_share/fs/node', { body: node }).then(v => v.data),
getPath: (node: number) => obj.client.POST('/api/user_share/fs/path', { body: node }).then(v => v.data),
getNodesSize: (nodes: number[]) => obj.client.POST('/api/user_share/fs/size', { body: nodes }).then(v => v.data),
getMime: (node: number) => obj.client.POST('/api/user_share/fs/mime', { body: node }).then(v => v.data),
downloadPreview: (node: number) => obj.client.POST('/api/user_share/fs/preview', { body: node }).then(v => v.data),
createNode: (name: string, parent: number, file: boolean) =>
client.POST('/api/user/fs/create', { body: { name, parent, file } }).then(v => v.data),
createNode: (name: string, parent: number, file: boolean) =>
obj.client.POST('/api/user/fs/create', { body: { name, parent, file } }).then(v => v.data),
deleteNodes: (nodes: number[], cbk: (v: string|null) => void) => fetchEventSource('/api/user/fs/delete', {
method: 'POST',
body: JSON.stringify(nodes),
headers: {
'Authorization': 'Bearer ' + rpc.token,
'Content-Type': 'application/json'
},
onmessage: v => cbk(v.data),
onerror: e => { throw e; },
onclose: () => cbk(null)
}),
deleteNodes: (nodes: number[], cbk: (v: string|null) => void) => fetchEventSource('/api/user/fs/delete', {
method: 'POST',
body: JSON.stringify(nodes),
headers: {
'Authorization': 'Bearer ' + obj.token,
'Content-Type': 'application/json'
},
onmessage: v => cbk(v.data),
onerror: e => { throw e; },
onclose: () => cbk(null)
}),
admin: {
listUsers: () => client.POST('/api/admin/users').then(v => v.data),
setEnabled: (id: number, state: boolean) => client.POST('/api/admin/user/set_enabled', { body: { id, state } }).then(v => v.data),
setAdmin: (id: number, state: boolean) => client.POST('/api/admin/user/set_admin', { body: { id, state } }).then(v => v.data),
sudo: (id: number) => client.POST('/api/admin/user/sudo', { body: id }).then(v => v.data),
logout: (id: number) => client.POST('/api/admin/user/logout', { body: id }).then(v => v.data),
disableTfa: (id: number) => client.POST('/api/admin/user/disable_tfa', { body: id }).then(v => v.data),
deleteUser: (id: number) => client.POST('/api/admin/user/delete', { body: id }).then(v => v.data),
unSudo: () => client.POST('/api/admin/un_sudo').then(v => v.data),
shutdown: () => client.POST('/api/admin/shutdown').then(v => v.data),
shareNode: (node: number) => obj.client.POST('/api/user/fs/share', { body: node }).then(v => v.data),
unshareNode: (node: number) => obj.client.POST('/api/user/fs/unshare', { body: node }).then(v => v.data),
admin: {
listUsers: () => obj.client.POST('/api/admin/users').then(v => v.data),
setEnabled: (id: number, state: boolean) => obj.client.POST('/api/admin/user/set_enabled', { body: { id, state } }).then(v => v.data),
setAdmin: (id: number, state: boolean) => obj.client.POST('/api/admin/user/set_admin', { body: { id, state } }).then(v => v.data),
sudo: (id: number) => obj.client.POST('/api/admin/user/sudo', { body: id }).then(v => v.data),
logout: (id: number) => obj.client.POST('/api/admin/user/logout', { body: id }).then(v => v.data),
disableTfa: (id: number) => obj.client.POST('/api/admin/user/disable_tfa', { body: id }).then(v => v.data),
deleteUser: (id: number) => obj.client.POST('/api/admin/user/delete', { body: id }).then(v => v.data),
unSudo: () => obj.client.POST('/api/admin/un_sudo').then(v => v.data),
shutdown: () => obj.client.POST('/api/admin/shutdown').then(v => v.data),
}
};
obj.client.use({
onRequest({ schemaPath, request }) {
if (schemaPath.startsWith('/api/public') || obj.token == '')
return;
request.headers.set('Authorization', `Bearer ${obj.token}`);
return request;
}
});
return obj;
}
export type RpcClient = Omit<ReturnType<typeof _getRpcClient>, 'share_root'> & { share_root: null | number };
export const getRpcClient: () => RpcClient = () => _getRpcClient();
export const globalRpc = getRpcClient();
globalRpc.client.use({
onResponse({ schemaPath, response }) {
if (schemaPath.startsWith('/api/public') || schemaPath == '/api/user/session')
return response;
if (response.status >= 400 && response.status != 404) {
token.set(null);
replace('/login');
}
return response;
}
};
})

View File

@@ -4,6 +4,102 @@
*/
export interface paths {
"/api/user_share/session": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["session"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/user_share/fs/size": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["size"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/user_share/fs/preview": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["preview"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/user_share/fs/path": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["path"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/user_share/fs/node": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["nodeInfo"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/user_share/fs/mime": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["mime"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/user/tfa/setup_totp": {
parameters: {
query?: never;
@@ -68,7 +164,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/user/session": {
"/api/user/fs/unshare": {
parameters: {
query?: never;
header?: never;
@@ -77,14 +173,14 @@ export interface paths {
};
get?: never;
put?: never;
post: operations["session"];
post: operations["path_1"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/user/fs/size": {
"/api/user/fs/share": {
parameters: {
query?: never;
header?: never;
@@ -93,71 +189,7 @@ export interface paths {
};
get?: never;
put?: never;
post: operations["size"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/user/fs/preview": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["preview"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/user/fs/path": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["path"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/user/fs/node": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["nodeInfo"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/user/fs/mime": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["mime"];
post: operations["share"];
delete?: never;
options?: never;
head?: never;
@@ -472,15 +504,13 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
"de.mattv.fileserver.ResponseJava.lang.String": {
e?: string;
o?: string;
};
"de.mattv.fileserver.Response$Session": {
name: string;
tfaEnabled: boolean;
admin: boolean;
sudo: boolean;
/** Format: int64 */
shareRoot?: number;
};
"de.mattv.fileserver.Response$PathSegment": {
name: string;
@@ -493,12 +523,17 @@ export interface components {
name: string;
file: boolean;
preview: boolean;
shareName?: string;
/** Format: int64 */
size?: number;
/** Format: int64 */
parent?: number;
children?: components["schemas"]["de.mattv.fileserver.Response$Node"][];
};
"de.mattv.fileserver.ResponseJava.lang.String": {
e?: string;
o?: string;
};
"org.springframework.web.servlet.mvc.method.annotation.SseEmitter": {
/** Format: int64 */
timeout?: number;
@@ -566,88 +601,6 @@ export interface components {
}
export type $defs = Record<string, never>;
export interface operations {
setupTfaTotp: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["de.mattv.fileserver.ResponseJava.lang.String"];
};
};
};
};
setupTfaMail: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": string;
};
};
};
};
disableTfa: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
setupComplete: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": string;
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": string;
};
};
};
};
session: {
parameters: {
query?: never;
@@ -788,16 +741,146 @@ export interface operations {
};
};
};
delete: {
setupTfaTotp: {
parameters: {
query: {
ids: number[];
};
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["de.mattv.fileserver.ResponseJava.lang.String"];
};
};
};
};
setupTfaMail: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": string;
};
};
};
};
disableTfa: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
setupComplete: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": string;
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": string;
};
};
};
};
path_1: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": number;
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
share: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": number;
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": string;
};
};
};
};
delete: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": number[];
};
};
responses: {
/** @description OK */
200: {

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import {rpc, show_working} from '../store';
import {globalRpc, show_working} from '../store';
import {Button, ButtonGroup, Modal} from 'flowbite-svelte';
import {afterUpdate, createEventDispatcher} from 'svelte';
@@ -18,7 +18,8 @@
show_working.set(true);
await new Promise<void>((resolve) => {
rpc.deleteNodes(nodes, v => {
globalRpc.deleteNodes(nodes, v => {
if (v == null)
resolve();
else {

View File

@@ -22,14 +22,18 @@
Tooltip
} from 'flowbite-svelte';
import {filesize} from 'filesize';
import {Folder, FolderParent, DocumentBlank, CaretLeft, FolderAdd} from '../icons';
import {api, download, rpc, workingWrapperR, error_banner} from '../store';
import {Folder, FolderParent, DocumentBlank, CaretLeft, FolderAdd, Share} from '../icons';
import {api, download, workingWrapperR, error_banner, type RpcClient, info_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;
export let rpc: RpcClient
export let path_prefix: string;
const not_share = rpc.share_root == null;
const dispatch = createEventDispatcher<{reload_node: null}>();
@@ -79,7 +83,6 @@
$: 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;
@@ -93,15 +96,31 @@
const selectFolders = () => selected = dirs.map(v => v.id);
const selectFiles = () => selected = files.map(v => v.id);
const selectNone = () => selected = [];
const downloadSelected = () => download(nodes.filter(v => selected.includes(v.id)));
const downloadSelected = () => download(rpc, nodes.filter(v => selected.includes(v.id)));
let del: (nodes: number[]) => Promise<void>; // bound to DeleteModal
const deleteSelected = () => del(selected);
const onCtxDownload = () => download([ctx_node]);
let del: (nodes: number[]) => Promise<void>;
const onCtxDelete = () => del([ctx_node.id]);
const onCtxDownload = () => download(rpc, [ctx_node]);
const shareCopyLink = (id: string) => {
let link = `${location.origin}/#/share/${id}`;
navigator.clipboard.writeText(link);
info_banner.set('Copied link to clipboard');
};
const onCtxShare = async () => {
let id = await rpc.shareNode(ctx_node.id);
setTimeout(() => shareCopyLink(id!), 75);
dispatch('reload_node');
};
const onCtxCopyShare = () => shareCopyLink(ctx_node.shareName!);
const onCtxUnshare = async () => {
await rpc.unshareNode(ctx_node.id);
dispatch('reload_node');
}
const onShowPreview = (e: Event) => { show_preview.set((e.target as HTMLInputElement).checked); }
</script>
@@ -123,7 +142,7 @@
<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 class="pl-0"><A href={path_prefix + node.parent}>..</A></TableBodyCell>
<TableBodyCell></TableBodyCell>
</TableBodyRow>
{/if}
@@ -131,7 +150,10 @@
<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 class="pl-0 flex flex-row gap-2">
{#if node.shareName && not_share}<Share/>{/if}
<A href={path_prefix + node.id}>{node.name}</A>
</TableBodyCell>
<TableBodyCell></TableBodyCell>
</TableBodyRow>
{/each}
@@ -149,7 +171,10 @@
<DocumentBlank />
{/if}
</TableBodyCell>
<TableBodyCell class="pl-0"><A href={'#/view/' + node.id}>{node.name}</A></TableBodyCell>
<TableBodyCell class="pl-0 flex flex-row gap-2">
{#if node.shareName && not_share}<Share/>{/if}
<A href={path_prefix + node.id}>{node.name}</A>
</TableBodyCell>
<TableBodyCell>{filesize(node.size ?? 0, {base: 2, standard: 'jedec'})}</TableBodyCell>
</TableBodyRow>
{/each}
@@ -157,10 +182,10 @@
<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 not_share}<LinkButton on:click={() => (show_new_folder = true)} class="mr-3">New folder</LinkButton>{/if}
{#if selected.length > 0}
<LinkButton on:click={downloadSelected}>Download</LinkButton>
<LinkButton color="red" on:click={deleteSelected}>Delete</LinkButton>
{#if not_share}<LinkButton color="red" on:click={deleteSelected}>Delete</LinkButton>{/if}
{/if}
</td>
<td class="px-6 py-3">{filesize(total_size, {base: 2, standard: 'jedec'})}</td>
@@ -189,7 +214,17 @@
<!-- 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">
{#if not_share}
{#if ctx_node?.shareName}
<li><button class="font-medium py-2 px-4 text-sm hover:bg-gray-100 w-full text-left" on:click={onCtxCopyShare}>Copy share link</button></li>
<li><button class="font-medium py-2 px-4 text-sm hover:bg-gray-100 w-full text-left" on:click={onCtxUnshare}>Unshare</button></li>
{:else}
<li><button class="font-medium py-2 px-4 text-sm hover:bg-gray-100 w-full text-left" on:click={onCtxShare}>Share</button></li>
{/if}
{/if}
<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>
{#if not_share}
<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>
{/if}
</ul>
</div>

View File

@@ -1,10 +1,11 @@
<script lang="ts">
import {Button, Spinner} from 'flowbite-svelte';
import {Download} from '../icons';
import {api, download, rpc, token, workingWrapper} from '../store';
import {api, download, workingWrapper, type RpcClient} from '../store';
import {onDestroy} from 'svelte';
export let node: api.Node;
export let rpc: RpcClient;
let src = '';
let loading = false;
@@ -25,7 +26,7 @@
const resp = await fetch('/api/public/download', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `token=${$token ?? ''}&node=${node.id}`
body: `token=${rpc.token}&node=${node.id}`
});
if (resp.status != 200)
return;
@@ -38,7 +39,7 @@
onDestroy(() => { if (src.startsWith('blob')) URL.revokeObjectURL(src); });
</script>
<Button class="w-full mb-6" on:click={() => download([node])}><Download />Download</Button>
<Button class="w-full mb-6" on:click={() => download(rpc, [node])}><Download />Download</Button>
{#if can_display && !loading && src === ''}
<Button class="w-full" outline on:click={load}>Load</Button>
{:else if loading}

View File

@@ -10,6 +10,7 @@ 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 Share} from '~icons/carbon/Share';
export {default as CaretLeft} from '~icons/ph/CaretLeft';
export {default as OTP} from '~icons/ph/Password';

View File

@@ -1,11 +1,5 @@
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,

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { replace } from 'svelte-spa-router';
import { globalRpc } from '../api';
globalRpc.sessionInfo()
.then(v => {
if (v)
replace('/view/0');
else
replace('/login');
});
</script>

View File

@@ -2,7 +2,7 @@
import {Breadcrumb, Dropzone, Modal, Progressbar} from 'flowbite-svelte';
import {writable} from 'svelte/store';
import {CloudUpload} from '../icons';
import {api, rpc, token, type UploadFile, workingWrapper, workingWrapperR} from '../store';
import {api, globalRpc, token, type UploadFile, workingWrapper, workingWrapperR} from '../store';
import DirViewer from '../components/DirViewer.svelte';
import UploadModal from '../components/UploadModal.svelte';
import FileViewer from '../components/FileViewer.svelte';
@@ -25,10 +25,10 @@
const data = writable<Data>({node: null, segments: []});
async function updateData(id: number) {
let node = await workingWrapper(() => rpc.getNode(id));
let node = await workingWrapper(() => globalRpc.getNode(id));
if (!node)
return;
let segments = await workingWrapper(() => rpc.getPath(id));
let segments = await workingWrapper(() => globalRpc.getPath(id));
if (!segments)
return;
data.set({node: node as Data['node'], segments });
@@ -50,7 +50,7 @@
return [];
}
}
const resp = await workingWrapperR<api.CreateNodeInfo>(() => rpc.createNode(entry.name, parent, false));
const resp = await workingWrapperR<api.CreateNodeInfo>(() => globalRpc.createNode(entry.name, parent, false));
if (!resp) return [];
if (resp.isFile) return [];
const reader = (entry as FileSystemDirectoryEntry).createReader();
@@ -109,7 +109,7 @@
upload_progress_data.current = 0;
const upload_files: UploadFile[] = [];
for (const file of files) {
const resp = await workingWrapperR<api.CreateNodeInfo>(() => rpc.createNode(file.name, file.id, true));
const resp = await workingWrapperR<api.CreateNodeInfo>(() => globalRpc.createNode(file.name, file.id, true));
if (resp && resp.isFile)
upload_files.push({ ...file, id: resp.id, overwrite: resp.exists });
upload_progress_data.current++;
@@ -145,9 +145,9 @@
{#if $data.node === null}
<!-- Waiting for data -->
{:else if $data.node.file}
<FileViewer node={$data.node} />
<FileViewer rpc={globalRpc} node={$data.node} />
{:else}
<DirViewer node={$data.node} on:reload_node={() => updateData($data.node?.id ?? 0)} />
<DirViewer rpc={globalRpc} path_prefix="#/view/" 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)} />
@@ -159,4 +159,4 @@
</div>
<Progressbar class="!mt-0" size="h-4" bind:progress={upload_progress} />
</Modal>
{/if}
{/if}

View File

@@ -1,14 +1,14 @@
<script lang="ts">
import {Button, ButtonGroup, Card, Input, InputAddon} from 'flowbite-svelte';
import {Email, OTP, Password} from '../icons';
import {rpc, token, workingWrapperR} from '../store';
import {Email, OTP, Password} from '../../icons';
import {globalRpc, token, workingWrapperR} from '../../store';
import {replace} from 'svelte-spa-router';
let ask_tfa = false;
let username = '', password = '', tfa = '';
async function login() {
const resp = await workingWrapperR(() => rpc.login(username, password, ask_tfa ? tfa : undefined));
const resp = await workingWrapperR(() => globalRpc.login(username, password, ask_tfa ? tfa : undefined));
if (!resp) return;
if (resp.otpNeeded) {
ask_tfa = true;
@@ -45,7 +45,7 @@
<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>
<Button class="flex-1 flex-grow" color="primary" outline href="#/reset_pw">Forgot password</Button>
</ButtonGroup>
{/if}
</Card>

View File

@@ -1,14 +1,14 @@
<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 {Email, EmailNew, Password} from '../../icons';
import {error_banner, info_banner, globalRpc, 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.send_recovery_key(username));
await workingWrapper(() => globalRpc.send_recovery_key(username));
info_banner.set('A message has been sent');
enter_key = true;
}
@@ -19,7 +19,7 @@
return;
}
if (await workingWrapperO(() => rpc.reset_password(key, password)))
if (await workingWrapperO(() => globalRpc.reset_password(key, password)))
await replace('/login');
}

View File

@@ -1,7 +1,7 @@
<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 {Email, Password} from '../../icons';
import {error_banner, info_banner, globalRpc, workingWrapperO} from '../../store';
import {replace} from 'svelte-spa-router';
let username = '', username2 = '', password = '', password2 = '';
@@ -17,7 +17,7 @@
return;
}
const resp = await workingWrapperO(() => rpc.signup(username, password));
const resp = await workingWrapperO(() => globalRpc.signup(username, password));
if (resp) {
info_banner.set('Account created, please wait till an administrator approves it');

View File

@@ -1,51 +1,51 @@
<script lang="ts">
import {api, rpc, session, token, workingWrapper} from '../store';
import {api, globalRpc, session, token, workingWrapper} 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 {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 workingWrapper(() => rpc.admin.listUsers());
const resp = await workingWrapper(() => globalRpc.admin.listUsers());
users = resp || [];
}
async function changeEnabled(user: number, target: boolean) {
await workingWrapper(() => rpc.admin.setEnabled(user, target));
await workingWrapper(() => globalRpc.admin.setEnabled(user, target));
await fetchUsers();
}
async function changeAdmin(user: number, target: boolean) {
await workingWrapper(() => rpc.admin.setAdmin(user, target));
await workingWrapper(() => globalRpc.admin.setAdmin(user, target));
await fetchUsers();
}
async function sudo(user: number) {
await workingWrapper(() => rpc.admin.sudo(user))
await workingWrapper(() => globalRpc.admin.sudo(user))
await session.update('');
await replace('/view/0');
}
async function logout(user: number) {
await workingWrapper(() => rpc.admin.logout(user));
await workingWrapper(() => globalRpc.admin.logout(user));
await fetchUsers();
}
async function removeTfa(user: number) {
await workingWrapper(() => rpc.admin.disableTfa(user));
await workingWrapper(() => globalRpc.admin.disableTfa(user));
await fetchUsers();
}
async function deleteUser(user: number) {
await workingWrapper(() => rpc.admin.deleteUser(user));
await workingWrapper(() => globalRpc.admin.deleteUser(user));
await fetchUsers();
}
async function shutdown() {
if (confirm('Do you really want to shutdown the server?')) {
await rpc.admin.shutdown();
await globalRpc.admin.shutdown();
}
}

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import {error_banner, rpc, session, token, workingWrapper, workingWrapperO} from '../store';
import {error_banner, globalRpc, session, token, workingWrapper, workingWrapperO} from '../../store';
import {Accordion, AccordionItem, Button, ButtonGroup, Input, InputAddon} from 'flowbite-svelte';
import {Password} from '../icons';
import {info_banner} from '../store.js';
import {Password} from '../../icons';
import {info_banner} from '../../store.js';
const s = session.s;
const tfa_enabled: boolean = $s?.tfaEnabled ?? false;
@@ -15,7 +15,7 @@
return;
}
const resp = await workingWrapperO(() => rpc.change_password(old, password));
const resp = await workingWrapperO(() => globalRpc.change_password(old, password));
if (resp) {
info_banner.set('Changed password');
change_pw_data.o = '';
@@ -26,18 +26,18 @@
}
async function disableTfa() {
await workingWrapper(() => rpc.tfaDisable());
await workingWrapper(() => globalRpc.tfaDisable());
token.set(null);
}
async function logoutAll() {
await workingWrapper(() => rpc.logoutAll());
await workingWrapper(() => globalRpc.logoutAll());
token.set(null);
}
async function deleteAccount() {
if (confirm("Do your really want to delete your account?")) {
await workingWrapper(() => rpc.deleteAccount());
await workingWrapper(() => globalRpc.deleteAccount());
token.set(null);
}
}

View File

@@ -1,7 +1,7 @@
<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 {OTP} from '../../icons';
import {info_banner, globalRpc, session, token, workingWrapperO, workingWrapperR} from '../../store';
import QRCode from 'qrcode-svg';
const s = session.s;
@@ -13,13 +13,13 @@
async function startSetup(mail: boolean) {
if (mail) {
const resp = await workingWrapperO(() => rpc.tfaSetupMail());
const resp = await workingWrapperO(() => globalRpc.tfaSetupMail());
if (resp) {
secret = null;
step = 2;
}
} else {
const resp = await workingWrapperR<string>(() => rpc.tfaSetupTotp());
const resp = await workingWrapperR<string>(() => globalRpc.tfaSetupTotp());
if (resp != null) {
secret = resp.replaceAll('=', '');
secret_qr_code = new QRCode({
@@ -33,7 +33,7 @@
}
async function completeSetup() {
if (await workingWrapperO(() => rpc.tfaComplete(code))) {
if (await workingWrapperO(() => globalRpc.tfaComplete(code))) {
info_banner.set("Successfully set up two factor authentication");
token.set(null);
}

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { replace } from 'svelte-spa-router';
import { getRpcClient } from '../../api';
export let params: {sid?: string} | undefined = {};
let sid = params?.sid ?? '';
if (sid.length != 30) {
replace('/');
}
let rpc = getRpcClient();
rpc.token = sid;
rpc.sessionInfo().then(v => {
let root = v?.shareRoot;
if (!root) replace('/');
else replace(`/share/${sid}/${root}`);
})
</script>

View File

@@ -0,0 +1,77 @@
<script lang="ts">
import { replace } from 'svelte-spa-router';
import { getRpcClient } from '../../api';
import { writable } from 'svelte/store';
import { api, workingWrapper } from '../../store';
import { Breadcrumb } from 'flowbite-svelte';
import A from '../../components/A.svelte';
import FileViewer from '../../components/FileViewer.svelte';
import DirViewer from '../../components/DirViewer.svelte';
export let params: {sid?: string, id?: string} | undefined = {};
let sid = params?.sid ?? '';
if (sid.length != 30) {
replace('/');
}
let base_link = `#/share/${sid}/`;
let rpc = getRpcClient();
rpc.token = sid;
$: {
let id = 0;
if (params && params.id) {
id = parseInt(params.id);
if (id >= 0)
updateData(id);
}
}
interface Data {
node: api.Node | null,
segments: api.PathSegment[],
root: number,
}
const data = writable<Data>({node: null, segments: [], root: 0});
async function updateData(id: number) {
let root = await workingWrapper(() => rpc.sessionInfo().then(v => v?.shareRoot));
if (!root)
return replace('/');
let node = await workingWrapper(() => rpc.getNode(id));
if (!node)
return;
let segments = await workingWrapper(() => rpc.getPath(id));
if (!segments)
return;
rpc.share_root = root;
data.set({node: node as Data['node'], segments, root });
}
</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={base_link + segment.id}>{segment.id === $data.root ? 'Share' : segment.name}</A>
{:else}
<span style="padding: 0 0.25em;">{segment.name}</span>
{/if}
</li>
{/each}
</Breadcrumb>
<span class="flex-1"></span>
</div>
{#if $data.node === null}
<!-- Waiting for data -->
{:else if $data.node.file}
<FileViewer rpc={rpc} node={$data.node} />
{:else}
<DirViewer rpc={rpc} path_prefix={base_link} node={$data.node} on:reload_node={() => updateData($data.node?.id ?? $data.root)} />
{/if}
</div>

View File

@@ -1,17 +1,27 @@
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 Home from './pages/Home.svelte';
import Login from './pages/login/Login.svelte';
import Signup from './pages/login/Signup.svelte';
import ResetPassword from './pages/login/ResetPassword.svelte';
import Profile from './pages/profile/Profile.svelte';
import TfaSetup from './pages/profile/TfaSetup.svelte';
import Admin from './pages/profile/Admin.svelte';
import View from './pages/View.svelte';
import ShareHome from './pages/share/ShareHome.svelte';
import ShareView from './pages/share/ShareView.svelte';
export const routes = {
'/': Home,
'/login': Login,
'/signup': Signup,
'/reset_pw': ResetPassword,
'/profile': Profile,
'/tfa': TfaSetup,
'/admin': Admin,
'/view/:id': View
'/view/:id': View,
'/share/:sid': ShareHome,
'/share/:sid/:id': ShareView
}

View File

@@ -1,9 +1,9 @@
import {type Session, rpc} from './api';
import {type Session, type RpcClient, globalRpc} from './api';
import {type Writable, writable} from 'svelte/store';
import {filesize} from 'filesize';
export * as api from './api';
export {rpc} from './api';
export {globalRpc, type RpcClient} from './api';
export interface UploadFile {
id: number,
@@ -25,14 +25,11 @@ export const session: { s: Writable<Session|null>, update: (token: string|null)
session.s.set(null);
return;
}
const s = await rpc.sessionInfo();
if (!s)
token.set(null);
else
session.s.set(s);
const s = await globalRpc.sessionInfo();
session.s.set(s || null);
}
};
token.subscribe(t => rpc.token = t ?? '');
token.subscribe(t => globalRpc.token = t ?? '');
token.subscribe(t => session.update(t));
token.subscribe(v => {
@@ -76,7 +73,7 @@ export async function workingWrapperR<T>(fn: () => Promise<{
return resp.o as unknown as T;
}
export async function download<T extends {id:number, file:boolean}>(nodes: T[]) {
export async function download<T extends {id:number, file:boolean}>(rpc: RpcClient, nodes: T[]) {
const form = document.createElement('form');
form.method = 'POST';
form.target = '_blank';