From 870a3595ee332c6ad46bdf0f67826c93eec30b8c Mon Sep 17 00:00:00 2001 From: Mutzi Date: Sun, 22 Jun 2025 13:30:55 +0200 Subject: [PATCH] Implemented write access for share links via password --- build.gradle.kts | 4 +- frontend/icons/ShareEdit.svg | 11 + frontend/src/api/index.ts | 13 +- frontend/src/api/schema.d.ts | 220 ++++++++++++++---- frontend/src/components/DirViewer.svelte | 123 ++++++---- frontend/src/components/InputModal.svelte | 30 +++ frontend/src/components/UploadModal.svelte | 16 +- frontend/src/icons.ts | 1 + .../src/pages/{share => }/ShareHome.svelte | 2 +- frontend/src/pages/View.svelte | 100 ++++++-- frontend/src/pages/share/ShareView.svelte | 77 ------ frontend/src/routes.ts | 5 +- frontend/tsconfig.json | 2 +- frontend/vite.config.ts | 8 +- .../java/de/mattv/fileserver/Response.java | 6 +- .../java/de/mattv/fileserver/data/Node.java | 1 + .../java/de/mattv/fileserver/data/Token.java | 15 +- .../data/converter/NodeConverter.java | 12 +- .../de/mattv/fileserver/routes/Upload.java | 17 +- .../fileserver/routes/auth/AuthUtils.java | 4 +- .../mattv/fileserver/routes/auth/Login.java | 2 +- .../fileserver/routes/auth/SessionInfo.java | 8 +- .../fileserver/routes/auth/ShareUnlock.java | 31 +++ .../de/mattv/fileserver/routes/fs/Create.java | 7 +- .../mattv/fileserver/routes/fs/FsUtils.java | 2 +- .../de/mattv/fileserver/routes/fs/Share.java | 15 +- .../fileserver/security/ShareService.java | 17 +- .../fileserver/security/TokenService.java | 46 ++-- 28 files changed, 551 insertions(+), 244 deletions(-) create mode 100644 frontend/icons/ShareEdit.svg create mode 100644 frontend/src/components/InputModal.svelte rename frontend/src/pages/{share => }/ShareHome.svelte (90%) delete mode 100644 frontend/src/pages/share/ShareView.svelte create mode 100644 src/main/java/de/mattv/fileserver/routes/auth/ShareUnlock.java diff --git a/build.gradle.kts b/build.gradle.kts index b59b8ff..b4d4f2a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -43,8 +43,8 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0") - implementation("com.thoughtworks.xstream:xstream:1.4.20") - implementation("dev.samstevens.totp:totp:1.7") + implementation("com.thoughtworks.xstream:xstream:1.4.21") + implementation("dev.samstevens.totp:totp:1.7.1") implementation("com.twelvemonkeys.imageio:imageio-webp:3.11.0") implementation("com.twelvemonkeys.imageio:imageio-jpeg:3.11.0") implementation("net.coobird:thumbnailator:0.4.20") diff --git a/frontend/icons/ShareEdit.svg b/frontend/icons/ShareEdit.svg new file mode 100644 index 0000000..ff1163f --- /dev/null +++ b/frontend/icons/ShareEdit.svg @@ -0,0 +1,11 @@ + + + + diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 67ca0e4..623c959 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -3,18 +3,22 @@ import createClient from 'openapi-fetch'; import {fetchEventSource} from '@microsoft/fetch-event-source'; import {token} from '../store'; import { replace } from 'svelte-spa-router'; +import { writable } from 'svelte/store'; export type Session = components['schemas']['de.mattv.fileserver.Response$Session']; export type Node = components['schemas']['de.mattv.fileserver.Response$Node']; export type PathSegment = components['schemas']['de.mattv.fileserver.Response$PathSegment']; export type CreateNodeInfo = components['schemas']['de.mattv.fileserver.Response$CreateNodeInfo']; export type UserInfo = components['schemas']['de.mattv.fileserver.Response$UserInfo']; +export type AllPaths = keyof paths + +export enum RpcClientType { AUTH, SHARE_RO, SHARE_RW } const _getRpcClient = () => { let obj = { token: '', client: createClient(), - share_root: null, + ty: writable(RpcClientType.AUTH), signup: (username: string, password: string) => obj.client.POST('/api/public/auth/signup', { body: { username, password } }).then(v => v.data), @@ -48,7 +52,7 @@ const _getRpcClient = () => { downloadPreview: (node: number) => obj.client.POST('/api/user_share/fs/preview', { body: node }).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), + obj.client.POST('/api/user_share/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', @@ -64,6 +68,8 @@ const _getRpcClient = () => { 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), + shareSetPw: (node: number, pw?: string) => obj.client.POST('/api/user/fs/share_pw', { body: { id: node, password: pw } }).then(v => v.data), + shareUnlock: (pw: string) => obj.client.POST('/api/user_share/share_unlock', { body: pw }).then(v => v.data), admin: { listUsers: () => obj.client.POST('/api/admin/users').then(v => v.data), @@ -91,7 +97,8 @@ const _getRpcClient = () => { return obj; } -export type RpcClient = Omit, 'share_root'> & { share_root: null | number }; +//export type RpcClient = Omit, 'share_root'> & { share_root: null | number }; +export type RpcClient = ReturnType; export const getRpcClient: () => RpcClient = () => _getRpcClient(); export const globalRpc = getRpcClient(); diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 269b388..093a06a 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -4,7 +4,23 @@ */ export interface paths { - "/api/user_share/session": { + "/api/user_share/upload/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["upload"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/user_share/share_unlock": { parameters: { query?: never; header?: never; @@ -20,6 +36,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/user_share/session": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["session_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/user_share/fs/size": { parameters: { query?: never; @@ -100,6 +132,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/user_share/fs/create": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/user/tfa/setup_totp": { parameters: { query?: never; @@ -180,6 +228,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/user/fs/share_pw": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["setPw"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/user/fs/share": { parameters: { query?: never; @@ -212,22 +276,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/user/fs/create": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["create"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/user/auth/logout_all": { parameters: { query?: never; @@ -504,6 +552,10 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + "de.mattv.fileserver.ResponseJava.lang.String": { + e?: string; + o?: string; + }; "de.mattv.fileserver.Response$Session": { name: string; tfaEnabled: boolean; @@ -511,6 +563,8 @@ export interface components { sudo: boolean; /** Format: int64 */ shareRoot?: number; + shareCanRw: boolean; + shareIsRw: boolean; }; "de.mattv.fileserver.Response$PathSegment": { name: string; @@ -523,6 +577,7 @@ export interface components { name: string; file: boolean; preview: boolean; + shareHasRw: boolean; shareName?: string; /** Format: int64 */ size?: number; @@ -530,14 +585,6 @@ export interface components { 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; - }; "de.mattv.fileserver.routes.fs.Create$Body": { name: string; /** Format: int64 */ @@ -554,6 +601,15 @@ export interface components { e?: string; o?: components["schemas"]["de.mattv.fileserver.Response$CreateNodeInfo"]; }; + "de.mattv.fileserver.routes.fs.Share$SetPwBody": { + /** Format: int64 */ + id: number; + password?: string; + }; + "org.springframework.web.servlet.mvc.method.annotation.SseEmitter": { + /** Format: int64 */ + timeout?: number; + }; "de.mattv.fileserver.routes.auth.ChangePW$Body": { oldPassword: string; newPassword: string; @@ -601,7 +657,51 @@ export interface components { } export type $defs = Record; export interface operations { + upload: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; session: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": string; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["de.mattv.fileserver.ResponseJava.lang.String"]; + }; + }; + }; + }; + session_1: { parameters: { query?: never; header?: never; @@ -741,6 +841,30 @@ export interface operations { }; }; }; + create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["de.mattv.fileserver.routes.fs.Create$Body"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["de.mattv.fileserver.ResponseDe.mattv.fileserver.Response$CreateNodeInfo"]; + }; + }; + }; + }; setupTfaTotp: { parameters: { query?: never; @@ -845,6 +969,28 @@ export interface operations { }; }; }; + setPw: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["de.mattv.fileserver.routes.fs.Share$SetPwBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; share: { parameters: { query?: never; @@ -893,30 +1039,6 @@ export interface operations { }; }; }; - create: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["de.mattv.fileserver.routes.fs.Create$Body"]; - }; - }; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["de.mattv.fileserver.ResponseDe.mattv.fileserver.Response$CreateNodeInfo"]; - }; - }; - }; - }; logoutAll: { parameters: { query?: never; diff --git a/frontend/src/components/DirViewer.svelte b/frontend/src/components/DirViewer.svelte index 9404737..4178d55 100644 --- a/frontend/src/components/DirViewer.svelte +++ b/frontend/src/components/DirViewer.svelte @@ -22,21 +22,23 @@ Tooltip } from 'flowbite-svelte'; import {filesize} from 'filesize'; - import {Folder, FolderParent, DocumentBlank, CaretLeft, FolderAdd, Share} from '../icons'; + import {Folder, FolderParent, DocumentBlank, CaretLeft, FolderAdd, Share, ShareEdit, Password} 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'; + import { RpcClientType } from '../api'; + import InputModal from './InputModal.svelte'; export let node: api.Node; - export let rpc: RpcClient + export let rpc: RpcClient; export let path_prefix: string; - const not_share = rpc.share_root == null; - const dispatch = createEventDispatcher<{reload_node: null}>(); + const rpc_ty = rpc.ty; + let selected: number[] = []; let nodes: api.Node[], dirs: api.Node[], files: api.Node[], previews: {[key: number]: string|null} = {}; let total_size: number; @@ -53,29 +55,33 @@ } } - - 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) + //#region New Folder Dialog + let show_new_folder = false; + async function newFolder(e: CustomEvent) { + const name = e.detail; + if (name.length === 0) return error_banner.set('Folder name can\'t be empty'); show_new_folder = false; - const resp = await workingWrapperR(() => rpc.createNode(new_folder_name, node.id, false)); + const resp = await workingWrapperR(() => rpc.createNode(name, node.id, false)); if (resp && resp.isFile) return error_banner.set('Folder already exists as file'); dispatch('reload_node'); } + //#endregion - async function getPreview(node: number) { - const resp = await rpc.downloadPreview(node); - if (!resp) - return; - previews[node] = 'data:image/png;base64,' + resp; - previews = previews; + //#region Input password Dialog + let show_pw_dialog = false; + async function pwInputDone(e: CustomEvent) { + const pw = e.detail; + await rpc.shareSetPw(ctx_node.id, pw); + show_pw_dialog = false; + dispatch('reload_node'); } + //#endregion + //#region Context Menu let ctx_node: api.Node; let ctx_hidden = true; let ctx_x = 0, ctx_y = 0; @@ -92,14 +98,6 @@ 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(rpc, nodes.filter(v => selected.includes(v.id))); - - let del: (nodes: number[]) => Promise; // bound to DeleteModal - const deleteSelected = () => del(selected); const onCtxDelete = () => del([ctx_node.id]); const onCtxDownload = () => download(rpc, [ctx_node]); @@ -120,8 +118,31 @@ await rpc.unshareNode(ctx_node.id); dispatch('reload_node'); } + const onCtxSharePw = () => show_pw_dialog = true; + const onCtxShareRmPw = async () => { + await rpc.shareSetPw(ctx_node.id, undefined); + dispatch('reload_node'); + } + //#endregion + + async function getPreview(node: number) { + const resp = await rpc.downloadPreview(node); + if (!resp) + return; + previews[node] = 'data:image/png;base64,' + resp; + previews = previews; + } const onShowPreview = (e: Event) => { show_preview.set((e.target as HTMLInputElement).checked); } + + 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(rpc, nodes.filter(v => selected.includes(v.id))); + + let del: (nodes: number[]) => Promise; // bound to DeleteModal + const deleteSelected = () => del(selected); (ctx_hidden = true)} /> @@ -151,7 +172,9 @@ - {#if node.shareName && not_share}{/if} + {#if node.shareName && $rpc_ty === RpcClientType.AUTH} + {#if node.shareHasRw}{:else}{/if} + {/if} {node.name} @@ -172,7 +195,9 @@ {/if} - {#if node.shareName && not_share}{/if} + {#if node.shareName && $rpc_ty === RpcClientType.AUTH} + {#if node.shareHasRw}{:else}{/if} + {/if} {node.name} {filesize(node.size ?? 0, {base: 2, standard: 'jedec'})} @@ -182,10 +207,14 @@ - {#if not_share} (show_new_folder = true)} class="mr-3">New folder{/if} + {#if $rpc_ty !== RpcClientType.SHARE_RO} + (show_new_folder = true)} class="mr-3">New folder + {/if} {#if selected.length > 0} Download - {#if not_share}Delete{/if} + {#if $rpc_ty === RpcClientType.AUTH + }Delete + {/if} {/if} {filesize(total_size, {base: 2, standard: 'jedec'})} @@ -193,16 +222,24 @@ - - - - - - - - - - + + Select all @@ -214,8 +251,14 @@ - updateData($data.node?.id ?? 0)} /> + + updateData($data.node?.id ?? 0)} /> {#if upload_progress_data.current !== upload_progress_data.total}
@@ -160,3 +206,13 @@ {/if} + + diff --git a/frontend/src/pages/share/ShareView.svelte b/frontend/src/pages/share/ShareView.svelte deleted file mode 100644 index 30ebc64..0000000 --- a/frontend/src/pages/share/ShareView.svelte +++ /dev/null @@ -1,77 +0,0 @@ - - -
-
- - {#each $data.segments as segment, i} - {#if i > 0}
  • /
  • {/if} -
  • - {#if segment.id !== null} - {segment.id === $data.root ? 'Share' : segment.name} - {:else} - {segment.name} - {/if} -
  • - {/each} -
    - -
    - {#if $data.node === null} - - {:else if $data.node.file} - - {:else} - updateData($data.node?.id ?? $data.root)} /> - {/if} -
    diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index a98cc6b..23f7b36 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -6,8 +6,7 @@ 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'; +import ShareHome from './pages/ShareHome.svelte'; export const routes = { '/': Home, @@ -23,5 +22,5 @@ export const routes = { '/view/:id': View, '/share/:sid': ShareHome, - '/share/:sid/:id': ShareView + '/share/:sid/:id': View } diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index cfa0f5e..ab06d30 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -16,6 +16,6 @@ "isolatedModules": true, "noUncheckedIndexedAccess": true }, - "include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"], + "include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte", "src/icons/ShareEdit.svg", "icons/ShareEdit.svelte"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index b449b35..d192bb0 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,11 +4,17 @@ 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 {FileSystemIconLoader} from 'unplugin-icons/loaders'; export default defineConfig({ plugins: [ svelte(), - Icons({ compiler: 'svelte' }), + Icons({ + compiler: 'svelte', + customCollections: { + custom: FileSystemIconLoader('./icons') + } + }), purgeCss(), viteSingleFile({removeViteModuleLoader: true}), createHtmlPlugin({minify: true}) diff --git a/src/main/java/de/mattv/fileserver/Response.java b/src/main/java/de/mattv/fileserver/Response.java index efefc41..e715543 100644 --- a/src/main/java/de/mattv/fileserver/Response.java +++ b/src/main/java/de/mattv/fileserver/Response.java @@ -25,7 +25,9 @@ public class Response { @NonNull boolean tfaEnabled, @NonNull boolean admin, @NonNull boolean sudo, - @Nullable Long shareRoot + @Nullable Long shareRoot, + @NonNull boolean shareCanRw, + @NonNull boolean shareIsRw ) {} public record UserInfo( @@ -52,6 +54,7 @@ public class Response { @NonNull String name, @NonNull boolean file, @NonNull boolean preview, + @NonNull boolean shareHasRw, @Nullable String shareName, @Nullable Long size, @Nullable Long parent, @@ -63,6 +66,7 @@ public class Response { node.name, node.isFile, node.hasPreview, + node.shareWritePw != null, node.shareString, node.size, (node.parent == null || !hasParent) ? null : node.parent.id, diff --git a/src/main/java/de/mattv/fileserver/data/Node.java b/src/main/java/de/mattv/fileserver/data/Node.java index b143bdc..bd55484 100644 --- a/src/main/java/de/mattv/fileserver/data/Node.java +++ b/src/main/java/de/mattv/fileserver/data/Node.java @@ -21,6 +21,7 @@ public class Node { public long size = 0; public Node parent = null; public String shareString = null; + public String shareWritePw = null; public final ArrayList children = new ArrayList<>(); public final File file; diff --git a/src/main/java/de/mattv/fileserver/data/Token.java b/src/main/java/de/mattv/fileserver/data/Token.java index 656a40b..d12c77f 100644 --- a/src/main/java/de/mattv/fileserver/data/Token.java +++ b/src/main/java/de/mattv/fileserver/data/Token.java @@ -14,18 +14,20 @@ import java.util.List; public class Token implements Authentication { private static final Duration LIFETIME = Duration.ofHours(24); - private static Instant nowPlusLifetime() { return Instant.now().plus(LIFETIME); } + private static final Duration LIFETIME_SRW = Duration.ofHours(1); private static final SimpleGrantedAuthority AUTHORITY_USER = new SimpleGrantedAuthority("ROLE_USER"); private static final SimpleGrantedAuthority AUTHORITY_ADMIN = new SimpleGrantedAuthority("ROLE_ADMIN"); - private @NonNull Instant expiresAt = nowPlusLifetime(); + private @NonNull Instant expiresAt; @Getter private final @NonNull String token; @Getter private final Long shareRoot; + @Getter private final boolean shareCanRw; + @Getter private final boolean shareRw; @Getter private @NonNull User user; @Getter private User sudoRealUser = null; - public void refresh() { expiresAt = nowPlusLifetime(); } + public void refresh() { expiresAt = Instant.now().plus(shareRoot == null ? LIFETIME : LIFETIME_SRW); } public boolean isShare() { return shareRoot != null; } public boolean expired() { return Utils.instantExpired(expiresAt); } public boolean inSudo() { return sudoRealUser != null; } @@ -44,10 +46,13 @@ public class Token implements Authentication { user = newUser; } - public Token(@NonNull String token, @NonNull User user, Long shareRoot) { + public Token(@NonNull String token, @NonNull User user, Long shareRoot, boolean shareCanRw, boolean shareRw) { this.token = token; this.user = user; this.shareRoot = shareRoot; + this.shareCanRw = shareCanRw; + this.shareRw = shareRw; + this.refresh(); } @Override public Object getDetails() { return null; } @@ -72,6 +77,6 @@ public class Token implements Authentication { @Override public String toString() { if (shareRoot == null) return "Token{user=" + user + ", real_user=" + sudoRealUser + '}'; - else return "Share(" + token + ")"; + else return (shareRw ? "ShareRW(" : "Share(") + token + ")"; } } diff --git a/src/main/java/de/mattv/fileserver/data/converter/NodeConverter.java b/src/main/java/de/mattv/fileserver/data/converter/NodeConverter.java index 116d1ad..44d84c2 100644 --- a/src/main/java/de/mattv/fileserver/data/converter/NodeConverter.java +++ b/src/main/java/de/mattv/fileserver/data/converter/NodeConverter.java @@ -15,6 +15,7 @@ public class NodeConverter { private static final String ATTR_FLAGS_NAME = "flags"; private static final String ATTR_SIZE_NAME = "size"; private static final String ATTR_SHARE_NAME = "share"; + private static final String ATTR_SHARE_RW_NAME = "share-pw"; private static class Flags { public static final long FILE = 1; @@ -32,7 +33,10 @@ public class NodeConverter { writer.addAttribute(ATTR_FLAGS_NAME, String.valueOf(flags)); if (node.isFile) writer.addAttribute(ATTR_SIZE_NAME, String.valueOf(node.size)); - if (node.shareString != null) writer.addAttribute(ATTR_SHARE_NAME, node.shareString); + if (node.shareString != null) { + writer.addAttribute(ATTR_SHARE_NAME, node.shareString); + if (node.shareWritePw != null) writer.addAttribute(ATTR_SHARE_RW_NAME, node.shareWritePw); + } node.children.forEach(child -> toXml(child, writer)); @@ -65,8 +69,12 @@ public class NodeConverter { } String share = reader.getAttribute(ATTR_SHARE_NAME); - if (share != null) + if (share != null) { node.shareString = share; + String sharePw = reader.getAttribute(ATTR_SHARE_RW_NAME); + if (sharePw != null) + node.shareWritePw = sharePw; + } while (reader.hasMoreChildren()) { Node child = fromXml(reader, user); diff --git a/src/main/java/de/mattv/fileserver/routes/Upload.java b/src/main/java/de/mattv/fileserver/routes/Upload.java index 03271d7..9ef550a 100644 --- a/src/main/java/de/mattv/fileserver/routes/Upload.java +++ b/src/main/java/de/mattv/fileserver/routes/Upload.java @@ -4,10 +4,10 @@ import de.mattv.fileserver.Utils; import de.mattv.fileserver.data.Node; import de.mattv.fileserver.data.Token; import de.mattv.fileserver.data.User; +import de.mattv.fileserver.routes.fs.FsUtils; import de.mattv.fileserver.util.AuthUser; import de.mattv.fileserver.util.AutoCloseLock; -import de.mattv.fileserver.util.UserRestController; -import io.swagger.v3.oas.annotations.Operation; +import de.mattv.fileserver.util.UserOrShareRestController; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import jakarta.servlet.http.HttpServletRequest; @@ -25,7 +25,7 @@ import java.nio.file.StandardCopyOption; import java.util.concurrent.atomic.AtomicLong; @Slf4j -@UserRestController +@UserOrShareRestController public class Upload { private static final File TEMP_DIR = new File("temp"); private static final AtomicLong NEXT_TEMP_ID = new AtomicLong(0); @@ -46,7 +46,6 @@ public class Upload { } @PostMapping("/upload/{id}") - @Operation(hidden = true) private void upload(@AuthUser Token token, @PathVariable long id, HttpServletRequest request, HttpServletResponse response) throws IOException { User user = token.getUser(); try (AutoCloseLock ignored = new AutoCloseLock(user.nodeLock.readLock())) { @@ -55,6 +54,16 @@ public class Upload { response.sendError(400, "Invalid node"); return; } + if (token.isShare()) { + if (!token.isShareRw()) { + response.sendError(401, "Share is not unlocked"); + return; + } + if (!FsUtils.shareHasAccess(token, node)) { + response.sendError(401, "No access to node"); + return; + } + } File tempFile = getTempFile(); FileCopyUtils.copy(request.getInputStream(), Files.newOutputStream(tempFile.toPath())); diff --git a/src/main/java/de/mattv/fileserver/routes/auth/AuthUtils.java b/src/main/java/de/mattv/fileserver/routes/auth/AuthUtils.java index 141dfda..ae431a5 100644 --- a/src/main/java/de/mattv/fileserver/routes/auth/AuthUtils.java +++ b/src/main/java/de/mattv/fileserver/routes/auth/AuthUtils.java @@ -9,6 +9,6 @@ import org.springframework.security.crypto.argon2.Argon2PasswordEncoder; public class AuthUtils { private AuthUtils() {} - protected static final Argon2PasswordEncoder PW_ENCODER = Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8(); - protected static final CodeVerifier TOTP_VERIFIER = new DefaultCodeVerifier(new DefaultCodeGenerator(), new SystemTimeProvider()); + public static final Argon2PasswordEncoder PW_ENCODER = Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8(); + public static final CodeVerifier TOTP_VERIFIER = new DefaultCodeVerifier(new DefaultCodeGenerator(), new SystemTimeProvider()); } diff --git a/src/main/java/de/mattv/fileserver/routes/auth/Login.java b/src/main/java/de/mattv/fileserver/routes/auth/Login.java index 66abf5c..e80ebba 100644 --- a/src/main/java/de/mattv/fileserver/routes/auth/Login.java +++ b/src/main/java/de/mattv/fileserver/routes/auth/Login.java @@ -54,7 +54,7 @@ public class Login { } } - return Response.o(new Response.Login(false, TokenService.createToken(user))); + return Response.o(new Response.Login(false, TokenService.createToken(user, null))); } } } diff --git a/src/main/java/de/mattv/fileserver/routes/auth/SessionInfo.java b/src/main/java/de/mattv/fileserver/routes/auth/SessionInfo.java index bf4da07..060b7e0 100644 --- a/src/main/java/de/mattv/fileserver/routes/auth/SessionInfo.java +++ b/src/main/java/de/mattv/fileserver/routes/auth/SessionInfo.java @@ -18,7 +18,9 @@ public class SessionInfo { false, false, false, - token.getShareRoot() + token.getShareRoot(), + token.isShareCanRw(), + token.isShareRw() ); } User user = token.getUser(); @@ -27,7 +29,9 @@ public class SessionInfo { user.tfaSecret != null, token.isAdmin(), token.inSudo(), - null + null, + false, + false ); } } diff --git a/src/main/java/de/mattv/fileserver/routes/auth/ShareUnlock.java b/src/main/java/de/mattv/fileserver/routes/auth/ShareUnlock.java new file mode 100644 index 0000000..dbdf037 --- /dev/null +++ b/src/main/java/de/mattv/fileserver/routes/auth/ShareUnlock.java @@ -0,0 +1,31 @@ +package de.mattv.fileserver.routes.auth; + +import de.mattv.fileserver.Response; +import de.mattv.fileserver.data.Token; +import de.mattv.fileserver.security.TokenService; +import de.mattv.fileserver.util.AuthUser; +import de.mattv.fileserver.util.AutoCloseLock; +import de.mattv.fileserver.util.NonNull; +import de.mattv.fileserver.util.UserOrShareRestController; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@UserOrShareRestController +public class ShareUnlock { + @PostMapping("/share_unlock") + private @NonNull Response session(@AuthUser Token token, @RequestBody @NonNull String pw) { + if (!token.isShare()) return Response.e("Not a share token"); + if (!token.isShareCanRw()) return Response.e("Share can not be unlocked"); + if (token.isShareRw()) return Response.e("Share already unlocked"); + + var user = token.getUser(); + var root = token.getShareRoot(); + try (AutoCloseLock ignored = new AutoCloseLock(user.nodeLock.readLock())) { + var node = user.nodes.get(root); + if (node == null) return Response.e("Invalid share"); + if (node.shareWritePw == null) return Response.e("Share can not be unlocked"); + if (!AuthUtils.PW_ENCODER.matches(pw, node.shareWritePw)) return Response.e("Wrong password"); + return Response.o(TokenService.createToken(user, node)); + } + } +} diff --git a/src/main/java/de/mattv/fileserver/routes/fs/Create.java b/src/main/java/de/mattv/fileserver/routes/fs/Create.java index c6fd99e..6505ff4 100644 --- a/src/main/java/de/mattv/fileserver/routes/fs/Create.java +++ b/src/main/java/de/mattv/fileserver/routes/fs/Create.java @@ -6,7 +6,7 @@ import de.mattv.fileserver.data.Token; import de.mattv.fileserver.data.User; import de.mattv.fileserver.util.AuthUser; import de.mattv.fileserver.util.AutoCloseLock; -import de.mattv.fileserver.util.UserRestController; +import de.mattv.fileserver.util.UserOrShareRestController; import de.mattv.fileserver.util.NonNull; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.PostMapping; @@ -16,7 +16,7 @@ import java.io.IOException; import java.util.Optional; @Slf4j -@UserRestController +@UserOrShareRestController public class Create { private record Body(@NonNull String name, @NonNull long parent, @NonNull boolean file) {} @@ -25,7 +25,8 @@ public class Create { User user = token.getUser(); try (AutoCloseLock ignored = new AutoCloseLock(user.nodeLock.readLock())) { Node parent = user.nodes.get(body.parent); - if (parent == null) return Response.e("Invalid parent node"); + if (parent == null || !FsUtils.shareHasAccess(token, parent)) return Response.e("Invalid parent node"); + if (token.isShare() && !token.isShareRw()) return Response.e("Share is not unlocked"); Optional existing = parent.children.stream().filter(n -> n.name.equals(body.name)).findFirst(); if (existing.isPresent()) return Response.o(new Response.CreateNodeInfo( diff --git a/src/main/java/de/mattv/fileserver/routes/fs/FsUtils.java b/src/main/java/de/mattv/fileserver/routes/fs/FsUtils.java index 619bb2d..605ef2c 100644 --- a/src/main/java/de/mattv/fileserver/routes/fs/FsUtils.java +++ b/src/main/java/de/mattv/fileserver/routes/fs/FsUtils.java @@ -37,7 +37,7 @@ public class FsUtils { return path.isEmpty() ? "/" : path.toString(); } - public static boolean shareHasAccess(Token token, Node node) { + public static boolean shareHasAccess(@NonNull Token token, @NonNull Node node) { Long rootId = token.getShareRoot(); if (rootId == null) return true; do { diff --git a/src/main/java/de/mattv/fileserver/routes/fs/Share.java b/src/main/java/de/mattv/fileserver/routes/fs/Share.java index 51adedc..c1f148a 100644 --- a/src/main/java/de/mattv/fileserver/routes/fs/Share.java +++ b/src/main/java/de/mattv/fileserver/routes/fs/Share.java @@ -8,6 +8,7 @@ import de.mattv.fileserver.util.AuthUser; import de.mattv.fileserver.util.AutoCloseLock; import de.mattv.fileserver.util.NonNull; import de.mattv.fileserver.util.UserRestController; +import jakarta.annotation.Nullable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -28,10 +29,22 @@ public class Share { @PostMapping("/fs/unshare") private void path(@AuthUser Token token, @RequestBody @NonNull long id) { User user = token.getUser(); - try (AutoCloseLock ignored = new AutoCloseLock(user.nodeLock.readLock())) { + try (AutoCloseLock ignored = new AutoCloseLock(user.nodeLock.writeLock())) { Node node = user.nodes.get(id); if (node != null) ShareService.removeShare(node); } } + + + private record SetPwBody(@NonNull long id, @Nullable String password) {} + @PostMapping("/fs/share_pw") + private void setPw(@AuthUser Token token, @RequestBody @NonNull SetPwBody body) { + User user = token.getUser(); + try (AutoCloseLock ignored = new AutoCloseLock(user.nodeLock.writeLock())) { + Node node = user.nodes.get(body.id); + if (node != null) + ShareService.setPassword(node, body.password); + } + } } diff --git a/src/main/java/de/mattv/fileserver/security/ShareService.java b/src/main/java/de/mattv/fileserver/security/ShareService.java index c929371..84c05f4 100644 --- a/src/main/java/de/mattv/fileserver/security/ShareService.java +++ b/src/main/java/de/mattv/fileserver/security/ShareService.java @@ -3,10 +3,11 @@ package de.mattv.fileserver.security; import de.mattv.fileserver.data.Data; import de.mattv.fileserver.data.Node; import de.mattv.fileserver.data.User; +import de.mattv.fileserver.routes.auth.AuthUtils; import de.mattv.fileserver.util.NonNull; +import jakarta.annotation.Nullable; import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.tuple.Pair; -import org.springframework.lang.Nullable; import java.util.concurrent.ConcurrentHashMap; @@ -27,7 +28,7 @@ public class ShareService { return SHARES.get(token); } - public static String createShare(User user, Node node) { + public static String createShare(@NonNull User user, @NonNull Node node) { if (node.shareString != null) return node.shareString; @@ -37,9 +38,19 @@ public class ShareService { return name; } - public static void removeShare(Node node) { + public static void setPassword(@NonNull Node node, @Nullable String password) { + if (node.shareString == null) return; + node.shareWritePw = (password == null) + ? null + : AuthUtils.PW_ENCODER.encode(password); + TokenService.removeShareTokens(node.id); + } + + public static void removeShare(@NonNull Node node) { if (node.shareString == null) return; SHARES.remove(node.shareString); node.shareString = null; + node.shareWritePw = null; + TokenService.removeShareTokens(node.id); } } diff --git a/src/main/java/de/mattv/fileserver/security/TokenService.java b/src/main/java/de/mattv/fileserver/security/TokenService.java index 9e6b6e4..82f09a2 100644 --- a/src/main/java/de/mattv/fileserver/security/TokenService.java +++ b/src/main/java/de/mattv/fileserver/security/TokenService.java @@ -1,5 +1,6 @@ package de.mattv.fileserver.security; +import de.mattv.fileserver.data.Node; import de.mattv.fileserver.data.Token; import de.mattv.fileserver.data.User; import de.mattv.fileserver.util.NonNull; @@ -20,27 +21,34 @@ public class TokenService { TOKENS.entrySet().removeIf(entry -> entry.getValue().expired()); } - public static String createToken(@NonNull User user) { + public static String createToken(@NonNull User user, Node shareRoot) { String token = RandomStringUtils.random(TOKEN_LENGTH, true, true); - TOKENS.put(token, new Token(token, user, null)); + TOKENS.put(token, (shareRoot != null) + ? new Token(token, user, shareRoot.id, true, true) + : new Token(token, user, null, false, false) + ); return token; } public static @Nullable Token getToken(@NonNull String token) { - if (token.length() == TOKEN_LENGTH) { - Token found = TOKENS.get(token); - if (found == null) return null; - if (found.expired()) { - TOKENS.remove(token); - return null; + return switch (token.length()) { + case TOKEN_LENGTH -> { + Token found = TOKENS.get(token); + if (found == null) yield null; + if (found.expired()) { + TOKENS.remove(token); + yield null; + } + found.refresh(); + yield found; } - found.refresh(); - return found; - } else if (token.length() == SHARE_TOKEN_LENGTH) { - var share = ShareService.getShare(token); - if (share == null) return null; - return new Token(token, share.getLeft(), share.getRight().id); - } else return null; + case SHARE_TOKEN_LENGTH -> { + var share = ShareService.getShare(token); + if (share == null) yield null; + yield new Token(token, share.getLeft(), share.getRight().id, share.getRight().shareWritePw != null, false); + } + default -> null; + }; } public static void logout(@NonNull Token token) { @@ -53,4 +61,12 @@ public class TokenService { return token.getUser().id == id || token.getRealUser().id == id; }); } + + public static void removeShareTokens(long nodeId) { + TOKENS.entrySet().removeIf(entry -> { + Token token = entry.getValue(); + var root = token.getShareRoot(); + return root != null && root == nodeId; + }); + } }