@@ -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 @@
-
-
-
-
- {#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;
+ });
+ }
}