From 6db21313432a7b44f32cabe829f6f15bab5a3152 Mon Sep 17 00:00:00 2001 From: Merul Dhiman Date: Mon, 5 Jan 2026 15:45:11 +0530 Subject: [PATCH 1/8] chore: move file manager --- platforms/esigner/.svelte-kit/ambient.d.ts | 134 ++++- platforms/file-manager-api/package.json | 47 ++ .../src/controllers/AccessController.ts | 215 +++++++ .../src/controllers/AuthController.ts | 133 +++++ .../src/controllers/FileController.ts | 270 +++++++++ .../src/controllers/FolderController.ts | 245 ++++++++ .../src/controllers/TagController.ts | 259 ++++++++ .../src/controllers/UserController.ts | 58 ++ .../src/controllers/WebhookController.ts | 254 ++++++++ .../src/database/data-source.ts | 45 ++ .../src/database/entities/File.ts | 75 +++ .../src/database/entities/FileAccess.ts | 44 ++ .../src/database/entities/Folder.ts | 59 ++ .../src/database/entities/FolderAccess.ts | 44 ++ .../src/database/entities/Group.ts | 82 +++ .../src/database/entities/Message.ts | 41 ++ .../database/entities/SignatureContainer.ts | 54 ++ .../src/database/entities/Tag.ts | 41 ++ .../src/database/entities/User.ts | 47 ++ .../database/entities/UserEVaultMapping.ts | 35 ++ .../migrations/1767604964259-migration.ts | 112 ++++ platforms/file-manager-api/src/index.ts | 132 +++++ .../file-manager-api/src/middleware/auth.ts | 53 ++ .../src/services/AccessService.ts | 248 ++++++++ .../src/services/FileService.ts | 211 +++++++ .../src/services/FolderService.ts | 288 +++++++++ .../src/services/GroupService.ts | 253 ++++++++ .../src/services/MessageService.ts | 135 +++++ .../src/services/NotificationService.ts | 286 +++++++++ .../src/services/PlatformEVaultService.ts | 321 ++++++++++ .../src/services/TagService.ts | 240 ++++++++ .../src/services/UserService.ts | 142 +++++ .../file-manager-api/src/types/express.d.ts | 10 + platforms/file-manager-api/src/utils/jwt.ts | 16 + .../file-manager-api/src/utils/version.ts | 31 + .../web3adapter/mappings/group.mapping.json | 27 + .../web3adapter/mappings/message.mapping.json | 16 + .../web3adapter/mappings/user.mapping.json | 21 + .../src/web3adapter/watchers/subscriber.ts | 270 +++++++++ platforms/file-manager-api/tsconfig.json | 26 + .../file-manager/.svelte-kit/ambient.d.ts | 382 ++++++++++++ .../.svelte-kit/generated/client/app.js | 36 ++ .../.svelte-kit/generated/client/matchers.js | 1 + .../.svelte-kit/generated/client/nodes/0.js | 1 + .../.svelte-kit/generated/client/nodes/1.js | 1 + .../.svelte-kit/generated/client/nodes/2.js | 1 + .../.svelte-kit/generated/client/nodes/3.js | 1 + .../.svelte-kit/generated/client/nodes/4.js | 1 + .../.svelte-kit/generated/client/nodes/5.js | 1 + .../.svelte-kit/generated/client/nodes/6.js | 1 + .../.svelte-kit/generated/root.js | 3 + .../.svelte-kit/generated/root.svelte | 80 +++ .../.svelte-kit/generated/server/internal.js | 53 ++ .../file-manager/.svelte-kit/non-ambient.d.ts | 46 ++ .../file-manager/.svelte-kit/tsconfig.json | 52 ++ .../.svelte-kit/types/route_meta_data.json | 7 + .../.svelte-kit/types/src/routes/$types.d.ts | 24 + .../types/src/routes/(auth)/auth/$types.d.ts | 18 + .../types/src/routes/(protected)/$types.d.ts | 20 + .../src/routes/(protected)/files/$types.d.ts | 18 + .../routes/(protected)/files/[id]/$types.d.ts | 19 + platforms/file-manager/package.json | 46 ++ platforms/file-manager/src/app.css | 7 + platforms/file-manager/src/app.d.ts | 13 + platforms/file-manager/src/app.html | 12 + .../src/lib/components/ToastContainer.svelte | 82 +++ .../lib/components/UserMenuDropdown.svelte | 88 +++ .../file-manager/src/lib/stores/access.ts | 105 ++++ platforms/file-manager/src/lib/stores/auth.ts | 77 +++ .../file-manager/src/lib/stores/files.ts | 97 +++ .../file-manager/src/lib/stores/folders.ts | 90 +++ platforms/file-manager/src/lib/stores/tags.ts | 70 +++ .../file-manager/src/lib/stores/toast.ts | 39 ++ platforms/file-manager/src/lib/utils/axios.ts | 26 + .../src/routes/(auth)/auth/+page.svelte | 203 +++++++ .../src/routes/(protected)/+layout.svelte | 24 + .../src/routes/(protected)/files/+page.svelte | 526 +++++++++++++++++ .../(protected)/files/[id]/+page.svelte | 556 ++++++++++++++++++ .../file-manager/src/routes/+layout.svelte | 33 ++ .../file-manager/src/routes/+page.svelte | 23 + platforms/file-manager/svelte.config.js | 15 + platforms/file-manager/tsconfig.json | 15 + platforms/file-manager/vite.config.ts | 15 + platforms/registry/src/index.ts | 3 +- pnpm-lock.yaml | 164 ++++++ 85 files changed, 8108 insertions(+), 7 deletions(-) create mode 100644 platforms/file-manager-api/package.json create mode 100644 platforms/file-manager-api/src/controllers/AccessController.ts create mode 100644 platforms/file-manager-api/src/controllers/AuthController.ts create mode 100644 platforms/file-manager-api/src/controllers/FileController.ts create mode 100644 platforms/file-manager-api/src/controllers/FolderController.ts create mode 100644 platforms/file-manager-api/src/controllers/TagController.ts create mode 100644 platforms/file-manager-api/src/controllers/UserController.ts create mode 100644 platforms/file-manager-api/src/controllers/WebhookController.ts create mode 100644 platforms/file-manager-api/src/database/data-source.ts create mode 100644 platforms/file-manager-api/src/database/entities/File.ts create mode 100644 platforms/file-manager-api/src/database/entities/FileAccess.ts create mode 100644 platforms/file-manager-api/src/database/entities/Folder.ts create mode 100644 platforms/file-manager-api/src/database/entities/FolderAccess.ts create mode 100644 platforms/file-manager-api/src/database/entities/Group.ts create mode 100644 platforms/file-manager-api/src/database/entities/Message.ts create mode 100644 platforms/file-manager-api/src/database/entities/SignatureContainer.ts create mode 100644 platforms/file-manager-api/src/database/entities/Tag.ts create mode 100644 platforms/file-manager-api/src/database/entities/User.ts create mode 100644 platforms/file-manager-api/src/database/entities/UserEVaultMapping.ts create mode 100644 platforms/file-manager-api/src/database/migrations/1767604964259-migration.ts create mode 100644 platforms/file-manager-api/src/index.ts create mode 100644 platforms/file-manager-api/src/middleware/auth.ts create mode 100644 platforms/file-manager-api/src/services/AccessService.ts create mode 100644 platforms/file-manager-api/src/services/FileService.ts create mode 100644 platforms/file-manager-api/src/services/FolderService.ts create mode 100644 platforms/file-manager-api/src/services/GroupService.ts create mode 100644 platforms/file-manager-api/src/services/MessageService.ts create mode 100644 platforms/file-manager-api/src/services/NotificationService.ts create mode 100644 platforms/file-manager-api/src/services/PlatformEVaultService.ts create mode 100644 platforms/file-manager-api/src/services/TagService.ts create mode 100644 platforms/file-manager-api/src/services/UserService.ts create mode 100644 platforms/file-manager-api/src/types/express.d.ts create mode 100644 platforms/file-manager-api/src/utils/jwt.ts create mode 100644 platforms/file-manager-api/src/utils/version.ts create mode 100644 platforms/file-manager-api/src/web3adapter/mappings/group.mapping.json create mode 100644 platforms/file-manager-api/src/web3adapter/mappings/message.mapping.json create mode 100644 platforms/file-manager-api/src/web3adapter/mappings/user.mapping.json create mode 100644 platforms/file-manager-api/src/web3adapter/watchers/subscriber.ts create mode 100644 platforms/file-manager-api/tsconfig.json create mode 100644 platforms/file-manager/.svelte-kit/ambient.d.ts create mode 100644 platforms/file-manager/.svelte-kit/generated/client/app.js create mode 100644 platforms/file-manager/.svelte-kit/generated/client/matchers.js create mode 100644 platforms/file-manager/.svelte-kit/generated/client/nodes/0.js create mode 100644 platforms/file-manager/.svelte-kit/generated/client/nodes/1.js create mode 100644 platforms/file-manager/.svelte-kit/generated/client/nodes/2.js create mode 100644 platforms/file-manager/.svelte-kit/generated/client/nodes/3.js create mode 100644 platforms/file-manager/.svelte-kit/generated/client/nodes/4.js create mode 100644 platforms/file-manager/.svelte-kit/generated/client/nodes/5.js create mode 100644 platforms/file-manager/.svelte-kit/generated/client/nodes/6.js create mode 100644 platforms/file-manager/.svelte-kit/generated/root.js create mode 100644 platforms/file-manager/.svelte-kit/generated/root.svelte create mode 100644 platforms/file-manager/.svelte-kit/generated/server/internal.js create mode 100644 platforms/file-manager/.svelte-kit/non-ambient.d.ts create mode 100644 platforms/file-manager/.svelte-kit/tsconfig.json create mode 100644 platforms/file-manager/.svelte-kit/types/route_meta_data.json create mode 100644 platforms/file-manager/.svelte-kit/types/src/routes/$types.d.ts create mode 100644 platforms/file-manager/.svelte-kit/types/src/routes/(auth)/auth/$types.d.ts create mode 100644 platforms/file-manager/.svelte-kit/types/src/routes/(protected)/$types.d.ts create mode 100644 platforms/file-manager/.svelte-kit/types/src/routes/(protected)/files/$types.d.ts create mode 100644 platforms/file-manager/.svelte-kit/types/src/routes/(protected)/files/[id]/$types.d.ts create mode 100644 platforms/file-manager/package.json create mode 100644 platforms/file-manager/src/app.css create mode 100644 platforms/file-manager/src/app.d.ts create mode 100644 platforms/file-manager/src/app.html create mode 100644 platforms/file-manager/src/lib/components/ToastContainer.svelte create mode 100644 platforms/file-manager/src/lib/components/UserMenuDropdown.svelte create mode 100644 platforms/file-manager/src/lib/stores/access.ts create mode 100644 platforms/file-manager/src/lib/stores/auth.ts create mode 100644 platforms/file-manager/src/lib/stores/files.ts create mode 100644 platforms/file-manager/src/lib/stores/folders.ts create mode 100644 platforms/file-manager/src/lib/stores/tags.ts create mode 100644 platforms/file-manager/src/lib/stores/toast.ts create mode 100644 platforms/file-manager/src/lib/utils/axios.ts create mode 100644 platforms/file-manager/src/routes/(auth)/auth/+page.svelte create mode 100644 platforms/file-manager/src/routes/(protected)/+layout.svelte create mode 100644 platforms/file-manager/src/routes/(protected)/files/+page.svelte create mode 100644 platforms/file-manager/src/routes/(protected)/files/[id]/+page.svelte create mode 100644 platforms/file-manager/src/routes/+layout.svelte create mode 100644 platforms/file-manager/src/routes/+page.svelte create mode 100644 platforms/file-manager/svelte.config.js create mode 100644 platforms/file-manager/tsconfig.json create mode 100644 platforms/file-manager/vite.config.ts diff --git a/platforms/esigner/.svelte-kit/ambient.d.ts b/platforms/esigner/.svelte-kit/ambient.d.ts index b8199036d..a576735ad 100644 --- a/platforms/esigner/.svelte-kit/ambient.d.ts +++ b/platforms/esigner/.svelte-kit/ambient.d.ts @@ -62,46 +62,106 @@ declare module '$env/static/private' { export const VITE_EREPUTATION_BASE_URL: string; export const ESIGNER_DATABASE_URL: string; export const ESIGNER_MAPPING_DB_PATH: string; + export const FILE_MANAGER_DATABASE_URL: string; + export const FILE_MANAGER_MAPPING_DB_PATH: string; export const LOAD_TEST_USER_COUNT: string; export const SHELL: string; + export const LSCOLORS: string; export const npm_command: string; + export const GHOSTTY_BIN_DIR: string; export const COLORTERM: string; + export const TERM_PROGRAM_VERSION: string; export const npm_config_optional: string; + export const FNM_ARCH: string; + export const TMUX: string; export const npm_config_npm_globalconfig: string; export const NODE: string; + export const JAVA_HOME: string; export const npm_config_verify_deps_before_run: string; export const npm_config__jsr_registry: string; + export const CLOJURE_HOME: string; + export const MEMORY_PRESSURE_WRITE: string; + export const TMUX_PLUGIN_MANAGER_PATH: string; + export const FNM_NODE_DIST_MIRROR: string; export const npm_config_strict_peer_dependencies: string; + export const DESKTOP_SESSION: string; + export const ELECTRON_OZONE_PLATFORM_HINT: string; + export const XCURSOR_SIZE: string; export const npm_config_globalconfig: string; + export const EDITOR: string; + export const XDG_SEAT: string; export const PWD: string; + export const LOGNAME: string; + export const XDG_SESSION_DESKTOP: string; + export const QT_QPA_PLATFORMTHEME: string; + export const XDG_SESSION_TYPE: string; + export const SYSTEMD_EXEC_PID: string; + export const TERMINAL: string; + export const QT_QPA_PLATFORMTHEME_QT6: string; + export const MOTD_SHOWN: string; + export const GDM_LANG: string; + export const GHOSTTY_SHELL_FEATURES: string; export const HOME: string; + export const USERNAME: string; export const LANG: string; + export const FNM_COREPACK_ENABLED: string; + export const LS_COLORS: string; + export const XDG_CURRENT_DESKTOP: string; export const npm_package_version: string; - export const TURBO_IS_TUI: string; + export const MESA_GLSL_CACHE_MAX_SIZE: string; + export const MEMORY_PRESSURE_WATCH: string; + export const STARSHIP_SHELL: string; + export const WAYLAND_DISPLAY: string; export const pnpm_config_verify_deps_before_run: string; + export const NIRI_SOCKET: string; + export const MANAGERPID: string; export const INIT_CWD: string; + export const STARSHIP_SESSION_KEY: string; + export const QT_QPA_PLATFORM: string; export const npm_lifecycle_script: string; - export const TURBO_HASH: string; + export const GHOSTTY_RESOURCES_DIR: string; + export const XDG_SESSION_CLASS: string; + export const ANDROID_HOME: string; export const TERM: string; + export const TERMINFO: string; export const npm_package_name: string; + export const ZSH: string; export const USER: string; export const npm_config_frozen_lockfile: string; + export const NDK_HOME: string; + export const TMUX_PANE: string; export const DISPLAY: string; export const npm_lifecycle_event: string; export const SHLVL: string; + export const PAGER: string; + export const npm_config_manage_package_manager_versions: string; + export const FNM_VERSION_FILE_STRATEGY: string; + export const XDG_VTNR: string; + export const XDG_SESSION_ID: string; + export const MANAGERPIDFDID: string; export const npm_config_user_agent: string; export const PNPM_SCRIPT_SRC_DIR: string; export const npm_execpath: string; export const XDG_RUNTIME_DIR: string; + export const FNM_RESOLVE_ENGINES: string; + export const mesa_glthread: string; export const NODE_PATH: string; + export const DEBUGINFOD_URLS: string; export const npm_package_json: string; + export const XCURSOR_THEME: string; export const PATH: string; export const npm_config_node_gyp: string; + export const GDMSESSION: string; export const DBUS_SESSION_BUS_ADDRESS: string; + export const MAIL: string; export const npm_config_registry: string; + export const MESA_SHADER_CACHE_DIR: string; + export const FNM_DIR: string; + export const FNM_MULTISHELL_PATH: string; export const npm_node_execpath: string; + export const FNM_LOGLEVEL: string; + export const OLDPWD: string; export const TERM_PROGRAM: string; - export const NODE_ENV: string; } /** @@ -129,6 +189,7 @@ declare module '$env/static/public' { export const PUBLIC_APP_STORE_EID_WALLET: string; export const PUBLIC_PLAY_STORE_EID_WALLET: string; export const PUBLIC_ESIGNER_BASE_URL: string; + export const PUBLIC_FILE_MANAGER_BASE_URL: string; } /** @@ -181,46 +242,106 @@ declare module '$env/dynamic/private' { VITE_EREPUTATION_BASE_URL: string; ESIGNER_DATABASE_URL: string; ESIGNER_MAPPING_DB_PATH: string; + FILE_MANAGER_DATABASE_URL: string; + FILE_MANAGER_MAPPING_DB_PATH: string; LOAD_TEST_USER_COUNT: string; SHELL: string; + LSCOLORS: string; npm_command: string; + GHOSTTY_BIN_DIR: string; COLORTERM: string; + TERM_PROGRAM_VERSION: string; npm_config_optional: string; + FNM_ARCH: string; + TMUX: string; npm_config_npm_globalconfig: string; NODE: string; + JAVA_HOME: string; npm_config_verify_deps_before_run: string; npm_config__jsr_registry: string; + CLOJURE_HOME: string; + MEMORY_PRESSURE_WRITE: string; + TMUX_PLUGIN_MANAGER_PATH: string; + FNM_NODE_DIST_MIRROR: string; npm_config_strict_peer_dependencies: string; + DESKTOP_SESSION: string; + ELECTRON_OZONE_PLATFORM_HINT: string; + XCURSOR_SIZE: string; npm_config_globalconfig: string; + EDITOR: string; + XDG_SEAT: string; PWD: string; + LOGNAME: string; + XDG_SESSION_DESKTOP: string; + QT_QPA_PLATFORMTHEME: string; + XDG_SESSION_TYPE: string; + SYSTEMD_EXEC_PID: string; + TERMINAL: string; + QT_QPA_PLATFORMTHEME_QT6: string; + MOTD_SHOWN: string; + GDM_LANG: string; + GHOSTTY_SHELL_FEATURES: string; HOME: string; + USERNAME: string; LANG: string; + FNM_COREPACK_ENABLED: string; + LS_COLORS: string; + XDG_CURRENT_DESKTOP: string; npm_package_version: string; - TURBO_IS_TUI: string; + MESA_GLSL_CACHE_MAX_SIZE: string; + MEMORY_PRESSURE_WATCH: string; + STARSHIP_SHELL: string; + WAYLAND_DISPLAY: string; pnpm_config_verify_deps_before_run: string; + NIRI_SOCKET: string; + MANAGERPID: string; INIT_CWD: string; + STARSHIP_SESSION_KEY: string; + QT_QPA_PLATFORM: string; npm_lifecycle_script: string; - TURBO_HASH: string; + GHOSTTY_RESOURCES_DIR: string; + XDG_SESSION_CLASS: string; + ANDROID_HOME: string; TERM: string; + TERMINFO: string; npm_package_name: string; + ZSH: string; USER: string; npm_config_frozen_lockfile: string; + NDK_HOME: string; + TMUX_PANE: string; DISPLAY: string; npm_lifecycle_event: string; SHLVL: string; + PAGER: string; + npm_config_manage_package_manager_versions: string; + FNM_VERSION_FILE_STRATEGY: string; + XDG_VTNR: string; + XDG_SESSION_ID: string; + MANAGERPIDFDID: string; npm_config_user_agent: string; PNPM_SCRIPT_SRC_DIR: string; npm_execpath: string; XDG_RUNTIME_DIR: string; + FNM_RESOLVE_ENGINES: string; + mesa_glthread: string; NODE_PATH: string; + DEBUGINFOD_URLS: string; npm_package_json: string; + XCURSOR_THEME: string; PATH: string; npm_config_node_gyp: string; + GDMSESSION: string; DBUS_SESSION_BUS_ADDRESS: string; + MAIL: string; npm_config_registry: string; + MESA_SHADER_CACHE_DIR: string; + FNM_DIR: string; + FNM_MULTISHELL_PATH: string; npm_node_execpath: string; + FNM_LOGLEVEL: string; + OLDPWD: string; TERM_PROGRAM: string; - NODE_ENV: string; [key: `PUBLIC_${string}`]: undefined; [key: `${string}`]: string | undefined; } @@ -253,6 +374,7 @@ declare module '$env/dynamic/public' { PUBLIC_APP_STORE_EID_WALLET: string; PUBLIC_PLAY_STORE_EID_WALLET: string; PUBLIC_ESIGNER_BASE_URL: string; + PUBLIC_FILE_MANAGER_BASE_URL: string; [key: `PUBLIC_${string}`]: string | undefined; } } diff --git a/platforms/file-manager-api/package.json b/platforms/file-manager-api/package.json new file mode 100644 index 000000000..8f5c19eda --- /dev/null +++ b/platforms/file-manager-api/package.json @@ -0,0 +1,47 @@ +{ + "name": "file-manager-api", + "version": "1.0.0", + "description": "File Manager Platform API", + "main": "src/index.ts", + "scripts": { + "start": "ts-node src/index.ts", + "dev": "nodemon --exec ts-node src/index.ts", + "build": "tsc && cp -r src/web3adapter/mappings dist/web3adapter/", + "typeorm": "typeorm-ts-node-commonjs", + "migration:generate": "npm run typeorm migration:generate -- -d src/database/data-source.ts", + "migration:run": "npm run typeorm migration:run -- -d src/database/data-source.ts", + "migration:revert": "npm run typeorm migration:revert -- -d src/database/data-source.ts" + }, + "dependencies": { + "axios": "^1.6.7", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "eventsource-polyfill": "^0.9.6", + "express": "^4.18.2", + "graphql-request": "^6.1.0", + "jsonwebtoken": "^9.0.2", + "multer": "^1.4.5-lts.1", + "pg": "^8.11.3", + "reflect-metadata": "^0.2.1", + "typeorm": "^0.3.24", + "uuid": "^9.0.1", + "signature-validator": "workspace:*", + "web3-adapter": "workspace:*" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.5", + "@types/multer": "^1.4.11", + "@types/node": "^20.11.24", + "@types/pg": "^8.11.2", + "@types/uuid": "^9.0.8", + "@typescript-eslint/eslint-plugin": "^7.0.1", + "@typescript-eslint/parser": "^7.0.1", + "eslint": "^8.56.0", + "nodemon": "^3.0.3", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } +} + diff --git a/platforms/file-manager-api/src/controllers/AccessController.ts b/platforms/file-manager-api/src/controllers/AccessController.ts new file mode 100644 index 000000000..187407a68 --- /dev/null +++ b/platforms/file-manager-api/src/controllers/AccessController.ts @@ -0,0 +1,215 @@ +import { Request, Response } from "express"; +import { AccessService } from "../services/AccessService"; + +export class AccessController { + private accessService: AccessService; + + constructor() { + this.accessService = new AccessService(); + } + + grantFileAccess = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const { userId, permission = "view" } = req.body; + + if (!userId) { + return res.status(400).json({ error: "userId is required" }); + } + + const access = await this.accessService.grantFileAccess( + id, + userId, + req.user.id, + permission + ); + + res.status(201).json({ + id: access.id, + fileId: access.fileId, + userId: access.userId, + grantedBy: access.grantedBy, + permission: access.permission, + createdAt: access.createdAt, + }); + } catch (error) { + console.error("Error granting file access:", error); + if (error instanceof Error) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Failed to grant file access" }); + } + }; + + revokeFileAccess = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id, userId } = req.params; + + const revoked = await this.accessService.revokeFileAccess( + id, + userId, + req.user.id + ); + + if (!revoked) { + return res.status(404).json({ error: "Access not found or not authorized" }); + } + + res.json({ message: "File access revoked successfully" }); + } catch (error) { + console.error("Error revoking file access:", error); + if (error instanceof Error) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Failed to revoke file access" }); + } + }; + + getFileAccess = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const accessList = await this.accessService.getFileAccess(id, req.user.id); + + res.json(accessList.map(access => ({ + id: access.id, + fileId: access.fileId, + userId: access.userId, + user: access.user ? { + id: access.user.id, + name: access.user.name, + ename: access.user.ename, + avatarUrl: access.user.avatarUrl, + } : null, + grantedBy: access.grantedBy, + granter: access.granter ? { + id: access.granter.id, + name: access.granter.name, + ename: access.granter.ename, + } : null, + permission: access.permission, + createdAt: access.createdAt, + }))); + } catch (error) { + console.error("Error getting file access:", error); + if (error instanceof Error) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Failed to get file access" }); + } + }; + + grantFolderAccess = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const { userId, permission = "view" } = req.body; + + if (!userId) { + return res.status(400).json({ error: "userId is required" }); + } + + const access = await this.accessService.grantFolderAccess( + id, + userId, + req.user.id, + permission + ); + + res.status(201).json({ + id: access.id, + folderId: access.folderId, + userId: access.userId, + grantedBy: access.grantedBy, + permission: access.permission, + createdAt: access.createdAt, + }); + } catch (error) { + console.error("Error granting folder access:", error); + if (error instanceof Error) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Failed to grant folder access" }); + } + }; + + revokeFolderAccess = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id, userId } = req.params; + + const revoked = await this.accessService.revokeFolderAccess( + id, + userId, + req.user.id + ); + + if (!revoked) { + return res.status(404).json({ error: "Access not found or not authorized" }); + } + + res.json({ message: "Folder access revoked successfully" }); + } catch (error) { + console.error("Error revoking folder access:", error); + if (error instanceof Error) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Failed to revoke folder access" }); + } + }; + + getFolderAccess = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const accessList = await this.accessService.getFolderAccess(id, req.user.id); + + res.json(accessList.map(access => ({ + id: access.id, + folderId: access.folderId, + userId: access.userId, + user: access.user ? { + id: access.user.id, + name: access.user.name, + ename: access.user.ename, + avatarUrl: access.user.avatarUrl, + } : null, + grantedBy: access.grantedBy, + granter: access.granter ? { + id: access.granter.id, + name: access.granter.name, + ename: access.granter.ename, + } : null, + permission: access.permission, + createdAt: access.createdAt, + }))); + } catch (error) { + console.error("Error getting folder access:", error); + if (error instanceof Error) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Failed to get folder access" }); + } + }; +} + diff --git a/platforms/file-manager-api/src/controllers/AuthController.ts b/platforms/file-manager-api/src/controllers/AuthController.ts new file mode 100644 index 000000000..7e3bba88d --- /dev/null +++ b/platforms/file-manager-api/src/controllers/AuthController.ts @@ -0,0 +1,133 @@ +import { Request, Response } from "express"; +import { v4 as uuidv4 } from "uuid"; +import { UserService } from "../services/UserService"; +import { EventEmitter } from "events"; +import { signToken } from "../utils/jwt"; +import { isVersionValid } from "../utils/version"; +import { verifySignature } from "signature-validator"; + +const MIN_REQUIRED_VERSION = "0.4.0"; + +export class AuthController { + private userService: UserService; + private eventEmitter: EventEmitter; + + constructor() { + this.userService = new UserService(); + this.eventEmitter = new EventEmitter(); + } + + sseStream = async (req: Request, res: Response) => { + const { id } = req.params; + + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Access-Control-Allow-Origin": "*", + }); + + const handler = (data: any) => { + res.write(`data: ${JSON.stringify(data)}\n\n`); + }; + + this.eventEmitter.on(id, handler); + + req.on("close", () => { + this.eventEmitter.off(id, handler); + res.end(); + }); + + req.on("error", (error) => { + console.error("SSE Error:", error); + this.eventEmitter.off(id, handler); + res.end(); + }); + }; + + getOffer = async (req: Request, res: Response) => { + const url = new URL( + "/api/auth", + process.env.PUBLIC_FILE_MANAGER_BASE_URL, + ).toString(); + const session = uuidv4(); + const offer = `w3ds://auth?redirect=${url}&session=${session}&platform=file-manager`; + res.json({ uri: offer }); + }; + + login = async (req: Request, res: Response) => { + try { + const { ename, session, appVersion, signature } = req.body; + + if (!ename) { + return res.status(400).json({ error: "ename is required" }); + } + + if (!session) { + return res.status(400).json({ error: "session is required" }); + } + + if (!signature) { + return res.status(400).json({ error: "signature is required" }); + } + + if (!appVersion || !isVersionValid(appVersion, MIN_REQUIRED_VERSION)) { + const errorMessage = { + error: true, + message: `Your eID Wallet app version is outdated. Please update to version ${MIN_REQUIRED_VERSION} or later.`, + type: "version_mismatch" + }; + this.eventEmitter.emit(session, errorMessage); + return res.status(400).json({ + error: "App version too old", + message: errorMessage.message + }); + } + + const registryBaseUrl = process.env.PUBLIC_REGISTRY_URL; + if (!registryBaseUrl) { + console.error("PUBLIC_REGISTRY_URL not configured"); + return res.status(500).json({ error: "Server configuration error" }); + } + + const verificationResult = await verifySignature({ + eName: ename, + signature: signature, + payload: session, + registryBaseUrl: registryBaseUrl, + }); + + if (!verificationResult.valid) { + console.error("Signature validation failed:", verificationResult.error); + return res.status(401).json({ + error: "Invalid signature", + message: verificationResult.error + }); + } + + let user = await this.userService.findByEname(ename); + + if (!user) { + throw new Error("User not found"); + } + + const token = signToken({ userId: user.id }); + + const data = { + user: { + id: user.id, + ename: user.ename, + isVerified: user.isVerified, + isPrivate: user.isPrivate, + }, + token, + }; + this.eventEmitter.emit(session, data); + res.status(200).json(data); + } catch (error) { + console.error("Error during login:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; +} + diff --git a/platforms/file-manager-api/src/controllers/FileController.ts b/platforms/file-manager-api/src/controllers/FileController.ts new file mode 100644 index 000000000..11719845f --- /dev/null +++ b/platforms/file-manager-api/src/controllers/FileController.ts @@ -0,0 +1,270 @@ +import { Request, Response } from "express"; +import { FileService } from "../services/FileService"; +import multer from "multer"; + +const upload = multer({ + limits: { fileSize: 100 * 1024 * 1024 }, // 100MB limit + storage: multer.memoryStorage(), +}); + +export class FileController { + private fileService: FileService; + + constructor() { + this.fileService = new FileService(); + } + + uploadFile = [ + upload.single('file'), + async (req: Request, res: Response) => { + try { + if (!req.file) { + return res.status(400).json({ error: "No file provided" }); + } + + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { displayName, description, folderId } = req.body; + + const file = await this.fileService.createFile( + req.file.originalname, + req.file.mimetype, + req.file.size, + req.file.buffer, + req.user.id, + folderId || null, + displayName, + description + ); + + res.status(201).json({ + id: file.id, + name: file.name, + displayName: file.displayName, + description: file.description, + mimeType: file.mimeType, + size: file.size, + md5Hash: file.md5Hash, + folderId: file.folderId, + createdAt: file.createdAt, + }); + } catch (error) { + console.error("Error uploading file:", error); + if (error instanceof Error) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Failed to upload file" }); + } + } + ]; + + getFiles = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { folderId } = req.query; + const folderIdParam = folderId === 'null' || folderId === '' ? null : folderId as string | undefined; + + const files = await this.fileService.getUserFiles(req.user.id, folderIdParam); + res.json(files.map(file => ({ + id: file.id, + name: file.name, + displayName: file.displayName, + description: file.description, + mimeType: file.mimeType, + size: file.size, + md5Hash: file.md5Hash, + ownerId: file.ownerId, + folderId: file.folderId, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + canPreview: this.fileService.canPreview(file.mimeType), + }))); + } catch (error) { + console.error("Error getting files:", error); + res.status(500).json({ error: "Failed to get files" }); + } + }; + + getFile = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const file = await this.fileService.getFileById(id, req.user.id); + + if (!file) { + return res.status(404).json({ error: "File not found" }); + } + + res.json({ + id: file.id, + name: file.name, + displayName: file.displayName, + description: file.description, + mimeType: file.mimeType, + size: file.size, + md5Hash: file.md5Hash, + ownerId: file.ownerId, + folderId: file.folderId, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + canPreview: this.fileService.canPreview(file.mimeType), + tags: file.tags || [], + }); + } catch (error) { + console.error("Error getting file:", error); + res.status(500).json({ error: "Failed to get file" }); + } + }; + + updateFile = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const { displayName, description, folderId } = req.body; + + const file = await this.fileService.updateFile( + id, + req.user.id, + displayName, + description, + folderId !== undefined ? (folderId === 'null' || folderId === '' ? null : folderId) : undefined + ); + + if (!file) { + return res.status(404).json({ error: "File not found or not authorized" }); + } + + res.json({ + id: file.id, + name: file.name, + displayName: file.displayName, + description: file.description, + mimeType: file.mimeType, + size: file.size, + md5Hash: file.md5Hash, + ownerId: file.ownerId, + folderId: file.folderId, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + }); + } catch (error) { + console.error("Error updating file:", error); + if (error instanceof Error) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Failed to update file" }); + } + }; + + downloadFile = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const file = await this.fileService.getFileById(id, req.user.id); + + if (!file) { + return res.status(404).json({ error: "File not found" }); + } + + res.setHeader('Content-Type', file.mimeType); + res.setHeader('Content-Disposition', `attachment; filename="${file.name}"`); + res.setHeader('Content-Length', file.size.toString()); + res.send(file.data); + } catch (error) { + console.error("Error downloading file:", error); + res.status(500).json({ error: "Failed to download file" }); + } + }; + + previewFile = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const file = await this.fileService.getFileById(id, req.user.id); + + if (!file) { + return res.status(404).json({ error: "File not found" }); + } + + if (!this.fileService.canPreview(file.mimeType)) { + return res.status(400).json({ error: "File type cannot be previewed" }); + } + + res.setHeader('Content-Type', file.mimeType); + res.setHeader('Content-Disposition', `inline; filename="${file.name}"`); + res.setHeader('Content-Length', file.size.toString()); + res.send(file.data); + } catch (error) { + console.error("Error previewing file:", error); + res.status(500).json({ error: "Failed to preview file" }); + } + }; + + deleteFile = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const deleted = await this.fileService.deleteFile(id, req.user.id); + + if (!deleted) { + return res.status(404).json({ error: "File not found or not authorized" }); + } + + res.json({ message: "File deleted successfully" }); + } catch (error) { + console.error("Error deleting file:", error); + res.status(500).json({ error: "Failed to delete file" }); + } + }; + + moveFile = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const { folderId } = req.body; + const folderIdParam = folderId === 'null' || folderId === '' ? null : folderId as string | null; + + const file = await this.fileService.moveFile(id, folderIdParam, req.user.id); + + if (!file) { + return res.status(404).json({ error: "File not found or not authorized" }); + } + + res.json({ + id: file.id, + name: file.name, + folderId: file.folderId, + }); + } catch (error) { + console.error("Error moving file:", error); + if (error instanceof Error) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Failed to move file" }); + } + }; +} + diff --git a/platforms/file-manager-api/src/controllers/FolderController.ts b/platforms/file-manager-api/src/controllers/FolderController.ts new file mode 100644 index 000000000..179055994 --- /dev/null +++ b/platforms/file-manager-api/src/controllers/FolderController.ts @@ -0,0 +1,245 @@ +import { Request, Response } from "express"; +import { FolderService } from "../services/FolderService"; + +export class FolderController { + private folderService: FolderService; + + constructor() { + this.folderService = new FolderService(); + } + + createFolder = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { name, parentFolderId } = req.body; + + if (!name) { + return res.status(400).json({ error: "Folder name is required" }); + } + + const folder = await this.folderService.createFolder( + name, + req.user.id, + parentFolderId === 'null' || parentFolderId === '' ? null : parentFolderId + ); + + res.status(201).json({ + id: folder.id, + name: folder.name, + ownerId: folder.ownerId, + parentFolderId: folder.parentFolderId, + createdAt: folder.createdAt, + updatedAt: folder.updatedAt, + }); + } catch (error) { + console.error("Error creating folder:", error); + if (error instanceof Error) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Failed to create folder" }); + } + }; + + getFolders = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { parentFolderId } = req.query; + const parentFolderIdParam = parentFolderId === 'null' || parentFolderId === '' ? null : parentFolderId as string | undefined; + + const folders = await this.folderService.getUserFolders(req.user.id, parentFolderIdParam); + res.json(folders.map(folder => ({ + id: folder.id, + name: folder.name, + ownerId: folder.ownerId, + parentFolderId: folder.parentFolderId, + createdAt: folder.createdAt, + updatedAt: folder.updatedAt, + }))); + } catch (error) { + console.error("Error getting folders:", error); + res.status(500).json({ error: "Failed to get folders" }); + } + }; + + getFolder = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const folder = await this.folderService.getFolderById(id, req.user.id); + + if (!folder) { + return res.status(404).json({ error: "Folder not found" }); + } + + res.json({ + id: folder.id, + name: folder.name, + ownerId: folder.ownerId, + parentFolderId: folder.parentFolderId, + createdAt: folder.createdAt, + updatedAt: folder.updatedAt, + }); + } catch (error) { + console.error("Error getting folder:", error); + res.status(500).json({ error: "Failed to get folder" }); + } + }; + + getFolderContents = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const contents = await this.folderService.getFolderContents(id, req.user.id); + + res.json({ + files: contents.files.map(file => ({ + id: file.id, + name: file.name, + displayName: file.displayName, + description: file.description, + mimeType: file.mimeType, + size: file.size, + ownerId: file.ownerId, + folderId: file.folderId, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + })), + folders: contents.folders.map(folder => ({ + id: folder.id, + name: folder.name, + ownerId: folder.ownerId, + parentFolderId: folder.parentFolderId, + createdAt: folder.createdAt, + updatedAt: folder.updatedAt, + })), + }); + } catch (error) { + console.error("Error getting folder contents:", error); + if (error instanceof Error) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Failed to get folder contents" }); + } + }; + + updateFolder = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const { name, parentFolderId } = req.body; + + const folder = await this.folderService.updateFolder( + id, + req.user.id, + name, + parentFolderId !== undefined ? (parentFolderId === 'null' || parentFolderId === '' ? null : parentFolderId) : undefined + ); + + if (!folder) { + return res.status(404).json({ error: "Folder not found or not authorized" }); + } + + res.json({ + id: folder.id, + name: folder.name, + ownerId: folder.ownerId, + parentFolderId: folder.parentFolderId, + createdAt: folder.createdAt, + updatedAt: folder.updatedAt, + }); + } catch (error) { + console.error("Error updating folder:", error); + if (error instanceof Error) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Failed to update folder" }); + } + }; + + deleteFolder = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const deleted = await this.folderService.deleteFolder(id, req.user.id); + + if (!deleted) { + return res.status(404).json({ error: "Folder not found or not authorized" }); + } + + res.json({ message: "Folder deleted successfully" }); + } catch (error) { + console.error("Error deleting folder:", error); + res.status(500).json({ error: "Failed to delete folder" }); + } + }; + + moveFolder = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const { parentFolderId } = req.body; + const parentFolderIdParam = parentFolderId === 'null' || parentFolderId === '' ? null : parentFolderId as string | null; + + const folder = await this.folderService.moveFolder(id, parentFolderIdParam, req.user.id); + + if (!folder) { + return res.status(404).json({ error: "Folder not found or not authorized" }); + } + + res.json({ + id: folder.id, + name: folder.name, + parentFolderId: folder.parentFolderId, + }); + } catch (error) { + console.error("Error moving folder:", error); + if (error instanceof Error) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Failed to move folder" }); + } + }; + + getFolderTree = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const folders = await this.folderService.getFolderTree(req.user.id); + res.json(folders.map(folder => ({ + id: folder.id, + name: folder.name, + ownerId: folder.ownerId, + parentFolderId: folder.parentFolderId, + createdAt: folder.createdAt, + updatedAt: folder.updatedAt, + }))); + } catch (error) { + console.error("Error getting folder tree:", error); + res.status(500).json({ error: "Failed to get folder tree" }); + } + }; +} + diff --git a/platforms/file-manager-api/src/controllers/TagController.ts b/platforms/file-manager-api/src/controllers/TagController.ts new file mode 100644 index 000000000..d580bc298 --- /dev/null +++ b/platforms/file-manager-api/src/controllers/TagController.ts @@ -0,0 +1,259 @@ +import { Request, Response } from "express"; +import { TagService } from "../services/TagService"; + +export class TagController { + private tagService: TagService; + + constructor() { + this.tagService = new TagService(); + } + + createTag = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { name, color } = req.body; + + if (!name) { + return res.status(400).json({ error: "Tag name is required" }); + } + + const tag = await this.tagService.createTag( + name, + req.user.id, + color || null + ); + + res.status(201).json({ + id: tag.id, + name: tag.name, + color: tag.color, + ownerId: tag.ownerId, + createdAt: tag.createdAt, + }); + } catch (error) { + console.error("Error creating tag:", error); + if (error instanceof Error) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Failed to create tag" }); + } + }; + + getTags = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const tags = await this.tagService.getUserTags(req.user.id); + res.json(tags.map(tag => ({ + id: tag.id, + name: tag.name, + color: tag.color, + ownerId: tag.ownerId, + createdAt: tag.createdAt, + }))); + } catch (error) { + console.error("Error getting tags:", error); + res.status(500).json({ error: "Failed to get tags" }); + } + }; + + updateTag = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const { name, color } = req.body; + + const tag = await this.tagService.updateTag( + id, + req.user.id, + name, + color !== undefined ? (color === 'null' || color === '' ? null : color) : undefined + ); + + if (!tag) { + return res.status(404).json({ error: "Tag not found or not authorized" }); + } + + res.json({ + id: tag.id, + name: tag.name, + color: tag.color, + ownerId: tag.ownerId, + createdAt: tag.createdAt, + }); + } catch (error) { + console.error("Error updating tag:", error); + if (error instanceof Error) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Failed to update tag" }); + } + }; + + deleteTag = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const deleted = await this.tagService.deleteTag(id, req.user.id); + + if (!deleted) { + return res.status(404).json({ error: "Tag not found or not authorized" }); + } + + res.json({ message: "Tag deleted successfully" }); + } catch (error) { + console.error("Error deleting tag:", error); + res.status(500).json({ error: "Failed to delete tag" }); + } + }; + + addTagToFile = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const { tagId } = req.body; + + if (!tagId) { + return res.status(400).json({ error: "tagId is required" }); + } + + const file = await this.tagService.addTagToFile(id, tagId, req.user.id); + + if (!file) { + return res.status(404).json({ error: "File not found or access denied" }); + } + + res.json({ + id: file.id, + name: file.name, + tags: file.tags?.map(tag => ({ + id: tag.id, + name: tag.name, + color: tag.color, + })) || [], + }); + } catch (error) { + console.error("Error adding tag to file:", error); + if (error instanceof Error) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Failed to add tag to file" }); + } + }; + + removeTagFromFile = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id, tagId } = req.params; + + const file = await this.tagService.removeTagFromFile(id, tagId, req.user.id); + + if (!file) { + return res.status(404).json({ error: "File not found or access denied" }); + } + + res.json({ + id: file.id, + name: file.name, + tags: file.tags?.map(tag => ({ + id: tag.id, + name: tag.name, + color: tag.color, + })) || [], + }); + } catch (error) { + console.error("Error removing tag from file:", error); + if (error instanceof Error) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Failed to remove tag from file" }); + } + }; + + addTagToFolder = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const { tagId } = req.body; + + if (!tagId) { + return res.status(400).json({ error: "tagId is required" }); + } + + const folder = await this.tagService.addTagToFolder(id, tagId, req.user.id); + + if (!folder) { + return res.status(404).json({ error: "Folder not found or access denied" }); + } + + res.json({ + id: folder.id, + name: folder.name, + tags: folder.tags?.map(tag => ({ + id: tag.id, + name: tag.name, + color: tag.color, + })) || [], + }); + } catch (error) { + console.error("Error adding tag to folder:", error); + if (error instanceof Error) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Failed to add tag to folder" }); + } + }; + + removeTagFromFolder = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id, tagId } = req.params; + + const folder = await this.tagService.removeTagFromFolder(id, tagId, req.user.id); + + if (!folder) { + return res.status(404).json({ error: "Folder not found or access denied" }); + } + + res.json({ + id: folder.id, + name: folder.name, + tags: folder.tags?.map(tag => ({ + id: tag.id, + name: tag.name, + color: tag.color, + })) || [], + }); + } catch (error) { + console.error("Error removing tag from folder:", error); + if (error instanceof Error) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Failed to remove tag from folder" }); + } + }; +} + diff --git a/platforms/file-manager-api/src/controllers/UserController.ts b/platforms/file-manager-api/src/controllers/UserController.ts new file mode 100644 index 000000000..4c6fa21e2 --- /dev/null +++ b/platforms/file-manager-api/src/controllers/UserController.ts @@ -0,0 +1,58 @@ +import { Request, Response } from "express"; +import { UserService } from "../services/UserService"; + +export class UserController { + private userService: UserService; + + constructor() { + this.userService = new UserService(); + } + + currentUser = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + res.json({ + id: req.user.id, + name: req.user.name, + ename: req.user.ename, + handle: req.user.handle, + avatarUrl: req.user.avatarUrl, + isVerified: req.user.isVerified, + }); + } catch (error) { + console.error("Error getting current user:", error); + res.status(500).json({ error: "Failed to get current user" }); + } + }; + + search = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { query, page = 1, limit = 10 } = req.query; + + if (!query || typeof query !== 'string') { + return res.status(400).json({ error: "Query parameter is required" }); + } + + const users = await this.userService.searchUsers( + query, + Number(page), + Number(limit), + false, + "relevance" + ); + + res.json(users); + } catch (error) { + console.error("Error searching users:", error); + res.status(500).json({ error: "Failed to search users" }); + } + }; +} + diff --git a/platforms/file-manager-api/src/controllers/WebhookController.ts b/platforms/file-manager-api/src/controllers/WebhookController.ts new file mode 100644 index 000000000..c466fe950 --- /dev/null +++ b/platforms/file-manager-api/src/controllers/WebhookController.ts @@ -0,0 +1,254 @@ +import { Request, Response } from "express"; +import { UserService } from "../services/UserService"; +import { GroupService } from "../services/GroupService"; +import { MessageService } from "../services/MessageService"; +import { Web3Adapter } from "web3-adapter"; +import { User } from "../database/entities/User"; +import { Group } from "../database/entities/Group"; +import { Message } from "../database/entities/Message"; +import axios from "axios"; + +export class WebhookController { + userService: UserService; + groupService: GroupService; + messageService: MessageService; + adapter: Web3Adapter; + + constructor(adapter: Web3Adapter) { + this.userService = new UserService(); + this.groupService = new GroupService(); + this.messageService = new MessageService(); + this.adapter = adapter; + } + + handleWebhook = async (req: Request, res: Response) => { + try { + if (process.env.ANCHR_URL) { + axios.post( + new URL("file-manager", process.env.ANCHR_URL).toString(), + req.body + ); + } + const schemaId = req.body.schemaId; + const globalId = req.body.id; + const mapping = Object.values(this.adapter.mapping).find( + (m) => m.schemaId === schemaId + ); + this.adapter.addToLockedIds(globalId); + + if (!mapping) { + return res.status(400).json({ error: "Unknown schema" }); + } + + const local = await this.adapter.fromGlobal({ + data: req.body.data, + mapping, + }); + + let localId = await this.adapter.mappingDb.getLocalId(globalId); + + if (mapping.tableName === "users") { + if (localId) { + const user = await this.userService.findById(localId); + if (!user) throw new Error("User not found"); + + for (const key of Object.keys(local.data)) { + // @ts-ignore + user[key] = local.data[key] ?? user[key]; + } + user.name = req.body.data.displayName; + await this.userService.userRepository.save(user); + await this.adapter.mappingDb.storeMapping({ + localId: user.id, + globalId: req.body.id, + }); + this.adapter.addToLockedIds(user.id); + this.adapter.addToLockedIds(globalId); + } else { + const { user } = await this.userService.findOrCreateUser( + req.body.w3id + ); + for (const key of Object.keys(local.data)) { + // @ts-ignore + user[key] = local.data[key]; + } + user.name = req.body.data.displayName; + await this.userService.userRepository.save(user); + await this.adapter.mappingDb.storeMapping({ + localId: user.id, + globalId: req.body.id, + }); + this.adapter.addToLockedIds(user.id); + this.adapter.addToLockedIds(globalId); + } + } else if (mapping.tableName === "groups") { + let participants: User[] = []; + if ( + local.data.participants && + Array.isArray(local.data.participants) + ) { + const participantPromises = local.data.participants.map( + async (ref: string) => { + if (ref && typeof ref === "string") { + const userId = ref.split("(")[1].split(")")[0]; + return await this.userService.getUserById(userId); + } + return null; + } + ); + + participants = ( + await Promise.all(participantPromises) + ).filter((user: User | null): user is User => user !== null); + } + + let adminIds = local?.data?.admins as string[] ?? [] + adminIds = adminIds.map((a) => a.includes("(") ? a.split("(")[1].split(")")[0]: a) + + if (localId) { + const group = await this.groupService.getGroupById(localId); + if (!group) { + return res.status(500).send(); + } + + group.name = local.data.name as string; + group.description = local.data.description as string; + group.owner = local.data.owner as string; + group.admins = adminIds.map(id => ({ id } as User)); + group.participants = participants; + group.charter = local.data.charter as string; + group.ename = local.data.ename as string; + if (local.data.originalMatchParticipants) { + group.originalMatchParticipants = local.data.originalMatchParticipants as string[]; + } + + this.adapter.addToLockedIds(localId); + await this.groupService.groupRepository.save(group); + localId = group.id; + } else { + // Check if a group with the same name and description already exists + // This prevents duplicate group creation from junction table webhooks + const existingGroup = await this.groupService.groupRepository.findOne({ + where: { + name: local.data.name as string, + description: local.data.description as string + } + }); + + if (existingGroup) { + this.adapter.addToLockedIds(existingGroup.id); + await this.adapter.mappingDb.storeMapping({ + localId: existingGroup.id, + globalId: req.body.id, + }); + localId = existingGroup.id; + } else { + const group = await this.groupService.createGroup( + local.data.name as string, + local.data.description as string, + local.data.owner as string, + adminIds, + participants.map(p => p.id), + local.data.charter as string | undefined, + local.data.isPrivate as boolean | undefined, + local.data.visibility as "public" | "private" | "restricted" | undefined, + local.data.avatarUrl as string | undefined, + local.data.bannerUrl as string | undefined, + local.data.originalMatchParticipants as string[] | undefined, + ); + this.adapter.addToLockedIds(group.id); + await this.adapter.mappingDb.storeMapping({ + localId: group.id, + globalId: req.body.id, + }); + localId = group.id; + } + } + } else if (mapping.tableName === "messages") { + console.log("Processing message with data:", local.data); + + // Extract sender and group from the message data + let sender: User | null = null; + let group: Group | null = null; + + if (local.data.sender && typeof local.data.sender === "string") { + const senderId = local.data.sender.split("(")[1].split(")")[0]; + sender = await this.userService.getUserById(senderId); + } + + if (local.data.group && typeof local.data.group === "string") { + const groupId = local.data.group.split("(")[1].split(")")[0]; + group = await this.groupService.getGroupById(groupId); + } + + // Check if this is a system message (no sender required) + const isSystemMessage = local.data.isSystemMessage === true || + (local.data.text && typeof local.data.text === 'string' && local.data.text.startsWith('$$system-message$$')); + + if (!group) { + console.error("Group not found for message"); + return res.status(500).send(); + } + + // For system messages, sender can be null + if (!isSystemMessage && !sender) { + console.error("Sender not found for non-system message"); + return res.status(500).send(); + } + + if (localId) { + console.log("Updating existing message with localId:", localId); + const message = await this.messageService.getMessageById(localId); + if (!message) { + console.error("Message not found for localId:", localId); + return res.status(500).send(); + } + + // For system messages, ensure the prefix is preserved + if (isSystemMessage && !(local.data.text as string).startsWith('$$system-message$$')) { + message.text = `$$system-message$$ ${local.data.text as string}`; + } else { + message.text = local.data.text as string; + } + message.sender = sender || undefined; + message.group = group; + message.isSystemMessage = isSystemMessage as boolean; + + this.adapter.addToLockedIds(localId); + await this.messageService.messageRepository.save(message); + console.log("Updated message:", message.id); + } else { + console.log("Creating new message"); + let message: Message; + + if (isSystemMessage) { + message = await this.messageService.createSystemMessageWithoutPrefix({ + text: local.data.text as string, + groupId: group.id, + }); + } else { + message = await this.messageService.createMessage({ + text: local.data.text as string, + senderId: sender!.id, // We know sender exists for non-system messages + groupId: group.id, + }); + } + + console.log("Created message with ID:", message.id); + this.adapter.addToLockedIds(message.id); + await this.adapter.mappingDb.storeMapping({ + localId: message.id, + globalId: req.body.id, + }); + console.log("Stored mapping for message:", message.id, "->", req.body.id); + } + } + + res.status(200).json({ success: true }); + } catch (error) { + console.error("Error handling webhook:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; +} + diff --git a/platforms/file-manager-api/src/database/data-source.ts b/platforms/file-manager-api/src/database/data-source.ts new file mode 100644 index 000000000..7546efcb6 --- /dev/null +++ b/platforms/file-manager-api/src/database/data-source.ts @@ -0,0 +1,45 @@ +import "reflect-metadata"; +import { DataSource } from "typeorm"; +import { config } from "dotenv"; +import { User } from "./entities/User"; +import { Group } from "./entities/Group"; +import { File } from "./entities/File"; +import { Folder } from "./entities/Folder"; +import { SignatureContainer } from "./entities/SignatureContainer"; +import { Message } from "./entities/Message"; +import { UserEVaultMapping } from "./entities/UserEVaultMapping"; +import { FileAccess } from "./entities/FileAccess"; +import { FolderAccess } from "./entities/FolderAccess"; +import { Tag } from "./entities/Tag"; +import path from "path"; +import { PostgresSubscriber } from "../web3adapter/watchers/subscriber"; + +config({ path: path.resolve(__dirname, "../../../../.env") }); + +export const AppDataSource = new DataSource({ + type: "postgres", + url: process.env.FILE_MANAGER_DATABASE_URL, + synchronize: false, + logging: process.env.NODE_ENV === "development", + entities: [ + User, + Group, + File, + Folder, + SignatureContainer, + Message, + UserEVaultMapping, + FileAccess, + FolderAccess, + Tag, + ], + migrations: [path.join(__dirname, "migrations", "*.ts")], + subscribers: [PostgresSubscriber], + ssl: process.env.DB_CA_CERT + ? { + rejectUnauthorized: false, + ca: process.env.DB_CA_CERT, + } + : false, +}); + diff --git a/platforms/file-manager-api/src/database/entities/File.ts b/platforms/file-manager-api/src/database/entities/File.ts new file mode 100644 index 000000000..b963e0e8c --- /dev/null +++ b/platforms/file-manager-api/src/database/entities/File.ts @@ -0,0 +1,75 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + OneToMany, + ManyToMany, + JoinTable, +} from "typeorm"; +import { User } from "./User"; +import { Folder } from "./Folder"; +import { SignatureContainer } from "./SignatureContainer"; +import { Tag } from "./Tag"; + +@Entity("files") +export class File { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column() + name!: string; // Original file name + + @Column({ type: "varchar", nullable: true }) + displayName!: string | null; // Custom name + + @Column({ type: "text", nullable: true }) + description!: string | null; // Optional description + + @Column() + mimeType!: string; + + @Column("bigint") + size!: number; + + @Column({ type: "text" }) + md5Hash!: string; + + @Column({ type: "bytea" }) + data!: Buffer; + + @Column() + ownerId!: string; + + @Column({ nullable: true }) + folderId!: string | null; + + @ManyToOne(() => User) + @JoinColumn({ name: "ownerId" }) + owner!: User; + + @ManyToOne(() => Folder, (folder) => folder.files, { nullable: true }) + @JoinColumn({ name: "folderId" }) + folder!: Folder | null; + + @OneToMany(() => SignatureContainer, (signature) => signature.file) + signatures!: SignatureContainer[]; + + @ManyToMany(() => Tag, (tag) => tag.files) + @JoinTable({ + name: "file_tags", + joinColumn: { name: "fileId", referencedColumnName: "id" }, + inverseJoinColumn: { name: "tagId", referencedColumnName: "id" } + }) + tags!: Tag[]; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} + diff --git a/platforms/file-manager-api/src/database/entities/FileAccess.ts b/platforms/file-manager-api/src/database/entities/FileAccess.ts new file mode 100644 index 000000000..ed91c501f --- /dev/null +++ b/platforms/file-manager-api/src/database/entities/FileAccess.ts @@ -0,0 +1,44 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from "typeorm"; +import { File } from "./File"; +import { User } from "./User"; + +@Entity("file_access") +export class FileAccess { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column() + fileId!: string; + + @Column() + userId!: string; + + @Column() + grantedBy!: string; + + @Column({ default: "view" }) + permission!: "view"; + + @ManyToOne(() => File) + @JoinColumn({ name: "fileId" }) + file!: File; + + @ManyToOne(() => User) + @JoinColumn({ name: "userId" }) + user!: User; + + @ManyToOne(() => User) + @JoinColumn({ name: "grantedBy" }) + granter!: User; + + @CreateDateColumn() + createdAt!: Date; +} + diff --git a/platforms/file-manager-api/src/database/entities/Folder.ts b/platforms/file-manager-api/src/database/entities/Folder.ts new file mode 100644 index 000000000..19804f736 --- /dev/null +++ b/platforms/file-manager-api/src/database/entities/Folder.ts @@ -0,0 +1,59 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + ManyToMany, + JoinTable, +} from "typeorm"; +import { User } from "./User"; +import { File } from "./File"; +import { Tag } from "./Tag"; + +@Entity("folders") +export class Folder { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column() + name!: string; + + @Column({ nullable: true }) + parentFolderId!: string | null; + + @Column() + ownerId!: string; + + @ManyToOne(() => User) + @JoinColumn({ name: "ownerId" }) + owner!: User; + + @ManyToOne(() => Folder, (folder) => folder.children, { nullable: true }) + @JoinColumn({ name: "parentFolderId" }) + parent!: Folder | null; + + @OneToMany(() => Folder, (folder) => folder.parent) + children!: Folder[]; + + @OneToMany(() => File, (file) => file.folder) + files!: File[]; + + @ManyToMany(() => Tag, (tag) => tag.folders) + @JoinTable({ + name: "folder_tags", + joinColumn: { name: "folderId", referencedColumnName: "id" }, + inverseJoinColumn: { name: "tagId", referencedColumnName: "id" } + }) + tags!: Tag[]; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} + diff --git a/platforms/file-manager-api/src/database/entities/FolderAccess.ts b/platforms/file-manager-api/src/database/entities/FolderAccess.ts new file mode 100644 index 000000000..515c157af --- /dev/null +++ b/platforms/file-manager-api/src/database/entities/FolderAccess.ts @@ -0,0 +1,44 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from "typeorm"; +import { Folder } from "./Folder"; +import { User } from "./User"; + +@Entity("folder_access") +export class FolderAccess { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column() + folderId!: string; + + @Column() + userId!: string; + + @Column() + grantedBy!: string; + + @Column({ default: "view" }) + permission!: "view"; + + @ManyToOne(() => Folder) + @JoinColumn({ name: "folderId" }) + folder!: Folder; + + @ManyToOne(() => User) + @JoinColumn({ name: "userId" }) + user!: User; + + @ManyToOne(() => User) + @JoinColumn({ name: "grantedBy" }) + granter!: User; + + @CreateDateColumn() + createdAt!: Date; +} + diff --git a/platforms/file-manager-api/src/database/entities/Group.ts b/platforms/file-manager-api/src/database/entities/Group.ts new file mode 100644 index 000000000..36e859ce3 --- /dev/null +++ b/platforms/file-manager-api/src/database/entities/Group.ts @@ -0,0 +1,82 @@ +import { + Entity, + CreateDateColumn, + UpdateDateColumn, + PrimaryGeneratedColumn, + Column, + ManyToMany, + OneToMany, + JoinTable, +} from "typeorm"; +import { User } from "./User"; +import { Message } from "./Message"; + +@Entity() +export class Group { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ nullable: true }) + name!: string; + + @Column({ nullable: true }) + description!: string; + + @Column({ nullable: true }) + owner!: string; + + @Column({ type: "text", nullable: true }) + charter!: string; + + @Column({ default: false }) + isPrivate!: boolean; + + @Column({ default: "public" }) + visibility!: "public" | "private" | "restricted"; + + @ManyToMany(() => User) + @JoinTable({ + name: "group_members", + joinColumn: { name: "group_id", referencedColumnName: "id" }, + inverseJoinColumn: { name: "user_id", referencedColumnName: "id" } + }) + members!: User[]; + + @ManyToMany(() => User) + @JoinTable({ + name: "group_admins", + joinColumn: { name: "group_id", referencedColumnName: "id" }, + inverseJoinColumn: { name: "user_id", referencedColumnName: "id" } + }) + admins!: User[]; + + @ManyToMany(() => User) + @JoinTable({ + name: "group_participants", + joinColumn: { name: "group_id", referencedColumnName: "id" }, + inverseJoinColumn: { name: "user_id", referencedColumnName: "id" } + }) + participants!: User[]; + + @Column({ nullable: true }) + ename!: string; + + @Column({ nullable: true }) + avatarUrl!: string; + + @Column({ nullable: true }) + bannerUrl!: string; + + @Column({ type: "json", nullable: true }) + originalMatchParticipants!: string[]; // Store user IDs from the original match + + @OneToMany(() => Message, (message) => message.group) + messages!: Message[]; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} + diff --git a/platforms/file-manager-api/src/database/entities/Message.ts b/platforms/file-manager-api/src/database/entities/Message.ts new file mode 100644 index 000000000..18793ae44 --- /dev/null +++ b/platforms/file-manager-api/src/database/entities/Message.ts @@ -0,0 +1,41 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, +} from "typeorm"; +import { User } from "./User"; +import { Group } from "./Group"; + +@Entity("messages") +export class Message { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @ManyToOne(() => User, { nullable: true }) + sender?: User; // Nullable for system messages + + @Column("text") + text!: string; + + @ManyToOne(() => Group, (group) => group.messages) + group!: Group; + + @Column({ default: false }) + isSystemMessage!: boolean; // Flag to identify system messages + + @Column("uuid", { nullable: true }) + voteId?: string; // ID of the vote/poll this system message relates to + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; + + @Column({ default: false }) + isArchived!: boolean; +} + diff --git a/platforms/file-manager-api/src/database/entities/SignatureContainer.ts b/platforms/file-manager-api/src/database/entities/SignatureContainer.ts new file mode 100644 index 000000000..a79434976 --- /dev/null +++ b/platforms/file-manager-api/src/database/entities/SignatureContainer.ts @@ -0,0 +1,54 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToOne, + JoinColumn, +} from "typeorm"; +import { File } from "./File"; +import { User } from "./User"; + +@Entity("signature_containers") +export class SignatureContainer { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column() + fileId!: string; + + @Column() + userId!: string; + + @Column({ nullable: true }) + fileSigneeId!: string; + + @Column({ type: "text" }) + md5Hash!: string; + + @Column({ type: "text" }) + signature!: string; + + @Column({ type: "text" }) + publicKey!: string; + + @Column({ type: "text" }) + message!: string; + + @ManyToOne(() => File) + @JoinColumn({ name: "fileId" }) + file!: File; + + @ManyToOne(() => User) + @JoinColumn({ name: "userId" }) + user!: User; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} + diff --git a/platforms/file-manager-api/src/database/entities/Tag.ts b/platforms/file-manager-api/src/database/entities/Tag.ts new file mode 100644 index 000000000..24149a053 --- /dev/null +++ b/platforms/file-manager-api/src/database/entities/Tag.ts @@ -0,0 +1,41 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + ManyToMany, +} from "typeorm"; +import { User } from "./User"; +import { File } from "./File"; +import { Folder } from "./Folder"; + +@Entity("tags") +export class Tag { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column() + name!: string; + + @Column({ type: "varchar", nullable: true }) + color!: string | null; + + @Column() + ownerId!: string; + + @ManyToOne(() => User) + @JoinColumn({ name: "ownerId" }) + owner!: User; + + @ManyToMany(() => File, (file) => file.tags) + files!: File[]; + + @ManyToMany(() => Folder, (folder) => folder.tags) + folders!: Folder[]; + + @CreateDateColumn() + createdAt!: Date; +} + diff --git a/platforms/file-manager-api/src/database/entities/User.ts b/platforms/file-manager-api/src/database/entities/User.ts new file mode 100644 index 000000000..2eb71a6d8 --- /dev/null +++ b/platforms/file-manager-api/src/database/entities/User.ts @@ -0,0 +1,47 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm"; + +@Entity("users") +export class User { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ nullable: true }) + handle!: string; + + @Column({ nullable: true }) + name!: string; + + @Column({ nullable: true }) + description!: string; + + @Column({ nullable: true }) + avatarUrl!: string; + + @Column({ nullable: true }) + bannerUrl!: string; + + @Column({ nullable: true }) + ename!: string; + + @Column({ default: false }) + isVerified!: boolean; + + @Column({ default: false }) + isPrivate!: boolean; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; + + @Column({ default: false }) + isArchived!: boolean; +} + diff --git a/platforms/file-manager-api/src/database/entities/UserEVaultMapping.ts b/platforms/file-manager-api/src/database/entities/UserEVaultMapping.ts new file mode 100644 index 000000000..4b1ccf983 --- /dev/null +++ b/platforms/file-manager-api/src/database/entities/UserEVaultMapping.ts @@ -0,0 +1,35 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm"; + +@Entity("user_evault_mappings") +export class UserEVaultMapping { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column() + localUserId!: string; + + @Column() + evaultW3id!: string; + + @Column() + evaultUri!: string; + + @Column({ nullable: true }) + userProfileId!: string; // ID of the UserProfile object in the eVault + + @Column({ type: "jsonb", nullable: true }) + userProfileData!: any; // Store the UserProfile data + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} + diff --git a/platforms/file-manager-api/src/database/migrations/1767604964259-migration.ts b/platforms/file-manager-api/src/database/migrations/1767604964259-migration.ts new file mode 100644 index 000000000..ae7695617 --- /dev/null +++ b/platforms/file-manager-api/src/database/migrations/1767604964259-migration.ts @@ -0,0 +1,112 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Migration1767604964259 implements MigrationInterface { + name = 'Migration1767604964259' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "handle" character varying, "name" character varying, "description" character varying, "avatarUrl" character varying, "bannerUrl" character varying, "ename" character varying, "isVerified" boolean NOT NULL DEFAULT false, "isPrivate" boolean NOT NULL DEFAULT false, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "isArchived" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "messages" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "text" text NOT NULL, "isSystemMessage" boolean NOT NULL DEFAULT false, "voteId" uuid, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "isArchived" boolean NOT NULL DEFAULT false, "senderId" uuid, "groupId" uuid, CONSTRAINT "PK_18325f38ae6de43878487eff986" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "group" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying, "description" character varying, "owner" character varying, "charter" text, "isPrivate" boolean NOT NULL DEFAULT false, "visibility" character varying NOT NULL DEFAULT 'public', "ename" character varying, "avatarUrl" character varying, "bannerUrl" character varying, "originalMatchParticipants" json, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_256aa0fda9b1de1a73ee0b7106b" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "tags" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "color" character varying, "ownerId" uuid NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_e7dc17249a1148a1970748eda99" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "folders" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "parentFolderId" uuid, "ownerId" uuid NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_8578bd31b0e7f6d6c2480dbbca8" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "signature_containers" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "fileId" uuid NOT NULL, "userId" uuid NOT NULL, "fileSigneeId" character varying, "md5Hash" text NOT NULL, "signature" text NOT NULL, "publicKey" text NOT NULL, "message" text NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_11d098c75e494a23c73f3514328" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "files" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "displayName" character varying, "description" text, "mimeType" character varying NOT NULL, "size" bigint NOT NULL, "md5Hash" text NOT NULL, "data" bytea NOT NULL, "ownerId" uuid NOT NULL, "folderId" uuid, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_6c16b9093a142e0e7613b04a3d9" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "user_evault_mappings" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "localUserId" character varying NOT NULL, "evaultW3id" character varying NOT NULL, "evaultUri" character varying NOT NULL, "userProfileId" character varying, "userProfileData" jsonb, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_744ddb4ddca6af2de54773e9213" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "file_access" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "fileId" uuid NOT NULL, "userId" uuid NOT NULL, "grantedBy" uuid NOT NULL, "permission" character varying NOT NULL DEFAULT 'view', "createdAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_4f91934346cfc7e72466111a1bf" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "folder_access" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "folderId" uuid NOT NULL, "userId" uuid NOT NULL, "grantedBy" uuid NOT NULL, "permission" character varying NOT NULL DEFAULT 'view', "createdAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_900ccfc7f33cfeee529ba9d53af" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "group_members" ("group_id" uuid NOT NULL, "user_id" uuid NOT NULL, CONSTRAINT "PK_f5939ee0ad233ad35e03f5c65c1" PRIMARY KEY ("group_id", "user_id"))`); + await queryRunner.query(`CREATE INDEX "IDX_2c840df5db52dc6b4a1b0b69c6" ON "group_members" ("group_id") `); + await queryRunner.query(`CREATE INDEX "IDX_20a555b299f75843aa53ff8b0e" ON "group_members" ("user_id") `); + await queryRunner.query(`CREATE TABLE "group_admins" ("group_id" uuid NOT NULL, "user_id" uuid NOT NULL, CONSTRAINT "PK_a63ab4ea34529a63cdd55eed88d" PRIMARY KEY ("group_id", "user_id"))`); + await queryRunner.query(`CREATE INDEX "IDX_0ecd81bfecc31d4f804ece20ef" ON "group_admins" ("group_id") `); + await queryRunner.query(`CREATE INDEX "IDX_29bb650b1c5b1639dfb089f39a" ON "group_admins" ("user_id") `); + await queryRunner.query(`CREATE TABLE "group_participants" ("group_id" uuid NOT NULL, "user_id" uuid NOT NULL, CONSTRAINT "PK_92021b85af6470d6b405e12f312" PRIMARY KEY ("group_id", "user_id"))`); + await queryRunner.query(`CREATE INDEX "IDX_e61f897ae7a7df4b56595adaae" ON "group_participants" ("group_id") `); + await queryRunner.query(`CREATE INDEX "IDX_bb1d0ab0d82e0a62fa55b7e841" ON "group_participants" ("user_id") `); + await queryRunner.query(`CREATE TABLE "folder_tags" ("folderId" uuid NOT NULL, "tagId" uuid NOT NULL, CONSTRAINT "PK_f4915bb4e5d316b9200c78ac8e2" PRIMARY KEY ("folderId", "tagId"))`); + await queryRunner.query(`CREATE INDEX "IDX_b407e9ed1bdb16b03b0a823a08" ON "folder_tags" ("folderId") `); + await queryRunner.query(`CREATE INDEX "IDX_5e9a3bac68bad0de83835df44c" ON "folder_tags" ("tagId") `); + await queryRunner.query(`CREATE TABLE "file_tags" ("fileId" uuid NOT NULL, "tagId" uuid NOT NULL, CONSTRAINT "PK_ecab73fa9f86d642d318f037e45" PRIMARY KEY ("fileId", "tagId"))`); + await queryRunner.query(`CREATE INDEX "IDX_31dc65f6c8eb331dafec56d775" ON "file_tags" ("fileId") `); + await queryRunner.query(`CREATE INDEX "IDX_9055162d8ed81e7ce50c68e837" ON "file_tags" ("tagId") `); + await queryRunner.query(`ALTER TABLE "messages" ADD CONSTRAINT "FK_2db9cf2b3ca111742793f6c37ce" FOREIGN KEY ("senderId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "messages" ADD CONSTRAINT "FK_438f09ab5b4bbcd27683eac2a5e" FOREIGN KEY ("groupId") REFERENCES "group"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "FK_8ce74535e58cbab22452bc758cb" FOREIGN KEY ("ownerId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "folders" ADD CONSTRAINT "FK_6228242ce9f7a8f3aec9397c6a7" FOREIGN KEY ("ownerId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "folders" ADD CONSTRAINT "FK_d33cb81c88bba50eacc6eb26951" FOREIGN KEY ("parentFolderId") REFERENCES "folders"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "signature_containers" ADD CONSTRAINT "FK_9effe78087d666930d4f48d839a" FOREIGN KEY ("fileId") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "signature_containers" ADD CONSTRAINT "FK_7fc1823b42014453d35e7591333" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "files" ADD CONSTRAINT "FK_a23484d1055e34d75b25f616792" FOREIGN KEY ("ownerId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "files" ADD CONSTRAINT "FK_24dfe39188240d442f380dd8c04" FOREIGN KEY ("folderId") REFERENCES "folders"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "file_access" ADD CONSTRAINT "FK_397ca282d50517817262e3427e6" FOREIGN KEY ("fileId") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "file_access" ADD CONSTRAINT "FK_73775d0a6f317e5ca5723fb4d62" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "file_access" ADD CONSTRAINT "FK_722ea5cc318e53ca7a587c56e61" FOREIGN KEY ("grantedBy") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "folder_access" ADD CONSTRAINT "FK_3ec99e38dae197e09f183bfcc71" FOREIGN KEY ("folderId") REFERENCES "folders"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "folder_access" ADD CONSTRAINT "FK_cfe553dda20935a3383ffc8f6f9" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "folder_access" ADD CONSTRAINT "FK_953144dbdd75265c4b1ff1c8607" FOREIGN KEY ("grantedBy") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "group_members" ADD CONSTRAINT "FK_2c840df5db52dc6b4a1b0b69c6e" FOREIGN KEY ("group_id") REFERENCES "group"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "group_members" ADD CONSTRAINT "FK_20a555b299f75843aa53ff8b0ee" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "group_admins" ADD CONSTRAINT "FK_0ecd81bfecc31d4f804ece20efc" FOREIGN KEY ("group_id") REFERENCES "group"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "group_admins" ADD CONSTRAINT "FK_29bb650b1c5b1639dfb089f39a7" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "group_participants" ADD CONSTRAINT "FK_e61f897ae7a7df4b56595adaae7" FOREIGN KEY ("group_id") REFERENCES "group"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "group_participants" ADD CONSTRAINT "FK_bb1d0ab0d82e0a62fa55b7e8411" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "folder_tags" ADD CONSTRAINT "FK_b407e9ed1bdb16b03b0a823a08c" FOREIGN KEY ("folderId") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "folder_tags" ADD CONSTRAINT "FK_5e9a3bac68bad0de83835df44c4" FOREIGN KEY ("tagId") REFERENCES "tags"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "file_tags" ADD CONSTRAINT "FK_31dc65f6c8eb331dafec56d7753" FOREIGN KEY ("fileId") REFERENCES "files"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "file_tags" ADD CONSTRAINT "FK_9055162d8ed81e7ce50c68e837b" FOREIGN KEY ("tagId") REFERENCES "tags"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "file_tags" DROP CONSTRAINT "FK_9055162d8ed81e7ce50c68e837b"`); + await queryRunner.query(`ALTER TABLE "file_tags" DROP CONSTRAINT "FK_31dc65f6c8eb331dafec56d7753"`); + await queryRunner.query(`ALTER TABLE "folder_tags" DROP CONSTRAINT "FK_5e9a3bac68bad0de83835df44c4"`); + await queryRunner.query(`ALTER TABLE "folder_tags" DROP CONSTRAINT "FK_b407e9ed1bdb16b03b0a823a08c"`); + await queryRunner.query(`ALTER TABLE "group_participants" DROP CONSTRAINT "FK_bb1d0ab0d82e0a62fa55b7e8411"`); + await queryRunner.query(`ALTER TABLE "group_participants" DROP CONSTRAINT "FK_e61f897ae7a7df4b56595adaae7"`); + await queryRunner.query(`ALTER TABLE "group_admins" DROP CONSTRAINT "FK_29bb650b1c5b1639dfb089f39a7"`); + await queryRunner.query(`ALTER TABLE "group_admins" DROP CONSTRAINT "FK_0ecd81bfecc31d4f804ece20efc"`); + await queryRunner.query(`ALTER TABLE "group_members" DROP CONSTRAINT "FK_20a555b299f75843aa53ff8b0ee"`); + await queryRunner.query(`ALTER TABLE "group_members" DROP CONSTRAINT "FK_2c840df5db52dc6b4a1b0b69c6e"`); + await queryRunner.query(`ALTER TABLE "folder_access" DROP CONSTRAINT "FK_953144dbdd75265c4b1ff1c8607"`); + await queryRunner.query(`ALTER TABLE "folder_access" DROP CONSTRAINT "FK_cfe553dda20935a3383ffc8f6f9"`); + await queryRunner.query(`ALTER TABLE "folder_access" DROP CONSTRAINT "FK_3ec99e38dae197e09f183bfcc71"`); + await queryRunner.query(`ALTER TABLE "file_access" DROP CONSTRAINT "FK_722ea5cc318e53ca7a587c56e61"`); + await queryRunner.query(`ALTER TABLE "file_access" DROP CONSTRAINT "FK_73775d0a6f317e5ca5723fb4d62"`); + await queryRunner.query(`ALTER TABLE "file_access" DROP CONSTRAINT "FK_397ca282d50517817262e3427e6"`); + await queryRunner.query(`ALTER TABLE "files" DROP CONSTRAINT "FK_24dfe39188240d442f380dd8c04"`); + await queryRunner.query(`ALTER TABLE "files" DROP CONSTRAINT "FK_a23484d1055e34d75b25f616792"`); + await queryRunner.query(`ALTER TABLE "signature_containers" DROP CONSTRAINT "FK_7fc1823b42014453d35e7591333"`); + await queryRunner.query(`ALTER TABLE "signature_containers" DROP CONSTRAINT "FK_9effe78087d666930d4f48d839a"`); + await queryRunner.query(`ALTER TABLE "folders" DROP CONSTRAINT "FK_d33cb81c88bba50eacc6eb26951"`); + await queryRunner.query(`ALTER TABLE "folders" DROP CONSTRAINT "FK_6228242ce9f7a8f3aec9397c6a7"`); + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "FK_8ce74535e58cbab22452bc758cb"`); + await queryRunner.query(`ALTER TABLE "messages" DROP CONSTRAINT "FK_438f09ab5b4bbcd27683eac2a5e"`); + await queryRunner.query(`ALTER TABLE "messages" DROP CONSTRAINT "FK_2db9cf2b3ca111742793f6c37ce"`); + await queryRunner.query(`DROP INDEX "public"."IDX_9055162d8ed81e7ce50c68e837"`); + await queryRunner.query(`DROP INDEX "public"."IDX_31dc65f6c8eb331dafec56d775"`); + await queryRunner.query(`DROP TABLE "file_tags"`); + await queryRunner.query(`DROP INDEX "public"."IDX_5e9a3bac68bad0de83835df44c"`); + await queryRunner.query(`DROP INDEX "public"."IDX_b407e9ed1bdb16b03b0a823a08"`); + await queryRunner.query(`DROP TABLE "folder_tags"`); + await queryRunner.query(`DROP INDEX "public"."IDX_bb1d0ab0d82e0a62fa55b7e841"`); + await queryRunner.query(`DROP INDEX "public"."IDX_e61f897ae7a7df4b56595adaae"`); + await queryRunner.query(`DROP TABLE "group_participants"`); + await queryRunner.query(`DROP INDEX "public"."IDX_29bb650b1c5b1639dfb089f39a"`); + await queryRunner.query(`DROP INDEX "public"."IDX_0ecd81bfecc31d4f804ece20ef"`); + await queryRunner.query(`DROP TABLE "group_admins"`); + await queryRunner.query(`DROP INDEX "public"."IDX_20a555b299f75843aa53ff8b0e"`); + await queryRunner.query(`DROP INDEX "public"."IDX_2c840df5db52dc6b4a1b0b69c6"`); + await queryRunner.query(`DROP TABLE "group_members"`); + await queryRunner.query(`DROP TABLE "folder_access"`); + await queryRunner.query(`DROP TABLE "file_access"`); + await queryRunner.query(`DROP TABLE "user_evault_mappings"`); + await queryRunner.query(`DROP TABLE "files"`); + await queryRunner.query(`DROP TABLE "signature_containers"`); + await queryRunner.query(`DROP TABLE "folders"`); + await queryRunner.query(`DROP TABLE "tags"`); + await queryRunner.query(`DROP TABLE "group"`); + await queryRunner.query(`DROP TABLE "messages"`); + await queryRunner.query(`DROP TABLE "users"`); + } + +} diff --git a/platforms/file-manager-api/src/index.ts b/platforms/file-manager-api/src/index.ts new file mode 100644 index 000000000..0f9bce52b --- /dev/null +++ b/platforms/file-manager-api/src/index.ts @@ -0,0 +1,132 @@ +import "reflect-metadata"; +import express from "express"; +import cors from "cors"; +import { config } from "dotenv"; +import { AppDataSource } from "./database/data-source"; +import path from "path"; +import { AuthController } from "./controllers/AuthController"; +import { FileController } from "./controllers/FileController"; +import { FolderController } from "./controllers/FolderController"; +import { AccessController } from "./controllers/AccessController"; +import { TagController } from "./controllers/TagController"; +import { UserController } from "./controllers/UserController"; +import { authMiddleware, authGuard } from "./middleware/auth"; +import { WebhookController } from "./controllers/WebhookController"; +import { adapter } from "./web3adapter/watchers/subscriber"; +import { PlatformEVaultService } from "./services/PlatformEVaultService"; + +config({ path: path.resolve(__dirname, "../../../.env") }); + +const app = express(); +const port = process.env.PORT || 3005; + +// Initialize database connection and adapter +AppDataSource.initialize() + .then(async () => { + console.log("Database connection established"); + console.log("Web3 adapter initialized"); + + // Initialize platform eVault for File Manager + try { + const platformService = PlatformEVaultService.getInstance(); + const exists = await platformService.checkPlatformEVaultExists(); + + if (!exists) { + console.log("🔧 Creating platform eVault for File Manager..."); + const result = await platformService.createPlatformEVault(); + console.log(`✅ Platform eVault created successfully: ${result.w3id}`); + } else { + console.log("✅ Platform eVault already exists for File Manager"); + } + } catch (error) { + console.error("❌ Failed to initialize platform eVault:", error); + // Don't exit the process, just log the error + } + }) + .catch((error: any) => { + console.error("Error during initialization:", error); + process.exit(1); + }); + +// Middleware +app.use( + cors({ + origin: "*", + methods: ["GET", "POST", "OPTIONS", "PATCH", "DELETE"], + allowedHeaders: [ + "Content-Type", + "Authorization", + "X-Webhook-Signature", + "X-Webhook-Timestamp", + ], + credentials: true, + }), +); +app.use(express.json({ limit: "50mb" })); +app.use(express.urlencoded({ limit: "50mb", extended: true })); + +// Controllers +const authController = new AuthController(); +const fileController = new FileController(); +const folderController = new FolderController(); +const accessController = new AccessController(); +const tagController = new TagController(); +const userController = new UserController(); +const webhookController = new WebhookController(adapter); + +// Public routes (no auth required) +app.get("/api/auth/offer", authController.getOffer); +app.post("/api/auth", authController.login); +app.get("/api/auth/sessions/:id", authController.sseStream); +app.post("/api/webhook", webhookController.handleWebhook); + +// Protected routes (auth required) +app.use(authMiddleware); + +// File routes +app.post("/api/files", authGuard, fileController.uploadFile); +app.get("/api/files", authGuard, fileController.getFiles); +app.get("/api/files/:id", authGuard, fileController.getFile); +app.get("/api/files/:id/download", authGuard, fileController.downloadFile); +app.get("/api/files/:id/preview", authGuard, fileController.previewFile); +app.patch("/api/files/:id", authGuard, fileController.updateFile); +app.delete("/api/files/:id", authGuard, fileController.deleteFile); +app.post("/api/files/:id/move", authGuard, fileController.moveFile); + +// Folder routes +app.post("/api/folders", authGuard, folderController.createFolder); +app.get("/api/folders", authGuard, folderController.getFolders); +app.get("/api/folders/tree", authGuard, folderController.getFolderTree); +app.get("/api/folders/:id", authGuard, folderController.getFolder); +app.get("/api/folders/:id/contents", authGuard, folderController.getFolderContents); +app.patch("/api/folders/:id", authGuard, folderController.updateFolder); +app.delete("/api/folders/:id", authGuard, folderController.deleteFolder); +app.post("/api/folders/:id/move", authGuard, folderController.moveFolder); + +// Access routes +app.post("/api/files/:id/access", authGuard, accessController.grantFileAccess); +app.delete("/api/files/:id/access/:userId", authGuard, accessController.revokeFileAccess); +app.get("/api/files/:id/access", authGuard, accessController.getFileAccess); +app.post("/api/folders/:id/access", authGuard, accessController.grantFolderAccess); +app.delete("/api/folders/:id/access/:userId", authGuard, accessController.revokeFolderAccess); +app.get("/api/folders/:id/access", authGuard, accessController.getFolderAccess); + +// Tag routes +app.post("/api/tags", authGuard, tagController.createTag); +app.get("/api/tags", authGuard, tagController.getTags); +app.patch("/api/tags/:id", authGuard, tagController.updateTag); +app.delete("/api/tags/:id", authGuard, tagController.deleteTag); +app.post("/api/files/:id/tags", authGuard, tagController.addTagToFile); +app.delete("/api/files/:id/tags/:tagId", authGuard, tagController.removeTagFromFile); +app.post("/api/folders/:id/tags", authGuard, tagController.addTagToFolder); +app.delete("/api/folders/:id/tags/:tagId", authGuard, tagController.removeTagFromFolder); + +// User routes +app.get("/api/users", authGuard, userController.currentUser); +app.get("/api/users/search", authGuard, userController.search); + +// Start server +app.listen(port, () => { + console.log(`File Manager API server running on port ${port}`); +}); + diff --git a/platforms/file-manager-api/src/middleware/auth.ts b/platforms/file-manager-api/src/middleware/auth.ts new file mode 100644 index 000000000..f95280388 --- /dev/null +++ b/platforms/file-manager-api/src/middleware/auth.ts @@ -0,0 +1,53 @@ +import { Request, Response, NextFunction } from "express"; +import { AppDataSource } from "../database/data-source"; +import { User } from "../database/entities/User"; +import { verifyToken } from "../utils/jwt"; + +export const authMiddleware = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + // Check for token in Authorization header or query parameter + let token: string | undefined; + + const authHeader = req.headers.authorization; + if (authHeader?.startsWith("Bearer ")) { + token = authHeader.split(" ")[1]; + } else if (req.query.token) { + token = req.query.token as string; + } + + if (!token) { + return next(); + } + + const decoded = verifyToken(token) as { userId: string }; + + if (!decoded?.userId) { + return res.status(401).json({ error: "Invalid token" }); + } + + const userRepository = AppDataSource.getRepository(User); + const user = await userRepository.findOneBy({ id: decoded.userId }); + + if (!user) { + return res.status(401).json({ error: "User not found" }); + } + + req.user = user; + next(); + } catch (error) { + console.error("Auth middleware error:", error); + res.status(401).json({ error: "Invalid token" }); + } +}; + +export const authGuard = (req: Request, res: Response, next: NextFunction) => { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + next(); +}; + diff --git a/platforms/file-manager-api/src/services/AccessService.ts b/platforms/file-manager-api/src/services/AccessService.ts new file mode 100644 index 000000000..9400e709e --- /dev/null +++ b/platforms/file-manager-api/src/services/AccessService.ts @@ -0,0 +1,248 @@ +import { AppDataSource } from "../database/data-source"; +import { FileAccess } from "../database/entities/FileAccess"; +import { FolderAccess } from "../database/entities/FolderAccess"; +import { File } from "../database/entities/File"; +import { Folder } from "../database/entities/Folder"; +import { User } from "../database/entities/User"; +import { In } from "typeorm"; +import { NotificationService } from "./NotificationService"; + +export class AccessService { + private fileAccessRepository = AppDataSource.getRepository(FileAccess); + private folderAccessRepository = AppDataSource.getRepository(FolderAccess); + private fileRepository = AppDataSource.getRepository(File); + private folderRepository = AppDataSource.getRepository(Folder); + private userRepository = AppDataSource.getRepository(User); + private notificationService: NotificationService; + + constructor() { + this.notificationService = new NotificationService(); + } + + // File Access Management + async grantFileAccess( + fileId: string, + userId: string, + grantedBy: string, + permission: "view" = "view" + ): Promise { + // Verify file exists and granter is the owner + const file = await this.fileRepository.findOne({ + where: { id: fileId, ownerId: grantedBy }, + }); + + if (!file) { + throw new Error("File not found or user is not the owner"); + } + + // Verify user exists + const user = await this.userRepository.findOne({ + where: { id: userId }, + }); + + if (!user) { + throw new Error("User not found"); + } + + // Check if access already exists + const existingAccess = await this.fileAccessRepository.findOne({ + where: { fileId, userId }, + }); + + if (existingAccess) { + return existingAccess; + } + + // Create access record + const access = this.fileAccessRepository.create({ + fileId, + userId, + grantedBy, + permission, + }); + + const savedAccess = await this.fileAccessRepository.save(access); + + // Send notification (fire-and-forget) + const granter = await this.userRepository.findOne({ where: { id: grantedBy } }); + const granterName = granter?.name || granter?.ename; + this.notificationService.sendFileSharedNotification(userId, file, granterName).catch(error => { + console.error(`Failed to send file shared notification:`, error); + }); + + return savedAccess; + } + + async revokeFileAccess(fileId: string, userId: string, revokedBy: string): Promise { + // Verify file exists and revoker is the owner + const file = await this.fileRepository.findOne({ + where: { id: fileId, ownerId: revokedBy }, + }); + + if (!file) { + throw new Error("File not found or user is not the owner"); + } + + const result = await this.fileAccessRepository.delete({ fileId, userId }); + return (result.affected || 0) > 0; + } + + async getFileAccess(fileId: string, userId: string): Promise { + // Verify user has access to the file (owner or has access) + const file = await this.fileRepository.findOne({ + where: { id: fileId }, + }); + + if (!file) { + throw new Error("File not found"); + } + + if (file.ownerId !== userId) { + const access = await this.fileAccessRepository.findOne({ + where: { fileId, userId }, + }); + if (!access) { + throw new Error("Access denied"); + } + } + + return await this.fileAccessRepository.find({ + where: { fileId }, + relations: ["user", "granter"], + }); + } + + // Folder Access Management + async grantFolderAccess( + folderId: string, + userId: string, + grantedBy: string, + permission: "view" = "view" + ): Promise { + // Verify folder exists and granter is the owner + const folder = await this.folderRepository.findOne({ + where: { id: folderId, ownerId: grantedBy }, + }); + + if (!folder) { + throw new Error("Folder not found or user is not the owner"); + } + + // Verify user exists + const user = await this.userRepository.findOne({ + where: { id: userId }, + }); + + if (!user) { + throw new Error("User not found"); + } + + // Check if access already exists + const existingAccess = await this.folderAccessRepository.findOne({ + where: { folderId, userId }, + }); + + if (existingAccess) { + return existingAccess; + } + + // Create access record + const access = this.folderAccessRepository.create({ + folderId, + userId, + grantedBy, + permission, + }); + + const savedAccess = await this.folderAccessRepository.save(access); + + // Send notification (fire-and-forget) + const granter = await this.userRepository.findOne({ where: { id: grantedBy } }); + const granterName = granter?.name || granter?.ename; + this.notificationService.sendFolderSharedNotification(userId, folder, granterName).catch(error => { + console.error(`Failed to send folder shared notification:`, error); + }); + + return savedAccess; + } + + async revokeFolderAccess(folderId: string, userId: string, revokedBy: string): Promise { + // Verify folder exists and revoker is the owner + const folder = await this.folderRepository.findOne({ + where: { id: folderId, ownerId: revokedBy }, + }); + + if (!folder) { + throw new Error("Folder not found or user is not the owner"); + } + + const result = await this.folderAccessRepository.delete({ folderId, userId }); + return (result.affected || 0) > 0; + } + + async getFolderAccess(folderId: string, userId: string): Promise { + // Verify user has access to the folder (owner or has access) + const folder = await this.folderRepository.findOne({ + where: { id: folderId }, + }); + + if (!folder) { + throw new Error("Folder not found"); + } + + if (folder.ownerId !== userId) { + const access = await this.folderAccessRepository.findOne({ + where: { folderId, userId }, + }); + if (!access) { + throw new Error("Access denied"); + } + } + + return await this.folderAccessRepository.find({ + where: { folderId }, + relations: ["user", "granter"], + }); + } + + async checkFileAccess(fileId: string, userId: string): Promise { + const file = await this.fileRepository.findOne({ + where: { id: fileId }, + }); + + if (!file) { + return false; + } + + if (file.ownerId === userId) { + return true; + } + + const access = await this.fileAccessRepository.findOne({ + where: { fileId, userId }, + }); + + return !!access; + } + + async checkFolderAccess(folderId: string, userId: string): Promise { + const folder = await this.folderRepository.findOne({ + where: { id: folderId }, + }); + + if (!folder) { + return false; + } + + if (folder.ownerId === userId) { + return true; + } + + const access = await this.folderAccessRepository.findOne({ + where: { folderId, userId }, + }); + + return !!access; + } +} + diff --git a/platforms/file-manager-api/src/services/FileService.ts b/platforms/file-manager-api/src/services/FileService.ts new file mode 100644 index 000000000..297213577 --- /dev/null +++ b/platforms/file-manager-api/src/services/FileService.ts @@ -0,0 +1,211 @@ +import { AppDataSource } from "../database/data-source"; +import { File } from "../database/entities/File"; +import { Folder } from "../database/entities/Folder"; +import { FileAccess } from "../database/entities/FileAccess"; +import { SignatureContainer } from "../database/entities/SignatureContainer"; +import { In, IsNull } from "typeorm"; +import crypto from "crypto"; + +export class FileService { + private fileRepository = AppDataSource.getRepository(File); + private fileAccessRepository = AppDataSource.getRepository(FileAccess); + private folderRepository = AppDataSource.getRepository(Folder); + + async calculateMD5(buffer: Buffer): Promise { + return crypto.createHash('md5').update(buffer).digest('hex'); + } + + async createFile( + name: string, + mimeType: string, + size: number, + data: Buffer, + ownerId: string, + folderId?: string | null, + displayName?: string, + description?: string + ): Promise { + const md5Hash = await this.calculateMD5(data); + + // Verify folder exists and user owns it if folderId is provided + if (folderId) { + const folder = await this.folderRepository.findOne({ + where: { id: folderId, ownerId }, + }); + if (!folder) { + throw new Error("Folder not found or user is not the owner"); + } + } + + const fileData: Partial = { + name, + displayName: displayName || name, + mimeType, + size, + md5Hash, + data, + ownerId, + folderId: folderId || null, + }; + + if (description !== undefined) { + fileData.description = description || null; + } + + const file = this.fileRepository.create(fileData); + const savedFile = await this.fileRepository.save(file); + return savedFile; + } + + async getFileById(id: string, userId: string): Promise { + const file = await this.fileRepository.findOne({ + where: { id }, + relations: ["owner", "folder", "signatures", "signatures.user", "tags"], + }); + + if (!file) { + return null; + } + + // Check access: owner or has access permission + if (file.ownerId === userId) { + return file; + } + + const access = await this.fileAccessRepository.findOne({ + where: { fileId: id, userId }, + }); + + if (!access) { + return null; + } + + return file; + } + + async getUserFiles(userId: string, folderId?: string | null): Promise { + // Get files owned by user + // Explicitly check for null or undefined to get root-level files + let ownedFiles: File[]; + + if (folderId === null || folderId === undefined || folderId === 'null' || folderId === '') { + // Root level files (no folder) - folderId must be null + // Use IsNull() for proper NULL checking in TypeORM + ownedFiles = await this.fileRepository.find({ + where: { + ownerId: userId, + folderId: IsNull() + }, + relations: ["owner", "folder", "tags"], + order: { createdAt: "DESC" }, + }); + } else { + // Files in specific folder + ownedFiles = await this.fileRepository.find({ + where: { + ownerId: userId, + folderId: folderId + }, + relations: ["owner", "folder", "tags"], + order: { createdAt: "DESC" }, + }); + } + + // Get files where user has access + const accessedFiles = await this.fileAccessRepository.find({ + where: { userId }, + relations: ["file", "file.owner", "file.folder", "file.tags"], + }); + + const ownedFileIds = new Set(ownedFiles.map(f => f.id)); + const allFiles = [...ownedFiles]; + + // Add accessed files that aren't already in the list and match folder filter + for (const fileAccess of accessedFiles) { + if (!fileAccess.file) continue; + + // Skip if already in owned files + if (ownedFileIds.has(fileAccess.fileId)) continue; + + // Filter by folder if specified + if (folderId === null || folderId === undefined || folderId === 'null' || folderId === '') { + // Only add root-level files (folderId is null) + if (fileAccess.file.folderId === null) { + allFiles.push(fileAccess.file); + } + } else { + // Only add files in the specified folder + if (fileAccess.file.folderId === folderId) { + allFiles.push(fileAccess.file); + } + } + } + + return allFiles; + } + + async updateFile( + id: string, + userId: string, + displayName?: string, + description?: string, + folderId?: string | null + ): Promise { + const file = await this.fileRepository.findOne({ + where: { id, ownerId: userId }, + }); + + if (!file) { + return null; + } + + if (displayName !== undefined) { + file.displayName = displayName || null; + } + if (description !== undefined) { + file.description = description || null; + } + if (folderId !== undefined) { + // Verify folder exists and user owns it if folderId is provided + if (folderId) { + const folder = await this.folderRepository.findOne({ + where: { id: folderId, ownerId: userId }, + }); + if (!folder) { + throw new Error("Folder not found or user is not the owner"); + } + } + file.folderId = folderId; + } + + return await this.fileRepository.save(file); + } + + async deleteFile(id: string, userId: string): Promise { + const file = await this.fileRepository.findOne({ + where: { id, ownerId: userId }, + }); + + if (!file) { + return false; + } + + // Delete all access records + await this.fileAccessRepository.delete({ fileId: id }); + + await this.fileRepository.remove(file); + return true; + } + + async moveFile(fileId: string, folderId: string | null, userId: string): Promise { + return await this.updateFile(fileId, userId, undefined, undefined, folderId); + } + + canPreview(mimeType: string): boolean { + // Check if file type can be previewed (images and PDFs) + const imageTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']; + const pdfTypes = ['application/pdf']; + return imageTypes.includes(mimeType) || pdfTypes.includes(mimeType); + } +} + diff --git a/platforms/file-manager-api/src/services/FolderService.ts b/platforms/file-manager-api/src/services/FolderService.ts new file mode 100644 index 000000000..4a848c9d8 --- /dev/null +++ b/platforms/file-manager-api/src/services/FolderService.ts @@ -0,0 +1,288 @@ +import { AppDataSource } from "../database/data-source"; +import { Folder } from "../database/entities/Folder"; +import { File } from "../database/entities/File"; +import { FolderAccess } from "../database/entities/FolderAccess"; +import { In, IsNull } from "typeorm"; + +export class FolderService { + private folderRepository = AppDataSource.getRepository(Folder); + private folderAccessRepository = AppDataSource.getRepository(FolderAccess); + private fileRepository = AppDataSource.getRepository(File); + + async createFolder( + name: string, + ownerId: string, + parentFolderId?: string | null + ): Promise { + // Verify parent folder exists and user owns it if parentFolderId is provided + if (parentFolderId) { + const parentFolder = await this.folderRepository.findOne({ + where: { id: parentFolderId, ownerId }, + }); + if (!parentFolder) { + throw new Error("Parent folder not found or user is not the owner"); + } + } + + // Prevent circular references + if (parentFolderId) { + const isCircular = await this.checkCircularReference(parentFolderId, ownerId); + if (isCircular) { + throw new Error("Cannot create folder: would create circular reference"); + } + } + + const folder = this.folderRepository.create({ + name, + ownerId, + parentFolderId: parentFolderId || null, + }); + + return await this.folderRepository.save(folder); + } + + async getFolderById(id: string, userId: string): Promise { + const folder = await this.folderRepository.findOne({ + where: { id }, + relations: ["owner", "parent", "children", "files", "tags"], + }); + + if (!folder) { + return null; + } + + // Check access: owner or has access permission + if (folder.ownerId === userId) { + return folder; + } + + const access = await this.folderAccessRepository.findOne({ + where: { folderId: id, userId }, + }); + + if (!access) { + return null; + } + + return folder; + } + + async getUserFolders(userId: string, parentFolderId?: string | null): Promise { + // Get folders owned by user + // Explicitly check for null or undefined to get root-level folders + let ownedFolders: Folder[]; + + if (parentFolderId === null || parentFolderId === undefined || parentFolderId === 'null' || parentFolderId === '') { + // Root level folders (no parent) - parentFolderId must be null + // Use IsNull() for proper NULL checking in TypeORM + ownedFolders = await this.folderRepository.find({ + where: { + ownerId: userId, + parentFolderId: IsNull() + }, + relations: ["owner", "parent", "tags"], + order: { createdAt: "DESC" }, + }); + } else { + // Folders in specific parent folder + ownedFolders = await this.folderRepository.find({ + where: { + ownerId: userId, + parentFolderId: parentFolderId + }, + relations: ["owner", "parent", "tags"], + order: { createdAt: "DESC" }, + }); + } + + // Get folders where user has access + const accessedFolders = await this.folderAccessRepository.find({ + where: { userId }, + relations: ["folder", "folder.owner", "folder.parent", "folder.tags"], + }); + + const ownedFolderIds = new Set(ownedFolders.map(f => f.id)); + const allFolders = [...ownedFolders]; + + // Add accessed folders that aren't already in the list and match parent folder filter + for (const folderAccess of accessedFolders) { + if (!folderAccess.folder) continue; + + // Skip if already in owned folders + if (ownedFolderIds.has(folderAccess.folderId)) continue; + + // Filter by parent folder if specified + if (parentFolderId === null || parentFolderId === undefined || parentFolderId === 'null' || parentFolderId === '') { + // Only add root-level folders (parentFolderId is null) + if (folderAccess.folder.parentFolderId === null) { + allFolders.push(folderAccess.folder); + } + } else { + // Only add folders in the specified parent folder + if (folderAccess.folder.parentFolderId === parentFolderId) { + allFolders.push(folderAccess.folder); + } + } + } + + return allFolders; + } + + async getFolderContents(folderId: string, userId: string): Promise<{ files: File[]; folders: Folder[] }> { + const folder = await this.getFolderById(folderId, userId); + if (!folder) { + throw new Error("Folder not found or access denied"); + } + + const files = await AppDataSource.getRepository(File).find({ + where: { folderId }, + relations: ["owner", "tags"], + order: { createdAt: "DESC" }, + }); + + const folders = await this.folderRepository.find({ + where: { parentFolderId: folderId }, + relations: ["owner", "tags"], + order: { createdAt: "DESC" }, + }); + + return { files, folders }; + } + + async updateFolder( + id: string, + userId: string, + name?: string, + parentFolderId?: string | null + ): Promise { + const folder = await this.folderRepository.findOne({ + where: { id, ownerId: userId }, + }); + + if (!folder) { + return null; + } + + if (name !== undefined) { + folder.name = name; + } + if (parentFolderId !== undefined) { + // Prevent circular references + if (parentFolderId && parentFolderId === id) { + throw new Error("Cannot move folder into itself"); + } + if (parentFolderId) { + const isCircular = await this.checkCircularReference(parentFolderId, userId, id); + if (isCircular) { + throw new Error("Cannot move folder: would create circular reference"); + } + const parentFolder = await this.folderRepository.findOne({ + where: { id: parentFolderId, ownerId: userId }, + }); + if (!parentFolder) { + throw new Error("Parent folder not found or user is not the owner"); + } + } + folder.parentFolderId = parentFolderId; + } + + return await this.folderRepository.save(folder); + } + + async deleteFolder(id: string, userId: string): Promise { + const folder = await this.folderRepository.findOne({ + where: { id, ownerId: userId }, + }); + + if (!folder) { + return false; + } + + // Recursively delete all child folders and files + await this.deleteFolderRecursive(id); + + // Delete all access records + await this.folderAccessRepository.delete({ folderId: id }); + + await this.folderRepository.remove(folder); + return true; + } + + private async deleteFolderRecursive(folderId: string): Promise { + // Get all child folders + const childFolders = await this.folderRepository.find({ + where: { parentFolderId: folderId }, + }); + + // Recursively delete child folders + for (const childFolder of childFolders) { + await this.deleteFolderRecursive(childFolder.id); + await this.folderAccessRepository.delete({ folderId: childFolder.id }); + await this.folderRepository.remove(childFolder); + } + + // Delete all files in this folder + const files = await this.fileRepository.find({ + where: { folderId }, + }); + + for (const file of files) { + await AppDataSource.getRepository(File).remove(file); + } + } + + async moveFolder(folderId: string, parentFolderId: string | null, userId: string): Promise { + return await this.updateFolder(folderId, userId, undefined, parentFolderId); + } + + private async checkCircularReference(folderId: string, ownerId: string, excludeFolderId?: string): Promise { + // Check if moving to this folder would create a circular reference + let currentFolderId: string | null = folderId; + const visited = new Set(); + + while (currentFolderId) { + if (visited.has(currentFolderId)) { + return true; // Circular reference detected + } + if (excludeFolderId && currentFolderId === excludeFolderId) { + return true; // Would create a cycle + } + visited.add(currentFolderId); + + const folder = await this.folderRepository.findOne({ + where: { id: currentFolderId, ownerId }, + }); + + if (!folder) { + break; + } + + currentFolderId = folder.parentFolderId; + } + + return false; + } + + async getFolderTree(userId: string): Promise { + // Get all folders owned by user or accessible to user + const ownedFolders = await this.folderRepository.find({ + where: { ownerId: userId }, + relations: ["parent", "children"], + }); + + const accessedFolders = await this.folderAccessRepository.find({ + where: { userId }, + relations: ["folder", "folder.parent", "folder.children"], + }); + + const allFolders = [...ownedFolders]; + for (const access of accessedFolders) { + if (access.folder && !allFolders.find(f => f.id === access.folder.id)) { + allFolders.push(access.folder); + } + } + + return allFolders; + } +} + diff --git a/platforms/file-manager-api/src/services/GroupService.ts b/platforms/file-manager-api/src/services/GroupService.ts new file mode 100644 index 000000000..7ff3b67be --- /dev/null +++ b/platforms/file-manager-api/src/services/GroupService.ts @@ -0,0 +1,253 @@ +import { Repository, In } from "typeorm"; +import { AppDataSource } from "../database/data-source"; +import { Group } from "../database/entities/Group"; +import { User } from "../database/entities/User"; + +export class GroupService { + groupRepository: Repository; + userRepository: Repository; + + constructor() { + this.groupRepository = AppDataSource.getRepository(Group); + this.userRepository = AppDataSource.getRepository(User); + } + + // Group CRUD Operations + async findGroupByMembers(memberIds: string[]): Promise { + if (memberIds.length === 0) { + return null; + } + + const sortedMemberIds = memberIds.sort(); + + // For 2-member groups (DMs), use a precise query that ensures exact match + if (sortedMemberIds.length === 2) { + // Find groups that are private and have exactly these 2 members + const groups = await this.groupRepository + .createQueryBuilder("group") + .leftJoinAndSelect("group.members", "members") + .where("group.isPrivate = :isPrivate", { isPrivate: true }) + .andWhere((qb) => { + // Subquery to find groups where both members are present + const subQuery = qb.subQuery() + .select("gm.group_id") + .from("group_members", "gm") + .where("gm.user_id IN (:...memberIds)", { + memberIds: sortedMemberIds + }) + .groupBy("gm.group_id") + .having("COUNT(DISTINCT gm.user_id) = :memberCount", { memberCount: 2 }) + .getQuery(); + return "group.id IN " + subQuery; + }) + .getMany(); + + // Filter groups that have exactly the same 2 members (no more, no less) + for (const group of groups) { + if (group.members && group.members.length === 2) { + const groupMemberIds = group.members.map((m: User) => m.id).sort(); + + if (groupMemberIds.length === sortedMemberIds.length && + groupMemberIds.every((id: string, index: number) => id === sortedMemberIds[index])) { + return group; + } + } + } + } + + // Fallback: get all private groups and filter in memory + const allPrivateGroups = await this.groupRepository + .createQueryBuilder("group") + .leftJoinAndSelect("group.members", "members") + .where("group.isPrivate = :isPrivate", { isPrivate: true }) + .getMany(); + + // Filter groups that have exactly the same members (order doesn't matter) + for (const group of allPrivateGroups) { + if (!group.members || group.members.length !== sortedMemberIds.length) { + continue; + } + + const groupMemberIds = group.members.map((m: User) => m.id).sort(); + + if (groupMemberIds.length === sortedMemberIds.length && + groupMemberIds.every((id: string, index: number) => id === sortedMemberIds[index])) { + return group; + } + } + + return null; + } + + async getGroupById(id: string): Promise { + return await this.groupRepository.findOne({ + where: { id }, + relations: ["members", "admins", "participants"] + }); + } + + async createGroup( + name: string, + description: string, + owner: string, + adminIds: string[] = [], + memberIds: string[] = [], + charter?: string, + isPrivate: boolean = false, + visibility: "public" | "private" | "restricted" = "public", + avatarUrl?: string, + bannerUrl?: string, + originalMatchParticipants?: string[], + ): Promise { + // For File Manager Chat groups, use a transaction to prevent race conditions + if (isPrivate && (name.startsWith("File Manager Chat") || name.includes("File Manager Chat")) && memberIds.length === 2) { + return await AppDataSource.transaction(async (transactionalEntityManager) => { + // First check by description pattern (idempotency check using :: pattern) + if (description && description.includes("::")) { + const existingByDescription = await transactionalEntityManager.findOne(Group, { + where: { + name: name, + description: description, + isPrivate: true + } + }); + if (existingByDescription) { + console.log(`⚠️ DM already exists with description pattern, returning existing DM: ${existingByDescription.id}`); + return existingByDescription; + } + } + + // Check again within transaction to prevent race conditions + const sortedMemberIds = memberIds.sort(); + const existingGroups = await transactionalEntityManager + .createQueryBuilder(Group, "group") + .leftJoinAndSelect("group.members", "members") + .where("group.isPrivate = :isPrivate", { isPrivate: true }) + .andWhere((qb) => { + const subQuery = qb.subQuery() + .select("gm.group_id") + .from("group_members", "gm") + .where("gm.user_id IN (:...memberIds)", { + memberIds: sortedMemberIds + }) + .groupBy("gm.group_id") + .having("COUNT(DISTINCT gm.user_id) = :memberCount", { memberCount: 2 }) + .getQuery(); + return "group.id IN " + subQuery; + }) + .getMany(); + + // Check if any group has exactly these 2 members + for (const group of existingGroups) { + if (group.members && group.members.length === 2) { + const groupMemberIds = group.members.map((m: User) => m.id).sort(); + if (groupMemberIds.length === sortedMemberIds.length && + groupMemberIds.every((id: string, index: number) => id === sortedMemberIds[index])) { + console.log(`⚠️ DM already exists between users ${memberIds.join(", ")}, returning existing DM: ${group.id}`); + return group; + } + } + } + + // No existing group found, create new one + const members = await transactionalEntityManager.findBy(User, { + id: In(memberIds), + }); + if (members.length !== memberIds.length) { + throw new Error("One or more members not found"); + } + + const admins = await transactionalEntityManager.findBy(User, { + id: In(adminIds), + }); + if (admins.length !== adminIds.length) { + throw new Error("One or more admins not found"); + } + + const group = transactionalEntityManager.create(Group, { + name, + description, + owner, + charter, + members, + admins, + participants: members, + isPrivate, + visibility, + avatarUrl, + bannerUrl, + originalMatchParticipants: originalMatchParticipants || [], + }); + return await transactionalEntityManager.save(Group, group); + }); + } + + // For non-DM groups, proceed normally + const members = await this.userRepository.findBy({ + id: In(memberIds), + }); + if (members.length !== memberIds.length) { + throw new Error("One or more members not found"); + } + + const admins = await this.userRepository.findBy({ + id: In(adminIds), + }); + if (admins.length !== adminIds.length) { + throw new Error("One or more admins not found"); + } + + const group = this.groupRepository.create({ + name, + description, + owner, + charter, + members, + admins, + participants: members, // Also set participants for compatibility + isPrivate, + visibility, + avatarUrl, + bannerUrl, + originalMatchParticipants: originalMatchParticipants || [], + }); + return await this.groupRepository.save(group); + } + + async updateGroup(id: string, updateData: Partial): Promise { + await this.groupRepository.update(id, updateData); + const updatedGroup = await this.groupRepository.findOneBy({ id }); + if (!updatedGroup) { + throw new Error("Group not found after update"); + } + return updatedGroup; + } + + async getUserGroups(userId: string): Promise { + return await this.groupRepository + .createQueryBuilder("group") + .leftJoinAndSelect("group.members", "members") + .leftJoinAndSelect("group.admins", "admins") + .leftJoinAndSelect("group.participants", "participants") + .where("members.id = :userId OR admins.id = :userId OR participants.id = :userId", { userId }) + .getMany(); + } + + async searchGroups(query: string, limit: number = 10): Promise { + return await this.groupRepository + .createQueryBuilder("group") + .where("group.name ILIKE :query OR group.description ILIKE :query", { query: `%${query}%` }) + .limit(limit) + .getMany(); + } + + async isGroupAdmin(groupId: string, userId: string): Promise { + const group = await this.groupRepository.findOne({ + where: { id: groupId }, + relations: ["admins"] + }); + if (!group) return false; + return group.admins.some(admin => admin.id === userId); + } +} + diff --git a/platforms/file-manager-api/src/services/MessageService.ts b/platforms/file-manager-api/src/services/MessageService.ts new file mode 100644 index 000000000..cc9a8301c --- /dev/null +++ b/platforms/file-manager-api/src/services/MessageService.ts @@ -0,0 +1,135 @@ +import { AppDataSource } from "../database/data-source"; +import { Message } from "../database/entities/Message"; +import { User } from "../database/entities/User"; +import { Group } from "../database/entities/Group"; + +export class MessageService { + public messageRepository = AppDataSource.getRepository(Message); + private userRepository = AppDataSource.getRepository(User); + private groupRepository = AppDataSource.getRepository(Group); + + async createMessage(messageData: { + text: string; + senderId: string; + groupId: string; + }): Promise { + const sender = await this.userRepository.findOne({ where: { id: messageData.senderId } }); + const group = await this.groupRepository.findOne({ where: { id: messageData.groupId } }); + + if (!sender || !group) { + throw new Error("Sender or group not found"); + } + + const message = this.messageRepository.create({ + text: messageData.text, + sender, + group, + isSystemMessage: false, + }); + + return await this.messageRepository.save(message); + } + + async createSystemMessage(messageData: { + text: string; + groupId: string; + }): Promise { + const group = await this.groupRepository.findOne({ where: { id: messageData.groupId } }); + + if (!group) { + throw new Error("Group not found"); + } + + // Add the system message prefix for web3-adapter compatibility + const prefixedText = `$$system-message$$ ${messageData.text}`; + + const message = this.messageRepository.create({ + text: prefixedText, + sender: undefined, // Use undefined instead of null for optional field + group, + isSystemMessage: true, + }); + + return await this.messageRepository.save(message); + } + + async createSystemMessageWithoutPrefix(messageData: { + text: string; + groupId: string; + }): Promise { + const group = await this.groupRepository.findOne({ where: { id: messageData.groupId } }); + + if (!group) { + throw new Error("Group not found"); + } + + const message = this.messageRepository.create({ + text: messageData.text, + sender: undefined, // Use undefined instead of null for optional field + group, + isSystemMessage: true, + }); + + return await this.messageRepository.save(message); + } + + async getMessageById(id: string): Promise { + return await this.messageRepository.findOne({ + where: { id }, + relations: ['sender', 'group'] + }); + } + + async getGroupMessages(groupId: string): Promise { + return await this.messageRepository.find({ + where: { group: { id: groupId } }, + relations: ['sender', 'group'], + order: { createdAt: 'ASC' } + }); + } + + async updateMessage(id: string, messageData: Partial): Promise { + // Get the current message, merge the data, and save it to trigger ORM events + const currentMessage = await this.getMessageById(id); + if (!currentMessage) { + throw new Error("Message not found"); + } + + // Merge the new data with the existing message + Object.assign(currentMessage, messageData); + + // Save the merged message to trigger ORM subscribers + const updatedMessage = await this.messageRepository.save(currentMessage); + return updatedMessage; + } + + async deleteMessage(id: string): Promise { + const result = await this.messageRepository.delete(id); + return result.affected ? result.affected > 0 : false; + } + + async getUserMessages(userId: string): Promise { + const messages = await this.messageRepository.find({ + where: { sender: { id: userId } }, + relations: ['sender', 'group'], + order: { createdAt: 'DESC' } + }); + + return messages; + } + + async archiveMessage(id: string): Promise { + // Get the current message, set archived flag, and save it to trigger ORM events + const currentMessage = await this.getMessageById(id); + if (!currentMessage) { + throw new Error("Message not found"); + } + + currentMessage.isArchived = true; + + // Save the updated message to trigger ORM subscribers + const archivedMessage = await this.messageRepository.save(currentMessage); + return archivedMessage; + } +} + diff --git a/platforms/file-manager-api/src/services/NotificationService.ts b/platforms/file-manager-api/src/services/NotificationService.ts new file mode 100644 index 000000000..5364b63a7 --- /dev/null +++ b/platforms/file-manager-api/src/services/NotificationService.ts @@ -0,0 +1,286 @@ +import { AppDataSource } from "../database/data-source"; +import { User } from "../database/entities/User"; +import { Group } from "../database/entities/Group"; +import { Message } from "../database/entities/Message"; +import { File } from "../database/entities/File"; +import { Folder } from "../database/entities/Folder"; +import { UserService } from "./UserService"; +import { GroupService } from "./GroupService"; +import { MessageService } from "./MessageService"; + +export class NotificationService { + private userService: UserService; + private groupService: GroupService; + private messageService: MessageService; + private fileManagerUser: User | null = null; + + constructor() { + this.userService = new UserService(); + this.groupService = new GroupService(); + this.messageService = new MessageService(); + } + + /** + * Find the File Manager platform user by searching for "File Manager Platform" in their name + */ + public async findFileManagerUser(): Promise { + if (this.fileManagerUser) { + return this.fileManagerUser; + } + + try { + // Search for users with "File Manager Platform" in their name + const users = await this.userService.searchUsers("File Manager Platform"); + this.fileManagerUser = users.find(user => + user.name?.includes("File Manager Platform") + ) || null; + + if (!this.fileManagerUser) { + console.error("❌ File Manager platform user not found in database"); + } else { + console.log(`✅ Found File Manager platform user: ${this.fileManagerUser.id}`); + } + + return this.fileManagerUser; + } catch (error) { + console.error("Error finding File Manager user:", error); + return null; + } + } + + /** + * Find or create a mutual chat between File Manager user and another user + * Returns both the chat and whether it was just created + */ + async findOrCreateMutualChat(targetUserId: string): Promise<{ chat: Group | null; wasCreated: boolean }> { + console.log(`🔍 Looking for mutual chat between File Manager and user: ${targetUserId}`); + + const fileManagerUser = await this.findFileManagerUser(); + if (!fileManagerUser) { + console.error("❌ Cannot create mutual chat: File Manager user not found"); + return { chat: null, wasCreated: false }; + } + + console.log(`👤 File Manager user found: ${fileManagerUser.id} (${fileManagerUser.name || fileManagerUser.ename})`); + + try { + // Check if a mutual chat already exists between these two users + console.log(`🔍 Checking for existing mutual chat between File Manager (${fileManagerUser.id}) and user (${targetUserId})`); + + const existingChat = await this.groupService.findGroupByMembers([ + fileManagerUser.id, + targetUserId + ]); + + if (existingChat) { + console.log(`✅ Found existing mutual chat: ${existingChat.id}`); + console.log(`📋 Chat details: Name="${existingChat.name}", Private=${existingChat.isPrivate}, Members=${existingChat.members?.length || 0}`); + return { chat: existingChat, wasCreated: false }; + } + + console.log(`🆕 No existing mutual chat found, creating new one...`); + + // Create a new mutual chat + const chatName = `File Manager Chat with ${targetUserId}`; + const chatDescription = `DM ID: ${targetUserId}::${fileManagerUser.id}`; + + console.log(`🔧 Creating mutual chat with:`); + console.log(` - Name: ${chatName}`); + console.log(` - Description: ${chatDescription}`); + console.log(` - Owner: ${fileManagerUser.id}`); + console.log(` - Members: [${fileManagerUser.id}, ${targetUserId}]`); + console.log(` - Private: true`); + + const mutualChat = await this.groupService.createGroup( + chatName, + chatDescription, + fileManagerUser.id, // File Manager is the owner + [fileManagerUser.id], // File Manager is admin + [fileManagerUser.id, targetUserId], // Both users are participants + undefined, // No charter + true, // isPrivate + "private", // visibility + undefined, // avatarUrl + undefined, // bannerUrl + [] // originalMatchParticipants + ); + + // Double-check: if createGroup returned an existing chat (due to race condition), verify it's the right one + if (mutualChat.id) { + const verifyChat = await this.groupService.findGroupByMembers([ + fileManagerUser.id, + targetUserId + ]); + + if (verifyChat && verifyChat.id !== mutualChat.id) { + console.log(`⚠️ Race condition detected: found different chat ${verifyChat.id}, using it instead`); + return { chat: verifyChat, wasCreated: false }; + } + } + + console.log(`✅ Created new mutual chat: ${mutualChat.id}`); + console.log(`📋 New chat details: Name="${mutualChat.name}", Private=${mutualChat.isPrivate}, Members=${mutualChat.members?.length || 0}`); + return { chat: mutualChat, wasCreated: true }; + } catch (error) { + console.error("❌ Error creating mutual chat:", error); + return { chat: null, wasCreated: false }; + } + } + + /** + * Send file shared notification to a user + */ + async sendFileSharedNotification(userId: string, file: File, sharerName?: string): Promise { + try { + const fileManagerUser = await this.findFileManagerUser(); + if (!fileManagerUser) { + console.error("❌ Cannot send notification: File Manager user not found"); + return; + } + + // Find or create mutual chat + const chatResult = await this.findOrCreateMutualChat(userId); + if (!chatResult.chat) { + console.error(`❌ Cannot send notification: failed to create chat for user ${userId}`); + return; + } + + const mutualChat = chatResult.chat; + const wasCreated = chatResult.wasCreated; + + // If chat was just created, wait 5 seconds before sending message + if (wasCreated) { + console.log(`⏳ Chat was just created, waiting 5 seconds before sending message...`); + await new Promise(resolve => setTimeout(resolve, 5000)); + console.log(`✅ 5-second delay completed for file shared message`); + } + + // Generate the file shared message + const messageContent = this.generateFileSharedMessage(file, sharerName); + + console.log(`💾 Creating file shared notification message...`); + const message = await this.messageService.createSystemMessage({ + text: messageContent, + groupId: mutualChat.id, + }); + + console.log(`✅ Message saved with ID: ${message.id}`); + console.log(`✅ File shared notification sent to user ${userId} in chat ${mutualChat.id}`); + } catch (error) { + console.error(`❌ Error sending file shared notification to user ${userId}:`, error); + console.error(`❌ Error details:`, (error as Error).message); + console.error(`❌ Error stack:`, (error as Error).stack); + } + } + + /** + * Send folder shared notification to a user + */ + async sendFolderSharedNotification(userId: string, folder: Folder, sharerName?: string): Promise { + try { + const fileManagerUser = await this.findFileManagerUser(); + if (!fileManagerUser) { + console.error("❌ Cannot send notification: File Manager user not found"); + return; + } + + // Find or create mutual chat + const chatResult = await this.findOrCreateMutualChat(userId); + if (!chatResult.chat) { + console.error(`❌ Cannot send notification: failed to create chat for user ${userId}`); + return; + } + + const mutualChat = chatResult.chat; + const wasCreated = chatResult.wasCreated; + + // If chat was just created, wait 5 seconds before sending message + if (wasCreated) { + console.log(`⏳ Chat was just created, waiting 5 seconds before sending message...`); + await new Promise(resolve => setTimeout(resolve, 5000)); + console.log(`✅ 5-second delay completed for folder shared message`); + } + + // Generate the folder shared message + const messageContent = this.generateFolderSharedMessage(folder, sharerName); + + console.log(`💾 Creating folder shared notification message...`); + const message = await this.messageService.createSystemMessage({ + text: messageContent, + groupId: mutualChat.id, + }); + + console.log(`✅ Message saved with ID: ${message.id}`); + console.log(`✅ Folder shared notification sent to user ${userId} in chat ${mutualChat.id}`); + } catch (error) { + console.error(`❌ Error sending folder shared notification to user ${userId}:`, error); + console.error(`❌ Error details:`, (error as Error).message); + console.error(`❌ Error stack:`, (error as Error).stack); + } + } + + /** + * Generate file shared message content + */ + private generateFileSharedMessage(file: File, sharerName?: string): string { + const formattedTime = new Date().toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + + const sharerText = sharerName ? ` from ${sharerName}` : ''; + const fileName = file.displayName || file.name; + const descriptionText = file.description ? `\nDescription: ${file.description}` : ''; + + return `📁 File Shared + +You have been granted access${sharerText} to a file. + +File: ${fileName}${descriptionText} +Original Name: ${file.name} +Size: ${this.formatFileSize(file.size)} +Type: ${file.mimeType} +Time: ${formattedTime} + +You can now view and download this file from the File Manager platform.`; + } + + /** + * Generate folder shared message content + */ + private generateFolderSharedMessage(folder: Folder, sharerName?: string): string { + const formattedTime = new Date().toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + + const sharerText = sharerName ? ` from ${sharerName}` : ''; + + return `📂 Folder Shared + +You have been granted access${sharerText} to a folder. + +Folder: ${folder.name} +Time: ${formattedTime} + +You can now view the contents of this folder from the File Manager platform.`; + } + + /** + * Format file size + */ + private formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } +} + diff --git a/platforms/file-manager-api/src/services/PlatformEVaultService.ts b/platforms/file-manager-api/src/services/PlatformEVaultService.ts new file mode 100644 index 000000000..1d537b773 --- /dev/null +++ b/platforms/file-manager-api/src/services/PlatformEVaultService.ts @@ -0,0 +1,321 @@ +import axios from "axios"; +import { GraphQLClient } from "graphql-request"; +import { v4 as uuidv4 } from "uuid"; +import { UserEVaultMapping } from "../database/entities/UserEVaultMapping"; +import { AppDataSource } from "../database/data-source"; + +const STORE_META_ENVELOPE = ` + mutation StoreMetaEnvelope($input: MetaEnvelopeInput!) { + storeMetaEnvelope(input: $input) { + metaEnvelope { + id + ontology + parsed + } + } + } +`; + +interface MetaEnvelopeResponse { + storeMetaEnvelope: { + metaEnvelope: { + id: string; + ontology: string; + parsed: any; + }; + }; +} + +interface PlatformProfile { + platformName: string; + displayName: string; + description: string; + version: string; + ename: string; + isActive: boolean; + createdAt: string; + updatedAt: string; + isArchived: boolean; +} + +export class PlatformEVaultService { + private static instance: PlatformEVaultService; + private client: GraphQLClient | null = null; + private endpoint: string | null = null; + private w3id: string | null = null; + + private constructor() {} + + public static getInstance(): PlatformEVaultService { + if (!PlatformEVaultService.instance) { + PlatformEVaultService.instance = new PlatformEVaultService(); + } + return PlatformEVaultService.instance; + } + + /** + * Check if File Manager platform eVault already exists + */ + async checkPlatformEVaultExists(): Promise { + const mappingRepository = + AppDataSource.getRepository(UserEVaultMapping); + const existingMapping = await mappingRepository.findOne({ + where: { localUserId: "file-manager-platform" }, + }); + return !!existingMapping; + } + + /** + * Create eVault for File Manager platform (one-time setup) + */ + async createPlatformEVault(): Promise<{ + w3id: string; + uri: string; + userProfileId: string; + }> { + console.log("Creating platform eVault for File Manager..."); + + // Check if platform eVault already exists + const exists = await this.checkPlatformEVaultExists(); + if (exists) { + throw new Error("Platform eVault already exists for File Manager"); + } + + try { + // Step 1: Get entropy from registry + const registryUrl = + process.env.PUBLIC_REGISTRY_URL || "http://localhost:3000"; + const { + data: { token: registryEntropy }, + } = await axios.get(new URL("/entropy", registryUrl).toString()); + + // Step 2: Provision eVault + const provisionerUrl = + process.env.PUBLIC_PROVISIONER_URL || "http://localhost:3001"; + const verificationId = + process.env.DEMO_VERIFICATION_CODE || + "d66b7138-538a-465f-a6ce-f6985854c3f4"; + + const { data } = await axios.post( + new URL("/provision", provisionerUrl).toString(), + { + registryEntropy, + namespace: uuidv4(), + verificationId, + publicKey: "0x00000000000000000000000000000000000000", + }, + ); + + if (!data || data.success !== true) { + throw new Error("Failed to provision platform eVault"); + } + + const { w3id, uri } = data; + + // Step 3: Create PlatformProfile in eVault + const userProfileId = await this.createPlatformProfileInEVault( + w3id, + uri, + ); + + // Step 4: Store mapping in database + const mappingRepository = + AppDataSource.getRepository(UserEVaultMapping); + const mapping = new UserEVaultMapping(); + mapping.localUserId = "file-manager-platform"; + mapping.evaultW3id = w3id; + mapping.evaultUri = uri; + mapping.userProfileId = userProfileId; + mapping.userProfileData = { + platformName: "file-manager", + displayName: "File Manager Platform", + description: + "File Manager - Cloud storage and file management platform", + version: "1.0.0", + }; + + await mappingRepository.save(mapping); + + console.log("Platform eVault created successfully:", { + w3id, + uri, + userProfileId, + }); + + return { w3id, uri, userProfileId }; + } catch (error) { + console.error("Failed to create platform eVault:", error); + throw error; + } + } + + /** + * Resolve eVault endpoint from registry + */ + private async resolveEndpoint(w3id: string): Promise { + try { + const registryUrl = + process.env.PUBLIC_REGISTRY_URL || "http://localhost:3000"; + const response = await axios.get( + new URL(`resolve?w3id=${w3id}`, registryUrl).toString(), + ); + return new URL("/graphql", response.data.uri).toString(); + } catch (error) { + console.error("Error resolving eVault endpoint:", error); + throw new Error("Failed to resolve eVault endpoint"); + } + } + + /** + * Ensure we have a valid GraphQL client + */ + private async ensureClient(w3id: string): Promise { + // Recreate client if w3id changed or client/endpoint is missing + if (!this.endpoint || !this.client || this.w3id !== w3id) { + this.endpoint = await this.resolveEndpoint(w3id); + this.client = new GraphQLClient(this.endpoint, { + headers: { + "X-ENAME": w3id, + }, + }); + this.w3id = w3id; + } + return this.client; + } + + /** + * Create PlatformProfile in eVault with retry mechanism + */ + private async createPlatformProfileInEVault( + w3id: string, + uri: string, + maxRetries = 20, + ): Promise { + console.log("Creating PlatformProfile in eVault..."); + + const now = new Date().toISOString(); + const platformProfile: PlatformProfile = { + platformName: "file-manager", + displayName: "File Manager Platform", + description: + "File Manager - Cloud storage and file management platform", + version: "1.0.0", + ename: w3id, + isActive: true, + createdAt: now, + updatedAt: now, + isArchived: false, + }; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const client = await this.ensureClient(w3id); + + console.log( + `Attempting to create PlatformProfile in eVault (attempt ${attempt}/${maxRetries})`, + ); + + const response = await client.request( + STORE_META_ENVELOPE, + { + input: { + ontology: "550e8400-e29b-41d4-a716-446655440000", // UserProfile ontology + payload: platformProfile, + acl: ["*"], + }, + }, + ); + + const userProfileId = + response.storeMetaEnvelope.metaEnvelope.id; + console.log( + "PlatformProfile created successfully in eVault:", + userProfileId, + ); + return userProfileId; + } catch (error) { + console.error( + `Failed to create PlatformProfile in eVault (attempt ${attempt}/${maxRetries}):`, + error, + ); + + if (attempt === maxRetries) { + console.error( + "Max retries reached, giving up on PlatformProfile creation", + ); + throw error; + } + + // Wait before retrying (exponential backoff) + const delay = Math.min(1000 * 2 ** (attempt - 1), 20000); + console.log(`Waiting ${delay}ms before retry...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + throw new Error("Failed to create PlatformProfile after all retries"); + } + + /** + * Get platform eVault mapping + */ + async getPlatformEVaultMapping(): Promise { + const mappingRepository = + AppDataSource.getRepository(UserEVaultMapping); + return await mappingRepository.findOne({ + where: { localUserId: "file-manager-platform" }, + }); + } + + /** + * Get platform eName (W3ID) + */ + async getPlatformEName(): Promise { + const mapping = await this.getPlatformEVaultMapping(); + return mapping?.evaultW3id || null; + } + + /** + * Get platform eVault URI + */ + async getPlatformEVaultUri(): Promise { + const mapping = await this.getPlatformEVaultMapping(); + return mapping?.evaultUri || null; + } + + /** + * Update platform profile in eVault + */ + async updatePlatformProfile( + updates: Partial, + ): Promise { + const mapping = await this.getPlatformEVaultMapping(); + if (!mapping) { + throw new Error("Platform eVault mapping not found"); + } + + const client = await this.ensureClient(mapping.evaultW3id); + + // Get current profile data + const currentData = mapping.userProfileData as PlatformProfile; + const updatedData = { + ...currentData, + ...updates, + updatedAt: new Date().toISOString(), + }; + + // Update in eVault + await client.request(STORE_META_ENVELOPE, { + input: { + ontology: "550e8400-e29b-41d4-a716-446655440000", + payload: updatedData, + acl: ["*"], + }, + }); + + // Update local mapping + mapping.userProfileData = updatedData; + await AppDataSource.getRepository(UserEVaultMapping).save(mapping); + } +} + diff --git a/platforms/file-manager-api/src/services/TagService.ts b/platforms/file-manager-api/src/services/TagService.ts new file mode 100644 index 000000000..c2ac1561b --- /dev/null +++ b/platforms/file-manager-api/src/services/TagService.ts @@ -0,0 +1,240 @@ +import { AppDataSource } from "../database/data-source"; +import { Tag } from "../database/entities/Tag"; +import { File } from "../database/entities/File"; +import { Folder } from "../database/entities/Folder"; + +export class TagService { + private tagRepository = AppDataSource.getRepository(Tag); + private fileRepository = AppDataSource.getRepository(File); + private folderRepository = AppDataSource.getRepository(Folder); + + async createTag( + name: string, + ownerId: string, + color?: string | null + ): Promise { + // Check if tag with same name already exists for this user + const existingTag = await this.tagRepository.findOne({ + where: { name, ownerId }, + }); + + if (existingTag) { + throw new Error("Tag with this name already exists"); + } + + const tag = this.tagRepository.create({ + name, + ownerId, + color: color || null, + }); + + return await this.tagRepository.save(tag); + } + + async getUserTags(userId: string): Promise { + return await this.tagRepository.find({ + where: { ownerId: userId }, + relations: ["files", "folders"], + order: { createdAt: "DESC" }, + }); + } + + async getTagById(id: string, userId: string): Promise { + const tag = await this.tagRepository.findOne({ + where: { id, ownerId: userId }, + relations: ["files", "folders"], + }); + + return tag; + } + + async updateTag( + id: string, + userId: string, + name?: string, + color?: string | null + ): Promise { + const tag = await this.tagRepository.findOne({ + where: { id, ownerId: userId }, + }); + + if (!tag) { + return null; + } + + if (name !== undefined) { + // Check if another tag with this name exists + const existingTag = await this.tagRepository.findOne({ + where: { name, ownerId: userId }, + }); + if (existingTag && existingTag.id !== id) { + throw new Error("Tag with this name already exists"); + } + tag.name = name; + } + + if (color !== undefined) { + tag.color = color; + } + + return await this.tagRepository.save(tag); + } + + async deleteTag(id: string, userId: string): Promise { + const tag = await this.tagRepository.findOne({ + where: { id, ownerId: userId }, + }); + + if (!tag) { + return false; + } + + await this.tagRepository.remove(tag); + return true; + } + + async addTagToFile(fileId: string, tagId: string, userId: string): Promise { + const file = await this.fileRepository.findOne({ + where: { id: fileId }, + relations: ["tags", "owner"], + }); + + if (!file) { + throw new Error("File not found"); + } + + // Verify user has access to the file + if (file.ownerId !== userId) { + throw new Error("Access denied"); + } + + const tag = await this.tagRepository.findOne({ + where: { id: tagId, ownerId: userId }, + }); + + if (!tag) { + throw new Error("Tag not found"); + } + + // Check if tag is already attached + if (file.tags && file.tags.some(t => t.id === tagId)) { + return file; + } + + if (!file.tags) { + file.tags = []; + } + + file.tags.push(tag); + return await this.fileRepository.save(file); + } + + async removeTagFromFile(fileId: string, tagId: string, userId: string): Promise { + const file = await this.fileRepository.findOne({ + where: { id: fileId }, + relations: ["tags", "owner"], + }); + + if (!file) { + throw new Error("File not found"); + } + + // Verify user has access to the file + if (file.ownerId !== userId) { + throw new Error("Access denied"); + } + + if (!file.tags || !file.tags.some(t => t.id === tagId)) { + return file; + } + + file.tags = file.tags.filter(t => t.id !== tagId); + return await this.fileRepository.save(file); + } + + async addTagToFolder(folderId: string, tagId: string, userId: string): Promise { + const folder = await this.folderRepository.findOne({ + where: { id: folderId }, + relations: ["tags", "owner"], + }); + + if (!folder) { + throw new Error("Folder not found"); + } + + // Verify user has access to the folder + if (folder.ownerId !== userId) { + throw new Error("Access denied"); + } + + const tag = await this.tagRepository.findOne({ + where: { id: tagId, ownerId: userId }, + }); + + if (!tag) { + throw new Error("Tag not found"); + } + + // Check if tag is already attached + if (folder.tags && folder.tags.some(t => t.id === tagId)) { + return folder; + } + + if (!folder.tags) { + folder.tags = []; + } + + folder.tags.push(tag); + return await this.folderRepository.save(folder); + } + + async removeTagFromFolder(folderId: string, tagId: string, userId: string): Promise { + const folder = await this.folderRepository.findOne({ + where: { id: folderId }, + relations: ["tags", "owner"], + }); + + if (!folder) { + throw new Error("Folder not found"); + } + + // Verify user has access to the folder + if (folder.ownerId !== userId) { + throw new Error("Access denied"); + } + + if (!folder.tags || !folder.tags.some(t => t.id === tagId)) { + return folder; + } + + folder.tags = folder.tags.filter(t => t.id !== tagId); + return await this.folderRepository.save(folder); + } + + async getFilesByTag(tagId: string, userId: string): Promise { + const tag = await this.tagRepository.findOne({ + where: { id: tagId, ownerId: userId }, + relations: ["files"], + }); + + if (!tag) { + return []; + } + + return tag.files || []; + } + + async getFoldersByTag(tagId: string, userId: string): Promise { + const tag = await this.tagRepository.findOne({ + where: { id: tagId, ownerId: userId }, + relations: ["folders"], + }); + + if (!tag) { + return []; + } + + return tag.folders || []; + } +} + diff --git a/platforms/file-manager-api/src/services/UserService.ts b/platforms/file-manager-api/src/services/UserService.ts new file mode 100644 index 000000000..8fdd784e6 --- /dev/null +++ b/platforms/file-manager-api/src/services/UserService.ts @@ -0,0 +1,142 @@ +import { AppDataSource } from "../database/data-source"; +import { User } from "../database/entities/User"; +import { signToken } from "../utils/jwt"; + +export class UserService { + userRepository = AppDataSource.getRepository(User); + + async createBlankUser(ename: string): Promise { + const user = this.userRepository.create({ + ename, + isVerified: false, + isPrivate: false, + isArchived: false, + }); + + return await this.userRepository.save(user); + } + + async findOrCreateUser( + ename: string + ): Promise<{ user: User; token: string }> { + let user = await this.userRepository.findOne({ + where: { ename }, + }); + + if (!user) { + user = await this.createBlankUser(ename); + } + + const token = signToken({ userId: user.id }); + return { user, token }; + } + + async findById(id: string): Promise { + return await this.userRepository.findOneBy({ id }); + } + + async findByEname(ename: string): Promise { + const normalizedEname = ename.startsWith('@') ? ename.slice(1) : ename; + const enameWithAt = `@${normalizedEname}`; + + const user = await this.userRepository + .createQueryBuilder("user") + .where("user.ename = :enameWithAt OR user.ename = :enameWithoutAt", { + enameWithAt, + enameWithoutAt: normalizedEname, + }) + .getOne(); + + return user; + } + + async findUser(ename: string): Promise { + return this.findByEname(ename); + } + + async getUserById(id: string): Promise { + return await this.findById(id); + } + + searchUsers = async ( + query: string, + page: number = 1, + limit: number = 10, + verifiedOnly: boolean = false, + sortBy: string = "relevance" + ) => { + const searchQuery = query.trim(); + + if (searchQuery.length < 2) { + return []; + } + + if (page < 1 || limit < 1 || limit > 100) { + return []; + } + + const queryBuilder = this.userRepository + .createQueryBuilder("user") + .select([ + "user.id", + "user.handle", + "user.name", + "user.ename", + "user.description", + "user.avatarUrl", + "user.isVerified" + ]) + .addSelect(` + CASE + WHEN user.ename ILIKE :exactQuery THEN 100 + WHEN user.name ILIKE :exactQuery THEN 90 + WHEN user.handle ILIKE :exactQuery THEN 80 + WHEN user.ename ILIKE :query THEN 70 + WHEN user.name ILIKE :query THEN 60 + WHEN user.handle ILIKE :query THEN 50 + WHEN user.description ILIKE :query THEN 30 + WHEN user.ename ILIKE :fuzzyQuery THEN 40 + WHEN user.name ILIKE :fuzzyQuery THEN 35 + WHEN user.handle ILIKE :fuzzyQuery THEN 30 + ELSE 0 + END`, 'relevance_score') + .where( + "user.name ILIKE :query OR user.ename ILIKE :query OR user.handle ILIKE :query OR user.description ILIKE :query OR user.ename ILIKE :fuzzyQuery OR user.name ILIKE :fuzzyQuery OR user.handle ILIKE :fuzzyQuery", + { + query: `%${searchQuery}%`, + exactQuery: searchQuery, + fuzzyQuery: `%${searchQuery.split('').join('%')}%` + } + ); + + if (verifiedOnly) { + queryBuilder.andWhere("user.isVerified = :verified", { verified: true }); + } + + queryBuilder.andWhere("user.isArchived = :archived", { archived: false }); + + switch (sortBy) { + case "name": + queryBuilder.orderBy("user.name", "ASC"); + break; + case "verified": + queryBuilder.orderBy("user.isVerified", "DESC").addOrderBy("user.name", "ASC"); + break; + case "newest": + queryBuilder.orderBy("user.createdAt", "DESC"); + break; + case "relevance": + default: + queryBuilder.orderBy("relevance_score", "DESC") + .addOrderBy("user.isVerified", "DESC") + .addOrderBy("user.name", "ASC"); + break; + } + + return queryBuilder + .skip((page - 1) * limit) + .take(limit) + .getMany(); + }; +} + diff --git a/platforms/file-manager-api/src/types/express.d.ts b/platforms/file-manager-api/src/types/express.d.ts new file mode 100644 index 000000000..0a9d9d6a4 --- /dev/null +++ b/platforms/file-manager-api/src/types/express.d.ts @@ -0,0 +1,10 @@ +import { User } from "../database/entities/User"; + +declare global { + namespace Express { + interface Request { + user?: User; + } + } +} + diff --git a/platforms/file-manager-api/src/utils/jwt.ts b/platforms/file-manager-api/src/utils/jwt.ts new file mode 100644 index 000000000..4f6127527 --- /dev/null +++ b/platforms/file-manager-api/src/utils/jwt.ts @@ -0,0 +1,16 @@ +import jwt from 'jsonwebtoken'; + +const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'; + +export const signToken = (payload: any): string => { + return jwt.sign(payload, JWT_SECRET, { expiresIn: '7d' }); +}; + +export const verifyToken = (token: string): any => { + try { + return jwt.verify(token, JWT_SECRET); + } catch (error) { + throw new Error('Invalid token'); + } +}; + diff --git a/platforms/file-manager-api/src/utils/version.ts b/platforms/file-manager-api/src/utils/version.ts new file mode 100644 index 000000000..29da82179 --- /dev/null +++ b/platforms/file-manager-api/src/utils/version.ts @@ -0,0 +1,31 @@ +/** + * Compares two semantic version strings + * @param version1 - First version string (e.g., "0.4.0") + * @param version2 - Second version string (e.g., "0.3.0") + * @returns -1 if version1 < version2, 0 if equal, 1 if version1 > version2 + */ +export function compareVersions(version1: string, version2: string): number { + const v1Parts = version1.split('.').map(Number); + const v2Parts = version2.split('.').map(Number); + + for (let i = 0; i < Math.max(v1Parts.length, v2Parts.length); i++) { + const v1Part = v1Parts[i] || 0; + const v2Part = v2Parts[i] || 0; + + if (v1Part < v2Part) return -1; + if (v1Part > v2Part) return 1; + } + + return 0; +} + +/** + * Checks if the app version meets the minimum required version + * @param appVersion - The version from the app (e.g., "0.4.0") + * @param minVersion - The minimum required version (e.g., "0.4.0") + * @returns true if appVersion >= minVersion, false otherwise + */ +export function isVersionValid(appVersion: string, minVersion: string): boolean { + return compareVersions(appVersion, minVersion) >= 0; +} + diff --git a/platforms/file-manager-api/src/web3adapter/mappings/group.mapping.json b/platforms/file-manager-api/src/web3adapter/mappings/group.mapping.json new file mode 100644 index 000000000..1d36fb5d0 --- /dev/null +++ b/platforms/file-manager-api/src/web3adapter/mappings/group.mapping.json @@ -0,0 +1,27 @@ +{ + "tableName": "groups", + "schemaId": "550e8400-e29b-41d4-a716-446655440003", + "ownerEnamePath": "users(participants[].ename)", + "ownedJunctionTables": [ + "group_participants" + ], + "localToUniversalMap": { + "name": "name", + "description": "description", + "owner": "owner", + "admins": "users(admins[].id),adminIds", + "charter": "charter", + "ename": "ename", + "participants": "users(participants[].id),participantIds", + "members": "users(members[].id),memberIds", + "originalMatchParticipants": "originalMatchParticipants", + "isPrivate": "isPrivate", + "visibility": "visibility", + "avatarUrl": "avatarUrl", + "bannerUrl": "bannerUrl", + "createdAt": "createdAt", + "updatedAt": "updatedAt" + }, + "readOnly": false +} + diff --git a/platforms/file-manager-api/src/web3adapter/mappings/message.mapping.json b/platforms/file-manager-api/src/web3adapter/mappings/message.mapping.json new file mode 100644 index 000000000..ac51a0215 --- /dev/null +++ b/platforms/file-manager-api/src/web3adapter/mappings/message.mapping.json @@ -0,0 +1,16 @@ +{ + "tableName": "messages", + "schemaId": "550e8400-e29b-41d4-a716-446655440004", + "ownerEnamePath": "groups(group.ename)||users(group.members[].ename)", + "ownedJunctionTables": [], + "localToUniversalMap": { + "text": "content", + "sender": "users(sender.id),senderId", + "group": "groups(group.id),chatId", + "isSystemMessage": "isSystemMessage", + "createdAt": "createdAt", + "updatedAt": "updatedAt", + "isArchived": "isArchived" + } +} + diff --git a/platforms/file-manager-api/src/web3adapter/mappings/user.mapping.json b/platforms/file-manager-api/src/web3adapter/mappings/user.mapping.json new file mode 100644 index 000000000..ff269b929 --- /dev/null +++ b/platforms/file-manager-api/src/web3adapter/mappings/user.mapping.json @@ -0,0 +1,21 @@ +{ + "tableName": "users", + "readOnly": true, + "schemaId": "550e8400-e29b-41d4-a716-446655440000", + "ownerEnamePath": "ename", + "ownedJunctionTables": [], + "localToUniversalMap": { + "handle": "username", + "name": "name", + "description": "bio", + "avatarUrl": "avatarUrl", + "bannerUrl": "bannerUrl", + "ename": "ename", + "isVerified": "isVerified", + "isPrivate": "isPrivate", + "createdAt": "createdAt", + "updatedAt": "updatedAt", + "isArchived": "isArchived" + } +} + diff --git a/platforms/file-manager-api/src/web3adapter/watchers/subscriber.ts b/platforms/file-manager-api/src/web3adapter/watchers/subscriber.ts new file mode 100644 index 000000000..70725461d --- /dev/null +++ b/platforms/file-manager-api/src/web3adapter/watchers/subscriber.ts @@ -0,0 +1,270 @@ +import { + EventSubscriber, + EntitySubscriberInterface, + InsertEvent, + UpdateEvent, + RemoveEvent, + ObjectLiteral, +} from "typeorm"; +import { Web3Adapter } from "web3-adapter"; +import path from "path"; +import dotenv from "dotenv"; +import { AppDataSource } from "../../database/data-source"; + +dotenv.config({ path: path.resolve(__dirname, "../../../../../.env") }); +export const adapter = new Web3Adapter({ + schemasPath: path.resolve(__dirname, "../mappings/"), + dbPath: path.resolve(process.env.FILE_MANAGER_MAPPING_DB_PATH as string), + registryUrl: process.env.PUBLIC_REGISTRY_URL as string, + platform: process.env.PUBLIC_FILE_MANAGER_BASE_URL as string, +}); + +@EventSubscriber() +export class PostgresSubscriber implements EntitySubscriberInterface { + private adapter: Web3Adapter; + private pendingChanges: Map = new Map(); + + constructor() { + this.adapter = adapter; + + setInterval(() => { + this.cleanupOldPendingChanges(); + }, 5 * 60 * 1000); + } + + private cleanupOldPendingChanges(): void { + const now = Date.now(); + const maxAge = 10 * 60 * 1000; + + for (const [key, timestamp] of this.pendingChanges.entries()) { + if (now - timestamp > maxAge) { + this.pendingChanges.delete(key); + } + } + } + + async enrichEntity(entity: any, tableName: string, tableTarget: any) { + try { + const enrichedEntity = { ...entity }; + return this.entityToPlain(enrichedEntity); + } catch (error) { + console.error("Error loading relations:", error); + return this.entityToPlain(entity); + } + } + + /** + * Special enrichment method for Message entities to ensure group and admin data is loaded + */ + private async enrichMessageEntity(messageEntity: any): Promise { + try { + const enrichedMessage = { ...messageEntity }; + + // If the message has a group, load the full group with admins and members + if (enrichedMessage.group && enrichedMessage.group.id) { + const groupRepository = AppDataSource.getRepository("Group"); + const fullGroup = await groupRepository.findOne({ + where: { id: enrichedMessage.group.id }, + relations: ["admins", "members", "participants"] + }); + + if (fullGroup) { + enrichedMessage.group = fullGroup; + } + } + + // If the message has a sender, ensure it's loaded + if (enrichedMessage.sender && enrichedMessage.sender.id) { + const userRepository = AppDataSource.getRepository("User"); + const fullSender = await userRepository.findOne({ + where: { id: enrichedMessage.sender.id } + }); + + if (fullSender) { + enrichedMessage.sender = fullSender; + } + } + + return enrichedMessage; + } catch (error) { + console.error("Error enriching Message entity:", error); + return messageEntity; + } + } + + async afterInsert(event: InsertEvent) { + let entity = event.entity; + if (entity) { + entity = (await this.enrichEntity( + entity, + event.metadata.tableName, + event.metadata.target + )) as ObjectLiteral; + } + + // Special handling for Message entities to ensure complete data + if (event.metadata.tableName === "messages" && entity) { + entity = await this.enrichMessageEntity(entity); + } + + this.handleChange( + entity ?? event.entityId, + event.metadata.tableName.endsWith("s") + ? event.metadata.tableName + : event.metadata.tableName + "s" + ); + } + + async afterUpdate(event: UpdateEvent) { + // For updates, we need to reload the full entity since event.entity only contains changed fields + let entity = event.entity; + + // Try different ways to get the entity ID + let entityId = event.entity?.id || event.databaseEntity?.id; + + if (!entityId && event.entity) { + // If we have the entity but no ID, try to extract it from the entity object + const entityKeys = Object.keys(event.entity); + + // Look for common ID field names + entityId = event.entity.id || event.entity.Id || event.entity.ID || event.entity._id; + } + + if (entityId) { + // Reload the full entity from the database + const repository = AppDataSource.getRepository(event.metadata.target); + + // Determine relations based on entity type + let relations: string[] = []; + if (event.metadata.tableName === "messages") { + relations = ["sender", "group", "group.members", "group.admins", "group.participants"]; + } else if (event.metadata.tableName === "groups") { + relations = ["members", "admins", "participants"]; + } + + const fullEntity = await repository.findOne({ + where: { id: entityId }, + relations: relations.length > 0 ? relations : undefined + }); + + if (fullEntity) { + entity = (await this.enrichEntity( + fullEntity, + event.metadata.tableName, + event.metadata.target + )) as ObjectLiteral; + } + } + + // Special handling for Message entities to ensure complete data + if (event.metadata.tableName === "messages" && entity) { + entity = await this.enrichMessageEntity(entity); + } + + this.handleChange( + entity ?? event.entity, + event.metadata.tableName.endsWith("s") + ? event.metadata.tableName + : event.metadata.tableName + "s" + ); + } + + async afterRemove(event: RemoveEvent) { + let entity = event.entity; + if (entity) { + entity = (await this.enrichEntity( + entity, + event.metadata.tableName, + event.metadata.target + )) as ObjectLiteral; + } + this.handleChange( + entity ?? event.entityId, + event.metadata.tableName + ); + } + + private async handleChange(entity: any, tableName: string): Promise { + // Handle users, groups, and messages + if (tableName !== "users" && tableName !== "groups" && tableName !== "messages") { + return; + } + + const data = this.entityToPlain(entity); + if (!data.id) return; + + const changeKey = `${tableName}:${entity.id}`; + + if (this.pendingChanges.has(changeKey)) { + return; + } + + this.pendingChanges.set(changeKey, Date.now()); + + try { + setTimeout(async () => { + try { + let globalId = await this.adapter.mappingDb.getGlobalId( + entity.id + ); + globalId = globalId ?? ""; + + if (this.adapter.lockedIds.includes(globalId)) { + return; + } + + // Check if this entity was recently created by a webhook + if (this.adapter.lockedIds.includes(entity.id)) { + return; + } + + const envelope = await this.adapter.handleChange({ + data, + tableName: tableName.toLowerCase(), + }); + } finally { + this.pendingChanges.delete(changeKey); + } + }, 3_000); + } catch (error) { + console.error(`Error processing change for ${tableName}:`, error); + this.pendingChanges.delete(changeKey); + } + } + + private entityToPlain(entity: any): any { + if (!entity) return {}; + + if (typeof entity !== "object" || entity === null) { + return entity; + } + + if (entity instanceof Date) { + return entity.toISOString(); + } + + if (Array.isArray(entity)) { + return entity.map((item) => this.entityToPlain(item)); + } + + const plain: Record = {}; + for (const [key, value] of Object.entries(entity)) { + if (key.startsWith("_")) continue; + + if (value && typeof value === "object") { + if (Array.isArray(value)) { + plain[key] = value.map((item) => this.entityToPlain(item)); + } else if (value instanceof Date) { + plain[key] = value.toISOString(); + } else { + plain[key] = this.entityToPlain(value); + } + } else { + plain[key] = value; + } + } + + return plain; + } +} + diff --git a/platforms/file-manager-api/tsconfig.json b/platforms/file-manager-api/tsconfig.json new file mode 100644 index 000000000..92d87d392 --- /dev/null +++ b/platforms/file-manager-api/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "moduleResolution": "node", + "baseUrl": "./src", + "esModuleInterop": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "typeRoots": [ + "./src/types", + "./node_modules/@types" + ] + }, + "include": ["src/**/*", "src/**/*.json"], + "exclude": ["node_modules", "dist"] +} + diff --git a/platforms/file-manager/.svelte-kit/ambient.d.ts b/platforms/file-manager/.svelte-kit/ambient.d.ts new file mode 100644 index 000000000..d9bad4f81 --- /dev/null +++ b/platforms/file-manager/.svelte-kit/ambient.d.ts @@ -0,0 +1,382 @@ + +// this file is generated — do not edit it + + +/// + +/** + * Environment variables [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env`. Like [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private), this module cannot be imported into client-side code. This module only includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://svelte.dev/docs/kit/configuration#env) (if configured). + * + * _Unlike_ [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private), the values exported from this module are statically injected into your bundle at build time, enabling optimisations like dead code elimination. + * + * ```ts + * import { API_KEY } from '$env/static/private'; + * ``` + * + * Note that all environment variables referenced in your code should be declared (for example in an `.env` file), even if they don't have a value until the app is deployed: + * + * ``` + * MY_FEATURE_FLAG="" + * ``` + * + * You can override `.env` values from the command line like so: + * + * ```sh + * MY_FEATURE_FLAG="enabled" npm run dev + * ``` + */ +declare module '$env/static/private' { + export const NEO4J_URI: string; + export const NEO4J_USER: string; + export const NEO4J_PASSWORD: string; + export const REGISTRY_ENTROPY_KEY_JWK: string; + export const ENCRYPTION_PASSWORD: string; + export const W3ID: string; + export const REGISTRY_DATABASE_URL: string; + export const REGISTRY_SHARED_SECRET: string; + export const PROVISIONER_DATABASE_URL: string; + export const VERIFF_HMAC_KEY: string; + export const DUPLICATES_POLICY: string; + export const IP_ADDR: string; + export const PICTIQUE_DATABASE_URL: string; + export const PICTIQUE_MAPPING_DB_PATH: string; + export const BLABSY_MAPPING_DB_PATH: string; + export const DREAMSYNC_MAPPING_DB_PATH: string; + export const GROUP_CHARTER_MAPPING_DB_PATH: string; + export const CERBERUS_MAPPING_DB_PATH: string; + export const GOOGLE_APPLICATION_CREDENTIALS: string; + export const GROUP_CHARTER_DATABASE_URL: string; + export const CERBERUS_DATABASE_URL: string; + export const EVOTING_DATABASE_URL: string; + export const EVOTING_MAPPING_DB_PATH: string; + export const OPENAI_API_KEY: string; + export const NOTIFICATION_SHARED_SECRET: string; + export const DREAMSYNC_DATABASE_URL: string; + export const VITE_DREAMSYNC_BASE_URL: string; + export const ECURRENCY_DATABASE_URL: string; + export const ECURRENCY_MAPPING_DB_PATH: string; + export const VITE_ECURRENCY_BASE_URL: string; + export const JWT_SECRET: string; + export const EREPUTATION_DATABASE_URL: string; + export const EREPUTATION_MAPPING_DB_PATH: string; + export const VITE_EREPUTATION_BASE_URL: string; + export const ESIGNER_DATABASE_URL: string; + export const ESIGNER_MAPPING_DB_PATH: string; + export const FILE_MANAGER_DATABASE_URL: string; + export const FILE_MANAGER_MAPPING_DB_PATH: string; + export const LOAD_TEST_USER_COUNT: string; + export const SHELL: string; + export const LSCOLORS: string; + export const npm_command: string; + export const GHOSTTY_BIN_DIR: string; + export const COLORTERM: string; + export const TERM_PROGRAM_VERSION: string; + export const npm_config_optional: string; + export const FNM_ARCH: string; + export const TMUX: string; + export const npm_config_npm_globalconfig: string; + export const NODE: string; + export const JAVA_HOME: string; + export const npm_config_verify_deps_before_run: string; + export const npm_config__jsr_registry: string; + export const CLOJURE_HOME: string; + export const MEMORY_PRESSURE_WRITE: string; + export const TMUX_PLUGIN_MANAGER_PATH: string; + export const FNM_NODE_DIST_MIRROR: string; + export const npm_config_strict_peer_dependencies: string; + export const DESKTOP_SESSION: string; + export const ELECTRON_OZONE_PLATFORM_HINT: string; + export const XCURSOR_SIZE: string; + export const npm_config_globalconfig: string; + export const EDITOR: string; + export const XDG_SEAT: string; + export const PWD: string; + export const LOGNAME: string; + export const XDG_SESSION_DESKTOP: string; + export const QT_QPA_PLATFORMTHEME: string; + export const XDG_SESSION_TYPE: string; + export const SYSTEMD_EXEC_PID: string; + export const TERMINAL: string; + export const QT_QPA_PLATFORMTHEME_QT6: string; + export const MOTD_SHOWN: string; + export const GDM_LANG: string; + export const GHOSTTY_SHELL_FEATURES: string; + export const HOME: string; + export const USERNAME: string; + export const LANG: string; + export const FNM_COREPACK_ENABLED: string; + export const LS_COLORS: string; + export const XDG_CURRENT_DESKTOP: string; + export const npm_package_version: string; + export const MESA_GLSL_CACHE_MAX_SIZE: string; + export const MEMORY_PRESSURE_WATCH: string; + export const STARSHIP_SHELL: string; + export const WAYLAND_DISPLAY: string; + export const pnpm_config_verify_deps_before_run: string; + export const NIRI_SOCKET: string; + export const MANAGERPID: string; + export const INIT_CWD: string; + export const STARSHIP_SESSION_KEY: string; + export const QT_QPA_PLATFORM: string; + export const npm_lifecycle_script: string; + export const GHOSTTY_RESOURCES_DIR: string; + export const XDG_SESSION_CLASS: string; + export const ANDROID_HOME: string; + export const TERM: string; + export const TERMINFO: string; + export const npm_package_name: string; + export const ZSH: string; + export const USER: string; + export const npm_config_frozen_lockfile: string; + export const NDK_HOME: string; + export const TMUX_PANE: string; + export const DISPLAY: string; + export const npm_lifecycle_event: string; + export const SHLVL: string; + export const PAGER: string; + export const npm_config_manage_package_manager_versions: string; + export const FNM_VERSION_FILE_STRATEGY: string; + export const XDG_VTNR: string; + export const XDG_SESSION_ID: string; + export const MANAGERPIDFDID: string; + export const npm_config_user_agent: string; + export const PNPM_SCRIPT_SRC_DIR: string; + export const npm_execpath: string; + export const XDG_RUNTIME_DIR: string; + export const FNM_RESOLVE_ENGINES: string; + export const mesa_glthread: string; + export const NODE_PATH: string; + export const DEBUGINFOD_URLS: string; + export const npm_package_json: string; + export const XCURSOR_THEME: string; + export const PATH: string; + export const npm_config_node_gyp: string; + export const GDMSESSION: string; + export const DBUS_SESSION_BUS_ADDRESS: string; + export const MAIL: string; + export const npm_config_registry: string; + export const MESA_SHADER_CACHE_DIR: string; + export const FNM_DIR: string; + export const FNM_MULTISHELL_PATH: string; + export const npm_node_execpath: string; + export const FNM_LOGLEVEL: string; + export const OLDPWD: string; + export const TERM_PROGRAM: string; + export const NODE_ENV: string; +} + +/** + * Similar to [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private), except that it only includes environment variables that begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) (which defaults to `PUBLIC_`), and can therefore safely be exposed to client-side code. + * + * Values are replaced statically at build time. + * + * ```ts + * import { PUBLIC_BASE_URL } from '$env/static/public'; + * ``` + */ +declare module '$env/static/public' { + export const PUBLIC_EVAULT_SERVER_URI: string; + export const PUBLIC_VERIFF_KEY: string; + export const PUBLIC_REGISTRY_URL: string; + export const PUBLIC_PROVISIONER_URL: string; + export const PUBLIC_PICTIQUE_URL: string; + export const PUBLIC_PICTIQUE_BASE_URL: string; + export const PUBLIC_BLABSY_URL: string; + export const PUBLIC_BLABSY_BASE_URL: string; + export const PUBLIC_GROUP_CHARTER_BASE_URL: string; + export const PUBLIC_CERBERUS_BASE_URL: string; + export const PUBLIC_EVOTING_BASE_URL: string; + export const PUBLIC_EVOTING_URL: string; + export const PUBLIC_APP_STORE_EID_WALLET: string; + export const PUBLIC_PLAY_STORE_EID_WALLET: string; + export const PUBLIC_ESIGNER_BASE_URL: string; + export const PUBLIC_FILE_MANAGER_BASE_URL: string; +} + +/** + * This module provides access to runtime environment variables, as defined by the platform you're running on. For example if you're using [`adapter-node`](https://github.com/sveltejs/kit/tree/main/packages/adapter-node) (or running [`vite preview`](https://svelte.dev/docs/kit/cli)), this is equivalent to `process.env`. This module only includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://svelte.dev/docs/kit/configuration#env) (if configured). + * + * This module cannot be imported into client-side code. + * + * ```ts + * import { env } from '$env/dynamic/private'; + * console.log(env.DEPLOYMENT_SPECIFIC_VARIABLE); + * ``` + * + * > [!NOTE] In `dev`, `$env/dynamic` always includes environment variables from `.env`. In `prod`, this behavior will depend on your adapter. + */ +declare module '$env/dynamic/private' { + export const env: { + NEO4J_URI: string; + NEO4J_USER: string; + NEO4J_PASSWORD: string; + REGISTRY_ENTROPY_KEY_JWK: string; + ENCRYPTION_PASSWORD: string; + W3ID: string; + REGISTRY_DATABASE_URL: string; + REGISTRY_SHARED_SECRET: string; + PROVISIONER_DATABASE_URL: string; + VERIFF_HMAC_KEY: string; + DUPLICATES_POLICY: string; + IP_ADDR: string; + PICTIQUE_DATABASE_URL: string; + PICTIQUE_MAPPING_DB_PATH: string; + BLABSY_MAPPING_DB_PATH: string; + DREAMSYNC_MAPPING_DB_PATH: string; + GROUP_CHARTER_MAPPING_DB_PATH: string; + CERBERUS_MAPPING_DB_PATH: string; + GOOGLE_APPLICATION_CREDENTIALS: string; + GROUP_CHARTER_DATABASE_URL: string; + CERBERUS_DATABASE_URL: string; + EVOTING_DATABASE_URL: string; + EVOTING_MAPPING_DB_PATH: string; + OPENAI_API_KEY: string; + NOTIFICATION_SHARED_SECRET: string; + DREAMSYNC_DATABASE_URL: string; + VITE_DREAMSYNC_BASE_URL: string; + ECURRENCY_DATABASE_URL: string; + ECURRENCY_MAPPING_DB_PATH: string; + VITE_ECURRENCY_BASE_URL: string; + JWT_SECRET: string; + EREPUTATION_DATABASE_URL: string; + EREPUTATION_MAPPING_DB_PATH: string; + VITE_EREPUTATION_BASE_URL: string; + ESIGNER_DATABASE_URL: string; + ESIGNER_MAPPING_DB_PATH: string; + FILE_MANAGER_DATABASE_URL: string; + FILE_MANAGER_MAPPING_DB_PATH: string; + LOAD_TEST_USER_COUNT: string; + SHELL: string; + LSCOLORS: string; + npm_command: string; + GHOSTTY_BIN_DIR: string; + COLORTERM: string; + TERM_PROGRAM_VERSION: string; + npm_config_optional: string; + FNM_ARCH: string; + TMUX: string; + npm_config_npm_globalconfig: string; + NODE: string; + JAVA_HOME: string; + npm_config_verify_deps_before_run: string; + npm_config__jsr_registry: string; + CLOJURE_HOME: string; + MEMORY_PRESSURE_WRITE: string; + TMUX_PLUGIN_MANAGER_PATH: string; + FNM_NODE_DIST_MIRROR: string; + npm_config_strict_peer_dependencies: string; + DESKTOP_SESSION: string; + ELECTRON_OZONE_PLATFORM_HINT: string; + XCURSOR_SIZE: string; + npm_config_globalconfig: string; + EDITOR: string; + XDG_SEAT: string; + PWD: string; + LOGNAME: string; + XDG_SESSION_DESKTOP: string; + QT_QPA_PLATFORMTHEME: string; + XDG_SESSION_TYPE: string; + SYSTEMD_EXEC_PID: string; + TERMINAL: string; + QT_QPA_PLATFORMTHEME_QT6: string; + MOTD_SHOWN: string; + GDM_LANG: string; + GHOSTTY_SHELL_FEATURES: string; + HOME: string; + USERNAME: string; + LANG: string; + FNM_COREPACK_ENABLED: string; + LS_COLORS: string; + XDG_CURRENT_DESKTOP: string; + npm_package_version: string; + MESA_GLSL_CACHE_MAX_SIZE: string; + MEMORY_PRESSURE_WATCH: string; + STARSHIP_SHELL: string; + WAYLAND_DISPLAY: string; + pnpm_config_verify_deps_before_run: string; + NIRI_SOCKET: string; + MANAGERPID: string; + INIT_CWD: string; + STARSHIP_SESSION_KEY: string; + QT_QPA_PLATFORM: string; + npm_lifecycle_script: string; + GHOSTTY_RESOURCES_DIR: string; + XDG_SESSION_CLASS: string; + ANDROID_HOME: string; + TERM: string; + TERMINFO: string; + npm_package_name: string; + ZSH: string; + USER: string; + npm_config_frozen_lockfile: string; + NDK_HOME: string; + TMUX_PANE: string; + DISPLAY: string; + npm_lifecycle_event: string; + SHLVL: string; + PAGER: string; + npm_config_manage_package_manager_versions: string; + FNM_VERSION_FILE_STRATEGY: string; + XDG_VTNR: string; + XDG_SESSION_ID: string; + MANAGERPIDFDID: string; + npm_config_user_agent: string; + PNPM_SCRIPT_SRC_DIR: string; + npm_execpath: string; + XDG_RUNTIME_DIR: string; + FNM_RESOLVE_ENGINES: string; + mesa_glthread: string; + NODE_PATH: string; + DEBUGINFOD_URLS: string; + npm_package_json: string; + XCURSOR_THEME: string; + PATH: string; + npm_config_node_gyp: string; + GDMSESSION: string; + DBUS_SESSION_BUS_ADDRESS: string; + MAIL: string; + npm_config_registry: string; + MESA_SHADER_CACHE_DIR: string; + FNM_DIR: string; + FNM_MULTISHELL_PATH: string; + npm_node_execpath: string; + FNM_LOGLEVEL: string; + OLDPWD: string; + TERM_PROGRAM: string; + NODE_ENV: string; + [key: `PUBLIC_${string}`]: undefined; + [key: `${string}`]: string | undefined; + } +} + +/** + * Similar to [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private), but only includes variables that begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) (which defaults to `PUBLIC_`), and can therefore safely be exposed to client-side code. + * + * Note that public dynamic environment variables must all be sent from the server to the client, causing larger network requests — when possible, use `$env/static/public` instead. + * + * ```ts + * import { env } from '$env/dynamic/public'; + * console.log(env.PUBLIC_DEPLOYMENT_SPECIFIC_VARIABLE); + * ``` + */ +declare module '$env/dynamic/public' { + export const env: { + PUBLIC_EVAULT_SERVER_URI: string; + PUBLIC_VERIFF_KEY: string; + PUBLIC_REGISTRY_URL: string; + PUBLIC_PROVISIONER_URL: string; + PUBLIC_PICTIQUE_URL: string; + PUBLIC_PICTIQUE_BASE_URL: string; + PUBLIC_BLABSY_URL: string; + PUBLIC_BLABSY_BASE_URL: string; + PUBLIC_GROUP_CHARTER_BASE_URL: string; + PUBLIC_CERBERUS_BASE_URL: string; + PUBLIC_EVOTING_BASE_URL: string; + PUBLIC_EVOTING_URL: string; + PUBLIC_APP_STORE_EID_WALLET: string; + PUBLIC_PLAY_STORE_EID_WALLET: string; + PUBLIC_ESIGNER_BASE_URL: string; + PUBLIC_FILE_MANAGER_BASE_URL: string; + [key: `PUBLIC_${string}`]: string | undefined; + } +} diff --git a/platforms/file-manager/.svelte-kit/generated/client/app.js b/platforms/file-manager/.svelte-kit/generated/client/app.js new file mode 100644 index 000000000..ea1b6623f --- /dev/null +++ b/platforms/file-manager/.svelte-kit/generated/client/app.js @@ -0,0 +1,36 @@ +export { matchers } from './matchers.js'; + +export const nodes = [ + () => import('./nodes/0'), + () => import('./nodes/1'), + () => import('./nodes/2'), + () => import('./nodes/3'), + () => import('./nodes/4'), + () => import('./nodes/5'), + () => import('./nodes/6') +]; + +export const server_loads = []; + +export const dictionary = { + "/": [3], + "/(auth)/auth": [4], + "/(protected)/files": [5,[2]], + "/(protected)/files/[id]": [6,[2]] + }; + +export const hooks = { + handleError: (({ error }) => { console.error(error) }), + + reroute: (() => {}), + transport: {} +}; + +export const decoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.decode])); +export const encoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.encode])); + +export const hash = false; + +export const decode = (type, value) => decoders[type](value); + +export { default as root } from '../root.js'; \ No newline at end of file diff --git a/platforms/file-manager/.svelte-kit/generated/client/matchers.js b/platforms/file-manager/.svelte-kit/generated/client/matchers.js new file mode 100644 index 000000000..f6bd30a4e --- /dev/null +++ b/platforms/file-manager/.svelte-kit/generated/client/matchers.js @@ -0,0 +1 @@ +export const matchers = {}; \ No newline at end of file diff --git a/platforms/file-manager/.svelte-kit/generated/client/nodes/0.js b/platforms/file-manager/.svelte-kit/generated/client/nodes/0.js new file mode 100644 index 000000000..fed1375f7 --- /dev/null +++ b/platforms/file-manager/.svelte-kit/generated/client/nodes/0.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/+layout.svelte"; \ No newline at end of file diff --git a/platforms/file-manager/.svelte-kit/generated/client/nodes/1.js b/platforms/file-manager/.svelte-kit/generated/client/nodes/1.js new file mode 100644 index 000000000..635016f9c --- /dev/null +++ b/platforms/file-manager/.svelte-kit/generated/client/nodes/1.js @@ -0,0 +1 @@ +export { default as component } from "../../../../../../node_modules/.pnpm/@sveltejs+kit@2.49.2_@opentelemetry+api@1.9.0_@sveltejs+vite-plugin-svelte@5.1.1_svelte_f2289347040f8edd51fabc9e5c8ef0b5/node_modules/@sveltejs/kit/src/runtime/components/svelte-5/error.svelte"; \ No newline at end of file diff --git a/platforms/file-manager/.svelte-kit/generated/client/nodes/2.js b/platforms/file-manager/.svelte-kit/generated/client/nodes/2.js new file mode 100644 index 000000000..e899b5cfd --- /dev/null +++ b/platforms/file-manager/.svelte-kit/generated/client/nodes/2.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/(protected)/+layout.svelte"; \ No newline at end of file diff --git a/platforms/file-manager/.svelte-kit/generated/client/nodes/3.js b/platforms/file-manager/.svelte-kit/generated/client/nodes/3.js new file mode 100644 index 000000000..1cb4f8552 --- /dev/null +++ b/platforms/file-manager/.svelte-kit/generated/client/nodes/3.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/+page.svelte"; \ No newline at end of file diff --git a/platforms/file-manager/.svelte-kit/generated/client/nodes/4.js b/platforms/file-manager/.svelte-kit/generated/client/nodes/4.js new file mode 100644 index 000000000..773b48116 --- /dev/null +++ b/platforms/file-manager/.svelte-kit/generated/client/nodes/4.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/(auth)/auth/+page.svelte"; \ No newline at end of file diff --git a/platforms/file-manager/.svelte-kit/generated/client/nodes/5.js b/platforms/file-manager/.svelte-kit/generated/client/nodes/5.js new file mode 100644 index 000000000..5362e6dbe --- /dev/null +++ b/platforms/file-manager/.svelte-kit/generated/client/nodes/5.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/(protected)/files/+page.svelte"; \ No newline at end of file diff --git a/platforms/file-manager/.svelte-kit/generated/client/nodes/6.js b/platforms/file-manager/.svelte-kit/generated/client/nodes/6.js new file mode 100644 index 000000000..8bd5dab8a --- /dev/null +++ b/platforms/file-manager/.svelte-kit/generated/client/nodes/6.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/(protected)/files/[id]/+page.svelte"; \ No newline at end of file diff --git a/platforms/file-manager/.svelte-kit/generated/root.js b/platforms/file-manager/.svelte-kit/generated/root.js new file mode 100644 index 000000000..4d1e8929f --- /dev/null +++ b/platforms/file-manager/.svelte-kit/generated/root.js @@ -0,0 +1,3 @@ +import { asClassComponent } from 'svelte/legacy'; +import Root from './root.svelte'; +export default asClassComponent(Root); \ No newline at end of file diff --git a/platforms/file-manager/.svelte-kit/generated/root.svelte b/platforms/file-manager/.svelte-kit/generated/root.svelte new file mode 100644 index 000000000..1c16fc867 --- /dev/null +++ b/platforms/file-manager/.svelte-kit/generated/root.svelte @@ -0,0 +1,80 @@ + + + + +{#if constructors[1]} + {@const Pyramid_0 = constructors[0]} + + + {#if constructors[2]} + {@const Pyramid_1 = constructors[1]} + + + + + + + {:else} + {@const Pyramid_1 = constructors[1]} + + + + {/if} + + +{:else} + {@const Pyramid_0 = constructors[0]} + + + +{/if} + +{#if mounted} +
+ {#if navigated} + {title} + {/if} +
+{/if} \ No newline at end of file diff --git a/platforms/file-manager/.svelte-kit/generated/server/internal.js b/platforms/file-manager/.svelte-kit/generated/server/internal.js new file mode 100644 index 000000000..a359d726f --- /dev/null +++ b/platforms/file-manager/.svelte-kit/generated/server/internal.js @@ -0,0 +1,53 @@ + +import root from '../root.js'; +import { set_building, set_prerendering } from '__sveltekit/environment'; +import { set_assets } from '$app/paths/internal/server'; +import { set_manifest, set_read_implementation } from '__sveltekit/server'; +import { set_private_env, set_public_env } from '../../../../../node_modules/.pnpm/@sveltejs+kit@2.49.2_@opentelemetry+api@1.9.0_@sveltejs+vite-plugin-svelte@5.1.1_svelte_f2289347040f8edd51fabc9e5c8ef0b5/node_modules/@sveltejs/kit/src/runtime/shared-server.js'; + +export const options = { + app_template_contains_nonce: false, + async: false, + csp: {"mode":"auto","directives":{"upgrade-insecure-requests":false,"block-all-mixed-content":false},"reportOnly":{"upgrade-insecure-requests":false,"block-all-mixed-content":false}}, + csrf_check_origin: true, + csrf_trusted_origins: [], + embedded: false, + env_public_prefix: 'PUBLIC_', + env_private_prefix: '', + hash_routing: false, + hooks: null, // added lazily, via `get_hooks` + preload_strategy: "modulepreload", + root, + service_worker: false, + service_worker_options: undefined, + templates: { + app: ({ head, body, assets, nonce, env }) => "\n\n\t\n\t\t\n\t\t\n\t\t" + head + "\n\t\n\t\n\t\t
" + body + "
\n\t\n\n\n", + error: ({ status, message }) => "\n\n\t\n\t\t\n\t\t" + message + "\n\n\t\t\n\t\n\t\n\t\t
\n\t\t\t" + status + "\n\t\t\t
\n\t\t\t\t

" + message + "

\n\t\t\t
\n\t\t
\n\t\n\n" + }, + version_hash: "9p1n1b" +}; + +export async function get_hooks() { + let handle; + let handleFetch; + let handleError; + let handleValidationError; + let init; + + + let reroute; + let transport; + + + return { + handle, + handleFetch, + handleError, + handleValidationError, + init, + reroute, + transport + }; +} + +export { set_assets, set_building, set_manifest, set_prerendering, set_private_env, set_public_env, set_read_implementation }; diff --git a/platforms/file-manager/.svelte-kit/non-ambient.d.ts b/platforms/file-manager/.svelte-kit/non-ambient.d.ts new file mode 100644 index 000000000..f5e4e64c9 --- /dev/null +++ b/platforms/file-manager/.svelte-kit/non-ambient.d.ts @@ -0,0 +1,46 @@ + +// this file is generated — do not edit it + + +declare module "svelte/elements" { + export interface HTMLAttributes { + 'data-sveltekit-keepfocus'?: true | '' | 'off' | undefined | null; + 'data-sveltekit-noscroll'?: true | '' | 'off' | undefined | null; + 'data-sveltekit-preload-code'?: + | true + | '' + | 'eager' + | 'viewport' + | 'hover' + | 'tap' + | 'off' + | undefined + | null; + 'data-sveltekit-preload-data'?: true | '' | 'hover' | 'tap' | 'off' | undefined | null; + 'data-sveltekit-reload'?: true | '' | 'off' | undefined | null; + 'data-sveltekit-replacestate'?: true | '' | 'off' | undefined | null; + } +} + +export {}; + + +declare module "$app/types" { + export interface AppTypes { + RouteId(): "/(protected)" | "/(auth)" | "/" | "/(auth)/auth" | "/(protected)/files" | "/(protected)/files/[id]"; + RouteParams(): { + "/(protected)/files/[id]": { id: string } + }; + LayoutParams(): { + "/(protected)": { id?: string }; + "/(auth)": Record; + "/": { id?: string }; + "/(auth)/auth": Record; + "/(protected)/files": { id?: string }; + "/(protected)/files/[id]": { id: string } + }; + Pathname(): "/" | "/auth" | "/auth/" | "/files" | "/files/" | `/files/${string}` & {} | `/files/${string}/` & {}; + ResolvedPathname(): `${"" | `/${string}`}${ReturnType}`; + Asset(): string & {}; + } +} \ No newline at end of file diff --git a/platforms/file-manager/.svelte-kit/tsconfig.json b/platforms/file-manager/.svelte-kit/tsconfig.json new file mode 100644 index 000000000..64aad0734 --- /dev/null +++ b/platforms/file-manager/.svelte-kit/tsconfig.json @@ -0,0 +1,52 @@ +{ + "compilerOptions": { + "paths": { + "$lib": [ + "../src/lib" + ], + "$lib/*": [ + "../src/lib/*" + ], + "$app/types": [ + "./types/index.d.ts" + ] + }, + "rootDirs": [ + "..", + "./types" + ], + "verbatimModuleSyntax": true, + "isolatedModules": true, + "lib": [ + "esnext", + "DOM", + "DOM.Iterable" + ], + "moduleResolution": "bundler", + "module": "esnext", + "noEmit": true, + "target": "esnext" + }, + "include": [ + "ambient.d.ts", + "non-ambient.d.ts", + "./types/**/$types.d.ts", + "../vite.config.js", + "../vite.config.ts", + "../src/**/*.js", + "../src/**/*.ts", + "../src/**/*.svelte", + "../tests/**/*.js", + "../tests/**/*.ts", + "../tests/**/*.svelte" + ], + "exclude": [ + "../node_modules/**", + "../src/service-worker.js", + "../src/service-worker/**/*.js", + "../src/service-worker.ts", + "../src/service-worker/**/*.ts", + "../src/service-worker.d.ts", + "../src/service-worker/**/*.d.ts" + ] +} \ No newline at end of file diff --git a/platforms/file-manager/.svelte-kit/types/route_meta_data.json b/platforms/file-manager/.svelte-kit/types/route_meta_data.json new file mode 100644 index 000000000..2d10d24b0 --- /dev/null +++ b/platforms/file-manager/.svelte-kit/types/route_meta_data.json @@ -0,0 +1,7 @@ +{ + "/(protected)": [], + "/": [], + "/(auth)/auth": [], + "/(protected)/files": [], + "/(protected)/files/[id]": [] +} \ No newline at end of file diff --git a/platforms/file-manager/.svelte-kit/types/src/routes/$types.d.ts b/platforms/file-manager/.svelte-kit/types/src/routes/$types.d.ts new file mode 100644 index 000000000..a56205933 --- /dev/null +++ b/platforms/file-manager/.svelte-kit/types/src/routes/$types.d.ts @@ -0,0 +1,24 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageParentData = EnsureDefined; +type LayoutRouteId = RouteId | "/" | "/(auth)/auth" | "/(protected)/files" | "/(protected)/files/[id]" | null +type LayoutParams = RouteParams & { id?: string } +type LayoutParentData = EnsureDefined<{}>; + +export type PageServerData = null; +export type PageData = Expand; +export type PageProps = { params: RouteParams; data: PageData } +export type LayoutServerData = null; +export type LayoutData = Expand; +export type LayoutProps = { params: LayoutParams; data: LayoutData; children: import("svelte").Snippet } \ No newline at end of file diff --git a/platforms/file-manager/.svelte-kit/types/src/routes/(auth)/auth/$types.d.ts b/platforms/file-manager/.svelte-kit/types/src/routes/(auth)/auth/$types.d.ts new file mode 100644 index 000000000..b22693e94 --- /dev/null +++ b/platforms/file-manager/.svelte-kit/types/src/routes/(auth)/auth/$types.d.ts @@ -0,0 +1,18 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/(auth)/auth'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageParentData = EnsureDefined; + +export type PageServerData = null; +export type PageData = Expand; +export type PageProps = { params: RouteParams; data: PageData } \ No newline at end of file diff --git a/platforms/file-manager/.svelte-kit/types/src/routes/(protected)/$types.d.ts b/platforms/file-manager/.svelte-kit/types/src/routes/(protected)/$types.d.ts new file mode 100644 index 000000000..dd22fdf5c --- /dev/null +++ b/platforms/file-manager/.svelte-kit/types/src/routes/(protected)/$types.d.ts @@ -0,0 +1,20 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/(protected)'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type LayoutRouteId = RouteId | "/(protected)/files" | "/(protected)/files/[id]" +type LayoutParams = RouteParams & { id?: string } +type LayoutParentData = EnsureDefined; + +export type LayoutServerData = null; +export type LayoutData = Expand; +export type LayoutProps = { params: LayoutParams; data: LayoutData; children: import("svelte").Snippet } \ No newline at end of file diff --git a/platforms/file-manager/.svelte-kit/types/src/routes/(protected)/files/$types.d.ts b/platforms/file-manager/.svelte-kit/types/src/routes/(protected)/files/$types.d.ts new file mode 100644 index 000000000..9a8701d2d --- /dev/null +++ b/platforms/file-manager/.svelte-kit/types/src/routes/(protected)/files/$types.d.ts @@ -0,0 +1,18 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/(protected)/files'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageParentData = Omit, keyof import('../$types.js').LayoutData> & EnsureDefined; + +export type PageServerData = null; +export type PageData = Expand; +export type PageProps = { params: RouteParams; data: PageData } \ No newline at end of file diff --git a/platforms/file-manager/.svelte-kit/types/src/routes/(protected)/files/[id]/$types.d.ts b/platforms/file-manager/.svelte-kit/types/src/routes/(protected)/files/[id]/$types.d.ts new file mode 100644 index 000000000..70863a49e --- /dev/null +++ b/platforms/file-manager/.svelte-kit/types/src/routes/(protected)/files/[id]/$types.d.ts @@ -0,0 +1,19 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { id: string }; +type RouteId = '/(protected)/files/[id]'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageParentData = Omit, keyof import('../../$types.js').LayoutData> & EnsureDefined; + +export type EntryGenerator = () => Promise> | Array; +export type PageServerData = null; +export type PageData = Expand; +export type PageProps = { params: RouteParams; data: PageData } \ No newline at end of file diff --git a/platforms/file-manager/package.json b/platforms/file-manager/package.json new file mode 100644 index 000000000..f88e0ce7c --- /dev/null +++ b/platforms/file-manager/package.json @@ -0,0 +1,46 @@ +{ + "name": "file-manager", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev --host", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "format": "prettier --write .", + "lint": "prettier --check . && eslint ." + }, + "devDependencies": { + "@eslint/compat": "^1.2.5", + "@eslint/js": "^9.18.0", + "@sveltejs/adapter-static": "^3.0.8", + "@sveltejs/kit": "^2.16.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/vite": "^4.0.0", + "clsx": "^2.1.1", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-svelte": "^3.0.0", + "globals": "^16.0.0", + "prettier": "^3.4.2", + "prettier-plugin-svelte": "^3.3.3", + "prettier-plugin-tailwindcss": "^0.7.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.0.0", + "typescript-eslint": "^8.20.0", + "vite": "^6.2.6" + }, + "dependencies": { + "@sveltejs/adapter-node": "^5.2.12", + "axios": "^1.6.7", + "svelte-qrcode": "^1.0.1", + "svelte-qrcode-action": "^1.0.2", + "tailwind-merge": "^3.0.2" + } +} + diff --git a/platforms/file-manager/src/app.css b/platforms/file-manager/src/app.css new file mode 100644 index 000000000..ea5d081ba --- /dev/null +++ b/platforms/file-manager/src/app.css @@ -0,0 +1,7 @@ +@import 'tailwindcss'; + +body { + font-family: system-ui, -apple-system, sans-serif; + background-color: white; +} + diff --git a/platforms/file-manager/src/app.d.ts b/platforms/file-manager/src/app.d.ts new file mode 100644 index 000000000..03dda6813 --- /dev/null +++ b/platforms/file-manager/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://kit.svelte.dev/docs/types#app +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface Platform {} + } +} + +export {}; + diff --git a/platforms/file-manager/src/app.html b/platforms/file-manager/src/app.html new file mode 100644 index 000000000..86c2e23aa --- /dev/null +++ b/platforms/file-manager/src/app.html @@ -0,0 +1,12 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + + diff --git a/platforms/file-manager/src/lib/components/ToastContainer.svelte b/platforms/file-manager/src/lib/components/ToastContainer.svelte new file mode 100644 index 000000000..f283be039 --- /dev/null +++ b/platforms/file-manager/src/lib/components/ToastContainer.svelte @@ -0,0 +1,82 @@ + + +
+ {#each $toasts as toast} + + {/each} +
+ + + diff --git a/platforms/file-manager/src/lib/components/UserMenuDropdown.svelte b/platforms/file-manager/src/lib/components/UserMenuDropdown.svelte new file mode 100644 index 000000000..942ec3356 --- /dev/null +++ b/platforms/file-manager/src/lib/components/UserMenuDropdown.svelte @@ -0,0 +1,88 @@ + + +
+ + + {#if isOpen} + +
+ + +
+ +
+
+ {$currentUser?.name || $currentUser?.ename || 'User'} +
+ {#if $currentUser?.ename && $currentUser?.name} +
{$currentUser.ename}
+ {/if} +
+ + +
+ +
+
+ {/if} +
+ diff --git a/platforms/file-manager/src/lib/stores/access.ts b/platforms/file-manager/src/lib/stores/access.ts new file mode 100644 index 000000000..48e57e72c --- /dev/null +++ b/platforms/file-manager/src/lib/stores/access.ts @@ -0,0 +1,105 @@ +import { writable } from 'svelte/store'; +import { apiClient } from '$lib/utils/axios'; + +export const fileAccess = writable([]); +export const folderAccess = writable([]); +export const isLoading = writable(false); + +export interface FileAccess { + id: string; + fileId: string; + userId: string; + user: { + id: string; + name: string; + ename: string; + avatarUrl: string; + } | null; + grantedBy: string; + granter: { + id: string; + name: string; + ename: string; + } | null; + permission: 'view'; + createdAt: string; +} + +export interface FolderAccess { + id: string; + folderId: string; + userId: string; + user: { + id: string; + name: string; + ename: string; + avatarUrl: string; + } | null; + grantedBy: string; + granter: { + id: string; + name: string; + ename: string; + } | null; + permission: 'view'; + createdAt: string; +} + +export const fetchFileAccess = async (fileId: string) => { + try { + isLoading.set(true); + const response = await apiClient.get(`/api/files/${fileId}/access`); + fileAccess.set(response.data || []); + } catch (error) { + console.error('Failed to fetch file access:', error); + fileAccess.set([]); + } finally { + isLoading.set(false); + } +}; + +export const fetchFolderAccess = async (folderId: string) => { + try { + isLoading.set(true); + const response = await apiClient.get(`/api/folders/${folderId}/access`); + folderAccess.set(response.data || []); + } catch (error) { + console.error('Failed to fetch folder access:', error); + folderAccess.set([]); + } finally { + isLoading.set(false); + } +}; + +export const grantFileAccess = async (fileId: string, userId: string): Promise => { + const response = await apiClient.post(`/api/files/${fileId}/access`, { + userId, + permission: 'view', + }); + + const newAccess = response.data; + fileAccess.update(access => [...access, newAccess]); + return newAccess; +}; + +export const revokeFileAccess = async (fileId: string, userId: string): Promise => { + await apiClient.delete(`/api/files/${fileId}/access/${userId}`); + fileAccess.update(access => access.filter(a => !(a.fileId === fileId && a.userId === userId))); +}; + +export const grantFolderAccess = async (folderId: string, userId: string): Promise => { + const response = await apiClient.post(`/api/folders/${folderId}/access`, { + userId, + permission: 'view', + }); + + const newAccess = response.data; + folderAccess.update(access => [...access, newAccess]); + return newAccess; +}; + +export const revokeFolderAccess = async (folderId: string, userId: string): Promise => { + await apiClient.delete(`/api/folders/${folderId}/access/${userId}`); + folderAccess.update(access => access.filter(a => !(a.folderId === folderId && a.userId === userId))); +}; + diff --git a/platforms/file-manager/src/lib/stores/auth.ts b/platforms/file-manager/src/lib/stores/auth.ts new file mode 100644 index 000000000..fb37e1a26 --- /dev/null +++ b/platforms/file-manager/src/lib/stores/auth.ts @@ -0,0 +1,77 @@ +import { writable } from 'svelte/store'; +import { apiClient, setAuthToken, removeAuthToken, removeAuthId } from '$lib/utils/axios'; + +export const isAuthenticated = writable(false); +export const currentUser = writable(null); +export const authInitialized = writable(false); + +export const initializeAuth = async () => { + authInitialized.set(false); + const token = localStorage.getItem('file_manager_auth_token'); + if (token) { + // Set token in axios headers immediately + apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`; + // Verify token is still valid by fetching current user + try { + const response = await apiClient.get('/api/users'); + if (response.data) { + currentUser.set(response.data); + isAuthenticated.set(true); + authInitialized.set(true); + return true; + } + } catch (err) { + // Token invalid, clear it + console.error('Auth token invalid:', err); + removeAuthToken(); + removeAuthId(); + delete apiClient.defaults.headers.common['Authorization']; + } + } + isAuthenticated.set(false); + currentUser.set(null); + authInitialized.set(true); + return false; +}; + +export const login = async (token: string, user?: any) => { + // Store token in localStorage first + setAuthToken(token); + // Set token in axios headers + apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`; + + // Set user if provided + if (user) { + currentUser.set(user); + isAuthenticated.set(true); + } + + // Verify by fetching user to ensure token is valid + try { + const response = await apiClient.get('/api/users'); + if (response.data) { + currentUser.set(response.data); + isAuthenticated.set(true); + return true; + } + } catch (err) { + console.error('Failed to verify login:', err); + // If verification fails, clear everything + removeAuthToken(); + removeAuthId(); + delete apiClient.defaults.headers.common['Authorization']; + isAuthenticated.set(false); + currentUser.set(null); + return false; + } + return true; +}; + +export const logout = () => { + removeAuthToken(); + removeAuthId(); + delete apiClient.defaults.headers.common['Authorization']; + isAuthenticated.set(false); + currentUser.set(null); +}; + diff --git a/platforms/file-manager/src/lib/stores/files.ts b/platforms/file-manager/src/lib/stores/files.ts new file mode 100644 index 000000000..840750ad3 --- /dev/null +++ b/platforms/file-manager/src/lib/stores/files.ts @@ -0,0 +1,97 @@ +import { writable } from 'svelte/store'; +import { apiClient } from '$lib/utils/axios'; + +export const files = writable([]); +export const isLoading = writable(false); + +export interface File { + id: string; + name: string; + displayName: string | null; + description: string | null; + mimeType: string; + size: number; + md5Hash: string; + ownerId: string; + folderId: string | null; + createdAt: string; + updatedAt: string; + canPreview: boolean; +} + +export const fetchFiles = async (folderId?: string | null) => { + try { + isLoading.set(true); + // Always pass folderId - use 'null' string for root, or the actual folderId + const params: any = {}; + if (folderId === null || folderId === undefined) { + params.folderId = 'null'; + } else { + params.folderId = folderId; + } + const response = await apiClient.get('/api/files', { params }); + files.set(response.data || []); + } catch (error) { + console.error('Failed to fetch files:', error); + files.set([]); + } finally { + isLoading.set(false); + } +}; + +export const uploadFile = async ( + file: globalThis.File, + folderId?: string | null, + displayName?: string, + description?: string +): Promise => { + const formData = new FormData(); + formData.append('file', file); + if (folderId !== undefined) { + formData.append('folderId', folderId || 'null'); + } + if (displayName) { + formData.append('displayName', displayName); + } + if (description) { + formData.append('description', description); + } + + const response = await apiClient.post('/api/files', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + const newFile = response.data; + files.update(files => [newFile, ...files]); + return newFile; +}; + +export const deleteFile = async (fileId: string): Promise => { + await apiClient.delete(`/api/files/${fileId}`); + files.update(files => files.filter(f => f.id !== fileId)); +}; + +export const updateFile = async ( + fileId: string, + displayName?: string, + description?: string, + folderId?: string | null +): Promise => { + const response = await apiClient.patch(`/api/files/${fileId}`, { + displayName, + description, + folderId: folderId !== undefined ? (folderId || 'null') : undefined, + }); + + const updatedFile = response.data; + files.update(files => files.map(f => f.id === fileId ? updatedFile : f)); + return updatedFile; +}; + +export const moveFile = async (fileId: string, folderId: string | null): Promise => { + await apiClient.post(`/api/files/${fileId}/move`, { folderId: folderId || 'null' }); + await fetchFiles(); +}; + diff --git a/platforms/file-manager/src/lib/stores/folders.ts b/platforms/file-manager/src/lib/stores/folders.ts new file mode 100644 index 000000000..80f9b0977 --- /dev/null +++ b/platforms/file-manager/src/lib/stores/folders.ts @@ -0,0 +1,90 @@ +import { writable } from 'svelte/store'; +import { apiClient } from '$lib/utils/axios'; + +export const folders = writable([]); +export const folderTree = writable([]); +export const isLoading = writable(false); + +export interface Folder { + id: string; + name: string; + ownerId: string; + parentFolderId: string | null; + createdAt: string; + updatedAt: string; +} + +export const fetchFolders = async (parentFolderId?: string | null) => { + try { + isLoading.set(true); + // Always pass parentFolderId - use 'null' string for root, or the actual parentFolderId + const params: any = {}; + if (parentFolderId === null || parentFolderId === undefined) { + params.parentFolderId = 'null'; + } else { + params.parentFolderId = parentFolderId; + } + const response = await apiClient.get('/api/folders', { params }); + folders.set(response.data || []); + } catch (error) { + console.error('Failed to fetch folders:', error); + folders.set([]); + } finally { + isLoading.set(false); + } +}; + +export const fetchFolderTree = async () => { + try { + const response = await apiClient.get('/api/folders/tree'); + folderTree.set(response.data || []); + } catch (error) { + console.error('Failed to fetch folder tree:', error); + folderTree.set([]); + } +}; + +export const createFolder = async ( + name: string, + parentFolderId?: string | null +): Promise => { + const response = await apiClient.post('/api/folders', { + name, + parentFolderId: parentFolderId || null, + }); + + const newFolder = response.data; + folders.update(folders => [newFolder, ...folders]); + return newFolder; +}; + +export const deleteFolder = async (folderId: string): Promise => { + await apiClient.delete(`/api/folders/${folderId}`); + folders.update(folders => folders.filter(f => f.id !== folderId)); +}; + +export const updateFolder = async ( + folderId: string, + name?: string, + parentFolderId?: string | null +): Promise => { + const response = await apiClient.patch(`/api/folders/${folderId}`, { + name, + parentFolderId: parentFolderId !== undefined ? (parentFolderId || 'null') : undefined, + }); + + const updatedFolder = response.data; + folders.update(folders => folders.map(f => f.id === folderId ? updatedFolder : f)); + return updatedFolder; +}; + +export const moveFolder = async (folderId: string, parentFolderId: string | null): Promise => { + await apiClient.post(`/api/folders/${folderId}/move`, { parentFolderId: parentFolderId || 'null' }); + await fetchFolders(); +}; + +export const getFolderContents = async (folderId: string): Promise<{ files: any[]; folders: any[] }> => { + const response = await apiClient.get(`/api/folders/${folderId}/contents`); + return response.data; +}; + diff --git a/platforms/file-manager/src/lib/stores/tags.ts b/platforms/file-manager/src/lib/stores/tags.ts new file mode 100644 index 000000000..0271cd77e --- /dev/null +++ b/platforms/file-manager/src/lib/stores/tags.ts @@ -0,0 +1,70 @@ +import { writable } from 'svelte/store'; +import { apiClient } from '$lib/utils/axios'; + +export const tags = writable([]); +export const isLoading = writable(false); + +export interface Tag { + id: string; + name: string; + color: string | null; + ownerId: string; + createdAt: string; +} + +export const fetchTags = async () => { + try { + isLoading.set(true); + const response = await apiClient.get('/api/tags'); + tags.set(response.data || []); + } catch (error) { + console.error('Failed to fetch tags:', error); + tags.set([]); + } finally { + isLoading.set(false); + } +}; + +export const createTag = async (name: string, color?: string | null): Promise => { + const response = await apiClient.post('/api/tags', { name, color: color || null }); + const newTag = response.data; + tags.update(tags => [newTag, ...tags]); + return newTag; +}; + +export const updateTag = async ( + tagId: string, + name?: string, + color?: string | null +): Promise => { + const response = await apiClient.patch(`/api/tags/${tagId}`, { + name, + color: color !== undefined ? (color || null) : undefined, + }); + + const updatedTag = response.data; + tags.update(tags => tags.map(t => t.id === tagId ? updatedTag : t)); + return updatedTag; +}; + +export const deleteTag = async (tagId: string): Promise => { + await apiClient.delete(`/api/tags/${tagId}`); + tags.update(tags => tags.filter(t => t.id !== tagId)); +}; + +export const addTagToFile = async (fileId: string, tagId: string): Promise => { + await apiClient.post(`/api/files/${fileId}/tags`, { tagId }); +}; + +export const removeTagFromFile = async (fileId: string, tagId: string): Promise => { + await apiClient.delete(`/api/files/${fileId}/tags/${tagId}`); +}; + +export const addTagToFolder = async (folderId: string, tagId: string): Promise => { + await apiClient.post(`/api/folders/${folderId}/tags`, { tagId }); +}; + +export const removeTagFromFolder = async (folderId: string, tagId: string): Promise => { + await apiClient.delete(`/api/folders/${folderId}/tags/${tagId}`); +}; + diff --git a/platforms/file-manager/src/lib/stores/toast.ts b/platforms/file-manager/src/lib/stores/toast.ts new file mode 100644 index 000000000..ff612a496 --- /dev/null +++ b/platforms/file-manager/src/lib/stores/toast.ts @@ -0,0 +1,39 @@ +import { writable } from 'svelte/store'; + +export interface Toast { + id: string; + message: string; + type: 'success' | 'error' | 'info' | 'warning'; + duration?: number; +} + +export const toasts = writable([]); + +let toastIdCounter = 0; + +export function showToast(message: string, type: Toast['type'] = 'info', duration: number = 3000) { + const id = `toast-${toastIdCounter++}`; + const toast: Toast = { id, message, type, duration }; + + toasts.update((current) => [...current, toast]); + + if (duration > 0) { + setTimeout(() => { + removeToast(id); + }, duration); + } + + return id; +} + +export function removeToast(id: string) { + toasts.update((current) => current.filter((t) => t.id !== id)); +} + +export const toast = { + success: (message: string, duration?: number) => showToast(message, 'success', duration), + error: (message: string, duration?: number) => showToast(message, 'error', duration), + info: (message: string, duration?: number) => showToast(message, 'info', duration), + warning: (message: string, duration?: number) => showToast(message, 'warning', duration), +}; + diff --git a/platforms/file-manager/src/lib/utils/axios.ts b/platforms/file-manager/src/lib/utils/axios.ts new file mode 100644 index 000000000..e1d2d9863 --- /dev/null +++ b/platforms/file-manager/src/lib/utils/axios.ts @@ -0,0 +1,26 @@ +import axios from 'axios'; +import { PUBLIC_FILE_MANAGER_BASE_URL } from '$env/static/public'; + +const API_BASE_URL = PUBLIC_FILE_MANAGER_BASE_URL || 'http://localhost:3005'; + +export const apiClient = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); + +export const setAuthToken = (token: string) => { + localStorage.setItem('file_manager_auth_token', token); + apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`; +}; + +export const removeAuthToken = () => { + localStorage.removeItem('file_manager_auth_token'); + delete apiClient.defaults.headers.common['Authorization']; +}; + +export const removeAuthId = () => { + localStorage.removeItem('file_manager_auth_id'); +}; + diff --git a/platforms/file-manager/src/routes/(auth)/auth/+page.svelte b/platforms/file-manager/src/routes/(auth)/auth/+page.svelte new file mode 100644 index 000000000..ee56b9fbf --- /dev/null +++ b/platforms/file-manager/src/routes/(auth)/auth/+page.svelte @@ -0,0 +1,203 @@ + + +
+
+ +
+
+ + + +
+
+

File Manager

+

Manage your files with your eID Wallet

+
+ +
+

+ {#if isMobileDevice()} + Login with your eID Wallet + {:else} + Scan the QR code using your eID App to login + {/if} +

+ {#if errorMessage} +
+

Authentication Error

+

{errorMessage}

+
+ {/if} + {#if qrData} + {#if isMobileDevice()} +
+ + Login with eID Wallet + +
+ Click the button to open your eID wallet app +
+
+ {:else} +
+ {/if} + {/if} + +

+ The {isMobileDevice() ? 'button' : 'code'} is valid for 60 seconds + Please refresh the page if it expires +

+ +

+ You are entering File Manager - a cloud storage and file management platform built on the Web 3.0 Data Space + (W3DS) architecture. Manage your files securely with your eID Wallet. +

+
+
+ diff --git a/platforms/file-manager/src/routes/(protected)/+layout.svelte b/platforms/file-manager/src/routes/(protected)/+layout.svelte new file mode 100644 index 000000000..f74b5f911 --- /dev/null +++ b/platforms/file-manager/src/routes/(protected)/+layout.svelte @@ -0,0 +1,24 @@ + + +
+
+
+
+
+
+ + + +
+

File Manager

+
+ +
+
+
+ + +
+ diff --git a/platforms/file-manager/src/routes/(protected)/files/+page.svelte b/platforms/file-manager/src/routes/(protected)/files/+page.svelte new file mode 100644 index 000000000..5942dea0b --- /dev/null +++ b/platforms/file-manager/src/routes/(protected)/files/+page.svelte @@ -0,0 +1,526 @@ + + +
+ +
+ +
+ +
+
+

+ {breadcrumbs[breadcrumbs.length - 1].name} +

+
+
+ + +
+
+ +
+ {#if isLoading} +
+
+

Loading...

+
+ {:else if allItems.length === 0} +
+

No files or folders yet

+

Drag and drop files here or click Upload

+
+ {:else} +
+ + + + + + + + + + + {#each allItems as item} + { + if (item.type === 'folder') { + navigateToFolder(item.id); + } else { + handlePreviewFile(item, e); + } + }} + > + + + + + + {/each} + +
+ Name + + Size + + Modified + + Actions +
+
+ + {item.type === 'folder' ? '📁' : getFileIcon(item.type === 'file' ? item.mimeType : '')} + +
+
+ {item.displayName || item.name} +
+ {#if item.type === 'file' && item.description} +
{item.description}
+ {/if} +
+
+
+ {item.type === 'folder' ? '—' : formatFileSize(item.type === 'file' ? item.size : 0)} + + {formatDate(item.updatedAt || item.createdAt)} + + +
+
+ {/if} +
+
+ + +{#if showUploadModal} +
+
+

Upload File

+ + {#if selectedFile} +

Selected: {selectedFile.name}

+ {/if} +
+ + +
+
+
+{/if} + + +{#if previewFile && previewUrl} +
+
e.stopPropagation()}> +
+

{previewFile.displayName || previewFile.name}

+ +
+ {#if previewFile.mimeType.startsWith('image/')} + {previewFile.name} + {:else if previewFile.mimeType === 'application/pdf'} + + {/if} +
+ + Download + + +
+
+
+{/if} + + +{#if showFolderModal} +
+
+

Create Folder

+ e.key === 'Enter' && handleCreateFolder()} + /> +
+ + +
+
+
+{/if} + + +{#if previewFile && previewUrl} +
+
e.stopPropagation()}> +
+

{previewFile.displayName || previewFile.name}

+ +
+ {#if previewFile.mimeType.startsWith('image/')} + {previewFile.name} + {:else if previewFile.mimeType === 'application/pdf'} + + {/if} +
+ + Download + + +
+
+
+{/if} + diff --git a/platforms/file-manager/src/routes/(protected)/files/[id]/+page.svelte b/platforms/file-manager/src/routes/(protected)/files/[id]/+page.svelte new file mode 100644 index 000000000..7c1b7cafc --- /dev/null +++ b/platforms/file-manager/src/routes/(protected)/files/[id]/+page.svelte @@ -0,0 +1,556 @@ + + +
+ +
+ +
+ + {#if isLoading} +
+
+

Loading...

+
+ {:else if file} + +
+ +
+
+

{file.displayName || file.name}

+

Size: {formatFileSize(file.size)} • Type: {file.mimeType}

+
+ + {#if previewUrl} +
+ {#if file.mimeType.startsWith('image/')} + {file.name} + {:else if file.mimeType === 'application/pdf'} + + {:else} +
+

Preview not available for this file type

+
+ {/if} +
+ {:else} +
+

Preview not available for this file type

+
+ {/if} + +
+ +
+
+ + +
+ +
+

Details

+
+
+
Name
+
{file.displayName || file.name}
+
+
+
Size
+
{formatFileSize(file.size)}
+
+
+
Type
+
{file.mimeType}
+
+
+
Created
+
{formatDate(file.createdAt)}
+
+
+
Modified
+
{formatDate(file.updatedAt || file.createdAt)}
+
+ {#if file.description} +
+
Description
+
{file.description}
+
+ {/if} +
+
+ + +
+
+

Tags

+ +
+ {#if file.tags && file.tags.length > 0} +
+ {#each file.tags as tag} + + {tag.name} + + {/each} +
+ {:else} +

No tags yet

+ {/if} +
+ + +
+
+

Shared with

+ +
+ {#if $fileAccess && $fileAccess.length > 0} +
+ {#each $fileAccess as access} +
+
+ {#if access.user?.avatarUrl} + {access.user.name} + {:else} +
+ {(access.user?.name || access.user?.ename || 'U')[0].toUpperCase()} +
+ {/if} +
+

{access.user?.name || access.user?.ename || 'Unknown'}

+

{access.permission}

+
+
+ {#if file.ownerId === $currentUser?.id} + + {/if} +
+ {/each} +
+ {:else} +

Not shared with anyone

+ {/if} +
+
+
+ {/if} +
+ + +{#if showAccessModal} +
{ showAccessModal = false; selectedUsers = []; searchQuery = ''; searchResults = []; }}> +
e.stopPropagation()}> +

Share File

+ + {#if searchResults.length > 0} +
+ {#each searchResults as user} +
{ + if (selectedUsers.find(u => u.id === user.id)) { + selectedUsers = selectedUsers.filter(u => u.id !== user.id); + } else { + selectedUsers = [...selectedUsers, user]; + } + }} + > + {user.name || user.ename} +
+ {/each} +
+ {/if} +
+ + +
+
+
+{/if} + + +{#if showTagModal} +
{ showTagModal = false; selectedTag = null; tagInput = ''; }}> +
e.stopPropagation()}> +

Add Tag

+
+ { + if (e.key === 'Enter' && tagInput.trim()) { + handleCreateOrSelectTag(); + } + }} + /> + {#if filteredTags.length > 0 || tagInput.trim()} +
+ {#if filteredTags.length > 0} + {#each filteredTags as tag} +
{ + selectedTag = tag.id; + handleAddTag(); + }} + > +
+ {tag.name} + {#if tag.color} + + {/if} +
+
+ {/each} + {/if} + {#if tagInput.trim() && !filteredTags.find(t => t.name.toLowerCase() === tagInput.trim().toLowerCase())} +
+
+ + + + Create "{tagInput.trim()}" +
+
+ {/if} +
+ {/if} +
+
+ +
+
+
+{/if} + diff --git a/platforms/file-manager/src/routes/+layout.svelte b/platforms/file-manager/src/routes/+layout.svelte new file mode 100644 index 000000000..e7d534cf1 --- /dev/null +++ b/platforms/file-manager/src/routes/+layout.svelte @@ -0,0 +1,33 @@ + + +{#if authInitComplete} + + +{:else} +
+
+
+

Loading...

+
+
+{/if} + diff --git a/platforms/file-manager/src/routes/+page.svelte b/platforms/file-manager/src/routes/+page.svelte new file mode 100644 index 000000000..f4f1fd5f3 --- /dev/null +++ b/platforms/file-manager/src/routes/+page.svelte @@ -0,0 +1,23 @@ + + +
+
+
+

Loading...

+
+
+ diff --git a/platforms/file-manager/svelte.config.js b/platforms/file-manager/svelte.config.js new file mode 100644 index 000000000..36202a10a --- /dev/null +++ b/platforms/file-manager/svelte.config.js @@ -0,0 +1,15 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter(), + env: { + dir: '../../' + } + } +}; + +export default config; + diff --git a/platforms/file-manager/tsconfig.json b/platforms/file-manager/tsconfig.json new file mode 100644 index 000000000..51db996c3 --- /dev/null +++ b/platforms/file-manager/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} + diff --git a/platforms/file-manager/vite.config.ts b/platforms/file-manager/vite.config.ts new file mode 100644 index 000000000..0c8f96ab1 --- /dev/null +++ b/platforms/file-manager/vite.config.ts @@ -0,0 +1,15 @@ +import tailwindcss from '@tailwindcss/vite'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + server: { + allowedHosts: [ + 'file-manager.w3ds-prototype.merul.org', + 'file-manager.staging.metastate.foundation', + 'file-manager.w3ds.metastate.foundation' + ] + } +}); + diff --git a/platforms/registry/src/index.ts b/platforms/registry/src/index.ts index ed9b36191..19745bdb2 100644 --- a/platforms/registry/src/index.ts +++ b/platforms/registry/src/index.ts @@ -170,7 +170,8 @@ server.get("/platforms", async (request, reply) => { process.env.VITE_EREPUTATION_BASE_URL, process.env.VITE_ECURRENCY_BASE_URL, process.env.PUBLIC_EMOVER_BASE_URL, - process.env.PUBLIC_ESIGNER_BASE_URL + process.env.PUBLIC_ESIGNER_BASE_URL, + process.env.PUBLIC_FILE_MANAGER_BASE_URL ]; return platforms; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b03d1e85..d54984386 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2329,6 +2329,170 @@ importers: specifier: ^5.3.3 version: 5.8.2 + platforms/file-manager: + dependencies: + '@sveltejs/adapter-node': + specifier: ^5.2.12 + version: 5.4.0(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.45.10)(vite@6.4.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.96.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.10)(vite@6.4.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.96.0)(tsx@4.21.0)(yaml@2.8.1))) + axios: + specifier: ^1.6.7 + version: 1.13.2 + svelte-qrcode: + specifier: ^1.0.1 + version: 1.0.1 + svelte-qrcode-action: + specifier: ^1.0.2 + version: 1.0.2(svelte@5.45.10) + tailwind-merge: + specifier: ^3.0.2 + version: 3.4.0 + devDependencies: + '@eslint/compat': + specifier: ^1.2.5 + version: 1.4.1(eslint@9.39.1(jiti@2.6.1)) + '@eslint/js': + specifier: ^9.18.0 + version: 9.39.1 + '@sveltejs/adapter-static': + specifier: ^3.0.8 + version: 3.0.10(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.45.10)(vite@6.4.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.96.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.10)(vite@6.4.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.96.0)(tsx@4.21.0)(yaml@2.8.1))) + '@sveltejs/kit': + specifier: ^2.16.0 + version: 2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.45.10)(vite@6.4.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.96.0)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.45.10)(vite@6.4.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.96.0)(tsx@4.21.0)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte': + specifier: ^5.0.0 + version: 5.1.1(svelte@5.45.10)(vite@6.4.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.96.0)(tsx@4.21.0)(yaml@2.8.1)) + '@tailwindcss/vite': + specifier: ^4.0.0 + version: 4.1.17(vite@6.4.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.96.0)(tsx@4.21.0)(yaml@2.8.1)) + clsx: + specifier: ^2.1.1 + version: 2.1.1 + eslint: + specifier: ^9.18.0 + version: 9.39.1(jiti@2.6.1) + eslint-config-prettier: + specifier: ^10.0.1 + version: 10.1.8(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-svelte: + specifier: ^3.0.0 + version: 3.13.1(eslint@9.39.1(jiti@2.6.1))(svelte@5.45.10)(ts-node@10.9.2(@types/node@24.10.3)(typescript@5.8.2)) + globals: + specifier: ^16.0.0 + version: 16.5.0 + prettier: + specifier: ^3.4.2 + version: 3.7.4 + prettier-plugin-svelte: + specifier: ^3.3.3 + version: 3.4.0(prettier@3.7.4)(svelte@5.45.10) + prettier-plugin-tailwindcss: + specifier: ^0.7.0 + version: 0.7.2(prettier-plugin-svelte@3.4.0(prettier@3.7.4)(svelte@5.45.10))(prettier@3.7.4) + svelte: + specifier: ^5.0.0 + version: 5.45.10 + svelte-check: + specifier: ^4.0.0 + version: 4.3.4(picomatch@4.0.3)(svelte@5.45.10)(typescript@5.8.2) + tailwindcss: + specifier: ^4.0.0 + version: 4.1.17 + typescript: + specifier: ^5.0.0 + version: 5.8.2 + typescript-eslint: + specifier: ^8.20.0 + version: 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.2) + vite: + specifier: ^6.2.6 + version: 6.4.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.96.0)(tsx@4.21.0)(yaml@2.8.1) + + platforms/file-manager-api: + dependencies: + axios: + specifier: ^1.6.7 + version: 1.13.2 + cors: + specifier: ^2.8.5 + version: 2.8.5 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + eventsource-polyfill: + specifier: ^0.9.6 + version: 0.9.6 + express: + specifier: ^4.18.2 + version: 4.22.1 + graphql-request: + specifier: ^6.1.0 + version: 6.1.0(encoding@0.1.13)(graphql@16.12.0) + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.3 + multer: + specifier: ^1.4.5-lts.1 + version: 1.4.5-lts.2 + pg: + specifier: ^8.11.3 + version: 8.16.3 + reflect-metadata: + specifier: ^0.2.1 + version: 0.2.2 + signature-validator: + specifier: workspace:* + version: link:../../infrastructure/signature-validator + typeorm: + specifier: ^0.3.24 + version: 0.3.28(babel-plugin-macros@3.1.0)(ioredis@5.8.2)(pg@8.16.3)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + uuid: + specifier: ^9.0.1 + version: 9.0.1 + web3-adapter: + specifier: workspace:* + version: link:../../infrastructure/web3-adapter + devDependencies: + '@types/cors': + specifier: ^2.8.17 + version: 2.8.19 + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + '@types/jsonwebtoken': + specifier: ^9.0.5 + version: 9.0.10 + '@types/multer': + specifier: ^1.4.11 + version: 1.4.13 + '@types/node': + specifier: ^20.11.24 + version: 20.19.26 + '@types/pg': + specifier: ^8.11.2 + version: 8.16.0 + '@types/uuid': + specifier: ^9.0.8 + version: 9.0.8 + '@typescript-eslint/eslint-plugin': + specifier: ^7.0.1 + version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1)(typescript@5.8.2) + '@typescript-eslint/parser': + specifier: ^7.0.1 + version: 7.18.0(eslint@8.57.1)(typescript@5.8.2) + eslint: + specifier: ^8.56.0 + version: 8.57.1 + nodemon: + specifier: ^3.0.3 + version: 3.1.11 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@20.19.26)(typescript@5.8.2) + typescript: + specifier: ^5.3.3 + version: 5.8.2 + platforms/group-charter-manager: dependencies: '@hookform/resolvers': From 50978ea42238bf05a3fce75d7aa50f1da800b1f9 Mon Sep 17 00:00:00 2001 From: Merul Dhiman Date: Mon, 5 Jan 2026 16:07:00 +0530 Subject: [PATCH 2/8] feat: synchronization between file manager & eSigner --- .../src/controllers/WebhookController.ts | 166 ++++++++++++++++ .../web3adapter/mappings/file.mapping.json | 18 ++ .../mappings/signature.mapping.json | 17 ++ .../src/web3adapter/watchers/subscriber.ts | 76 +++++++ platforms/esigner/.svelte-kit/ambient.d.ts | 2 + .../.svelte-kit/generated/server/internal.js | 2 +- .../src/controllers/FileController.ts | 5 +- .../src/controllers/WebhookController.ts | 185 ++++++++++++++++++ .../src/services/FileService.ts | 10 +- .../web3adapter/mappings/file.mapping.json | 19 ++ .../mappings/signature.mapping.json | 17 ++ .../src/web3adapter/watchers/subscriber.ts | 119 ++++++++++- .../.svelte-kit/generated/server/internal.js | 2 +- services/ontology/schemas/file.json | 66 +++++++ services/ontology/schemas/signature.json | 52 +++++ 15 files changed, 743 insertions(+), 13 deletions(-) create mode 100644 platforms/esigner-api/src/web3adapter/mappings/file.mapping.json create mode 100644 platforms/esigner-api/src/web3adapter/mappings/signature.mapping.json create mode 100644 platforms/file-manager-api/src/web3adapter/mappings/file.mapping.json create mode 100644 platforms/file-manager-api/src/web3adapter/mappings/signature.mapping.json create mode 100644 services/ontology/schemas/file.json create mode 100644 services/ontology/schemas/signature.json diff --git a/platforms/esigner-api/src/controllers/WebhookController.ts b/platforms/esigner-api/src/controllers/WebhookController.ts index b86b7b029..f868da9f8 100644 --- a/platforms/esigner-api/src/controllers/WebhookController.ts +++ b/platforms/esigner-api/src/controllers/WebhookController.ts @@ -2,22 +2,30 @@ import { Request, Response } from "express"; import { UserService } from "../services/UserService"; import { GroupService } from "../services/GroupService"; import { MessageService } from "../services/MessageService"; +import { FileService } from "../services/FileService"; import { Web3Adapter } from "web3-adapter"; import { User } from "../database/entities/User"; import { Group } from "../database/entities/Group"; import { Message } from "../database/entities/Message"; +import { File } from "../database/entities/File"; +import { SignatureContainer } from "../database/entities/SignatureContainer"; +import { AppDataSource } from "../database/data-source"; import axios from "axios"; export class WebhookController { userService: UserService; groupService: GroupService; messageService: MessageService; + fileService: FileService; adapter: Web3Adapter; + fileRepository = AppDataSource.getRepository(File); + signatureRepository = AppDataSource.getRepository(SignatureContainer); constructor(adapter: Web3Adapter) { this.userService = new UserService(); this.groupService = new GroupService(); this.messageService = new MessageService(); + this.fileService = new FileService(); this.adapter = adapter; } @@ -242,6 +250,164 @@ export class WebhookController { }); console.log("Stored mapping for message:", message.id, "->", req.body.id); } + } else if (mapping.tableName === "files") { + // Extract owner from the file data + // ownerId might be a global reference or local ID + let ownerId: string | null = null; + if (local.data.ownerId && typeof local.data.ownerId === "string") { + // Check if it's a reference format like "users(uuid)" + if (local.data.ownerId.includes("(")) { + ownerId = local.data.ownerId.split("(")[1].split(")")[0]; + } else { + ownerId = local.data.ownerId; + } + } + + // Resolve global ownerId to local ownerId if needed + if (ownerId) { + const localOwnerId = await this.adapter.mappingDb.getLocalId(ownerId); + ownerId = localOwnerId || ownerId; + } + + const owner = ownerId ? await this.userService.getUserById(ownerId) : null; + if (!owner) { + console.error("Owner not found for file"); + return res.status(500).send(); + } + + if (localId) { + // Update existing file + const file = await this.fileService.getFileById(localId); + if (!file) { + console.error("File not found for localId:", localId); + return res.status(500).send(); + } + + file.name = local.data.name as string; + file.displayName = local.data.displayName as string | null; + file.description = local.data.description as string | null; + file.mimeType = local.data.mimeType as string; + file.size = local.data.size as number; + file.md5Hash = local.data.md5Hash as string; + file.ownerId = owner.id; + + // Decode base64 data if provided + if (local.data.data && typeof local.data.data === "string") { + file.data = Buffer.from(local.data.data, "base64"); + } + + this.adapter.addToLockedIds(localId); + await this.fileRepository.save(file); + } else { + // Create new file with binary data + // Decode base64 data if provided + let fileData: Buffer = Buffer.alloc(0); + if (local.data.data && typeof local.data.data === "string") { + fileData = Buffer.from(local.data.data, "base64"); + } + + const file = this.fileRepository.create({ + name: local.data.name as string, + displayName: local.data.displayName as string | null, + description: local.data.description as string | null, + mimeType: local.data.mimeType as string, + size: local.data.size as number, + md5Hash: local.data.md5Hash as string, + ownerId: owner.id, + data: fileData, + }); + + this.adapter.addToLockedIds(file.id); + await this.fileRepository.save(file); + await this.adapter.mappingDb.storeMapping({ + localId: file.id, + globalId: req.body.id, + }); + localId = file.id; + } + } else if (mapping.tableName === "signature_containers") { + // Extract file and user from the signature data + let file: File | null = null; + let user: User | null = null; + + // Resolve fileId - might be global reference + let fileId: string | null = null; + if (local.data.fileId && typeof local.data.fileId === "string") { + if (local.data.fileId.includes("(")) { + const fileGlobalId = local.data.fileId.split("(")[1].split(")")[0]; + const fileLocalId = await this.adapter.mappingDb.getLocalId(fileGlobalId); + fileId = fileLocalId || fileGlobalId; + } else { + fileId = local.data.fileId; + } + } + + // Resolve userId - might be global reference + let userId: string | null = null; + if (local.data.userId && typeof local.data.userId === "string") { + if (local.data.userId.includes("(")) { + userId = local.data.userId.split("(")[1].split(")")[0]; + } else { + userId = local.data.userId; + } + } + + // Resolve global IDs to local IDs + if (fileId) { + const localFileId = await this.adapter.mappingDb.getLocalId(fileId); + fileId = localFileId || fileId; + } + if (userId) { + const localUserId = await this.adapter.mappingDb.getLocalId(userId); + userId = localUserId || userId; + } + + file = fileId ? await this.fileRepository.findOne({ where: { id: fileId } }) : null; + user = userId ? await this.userService.getUserById(userId) : null; + + if (!file || !user) { + console.error("File or user not found for signature"); + return res.status(500).send(); + } + + if (localId) { + // Update existing signature + const signature = await this.signatureRepository.findOne({ + where: { id: localId }, + }); + if (!signature) { + console.error("Signature not found for localId:", localId); + return res.status(500).send(); + } + + signature.fileId = file.id; + signature.userId = user.id; + signature.md5Hash = local.data.md5Hash as string; + signature.signature = local.data.signature as string; + signature.publicKey = local.data.publicKey as string; + signature.message = local.data.message as string; + + this.adapter.addToLockedIds(localId); + await this.signatureRepository.save(signature); + } else { + // Create new signature + const signature = this.signatureRepository.create({ + fileId: file.id, + userId: user.id, + md5Hash: local.data.md5Hash as string, + signature: local.data.signature as string, + publicKey: local.data.publicKey as string, + message: local.data.message as string, + }); + + this.adapter.addToLockedIds(signature.id); + await this.signatureRepository.save(signature); + await this.adapter.mappingDb.storeMapping({ + localId: signature.id, + globalId: req.body.id, + }); + localId = signature.id; + } } res.status(200).json({ success: true }); diff --git a/platforms/esigner-api/src/web3adapter/mappings/file.mapping.json b/platforms/esigner-api/src/web3adapter/mappings/file.mapping.json new file mode 100644 index 000000000..2b92c6d5f --- /dev/null +++ b/platforms/esigner-api/src/web3adapter/mappings/file.mapping.json @@ -0,0 +1,18 @@ +{ + "tableName": "files", + "schemaId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "ownerEnamePath": "users(owner.ename)", + "ownedJunctionTables": [], + "localToUniversalMap": { + "name": "name", + "displayName": "displayName", + "description": "description", + "mimeType": "mimeType", + "size": "size", + "md5Hash": "md5Hash", + "data": "data", + "ownerId": "users(owner.id),ownerId", + "createdAt": "__date(createdAt)", + "updatedAt": "__date(updatedAt)" + } +} \ No newline at end of file diff --git a/platforms/esigner-api/src/web3adapter/mappings/signature.mapping.json b/platforms/esigner-api/src/web3adapter/mappings/signature.mapping.json new file mode 100644 index 000000000..2b6fdfca4 --- /dev/null +++ b/platforms/esigner-api/src/web3adapter/mappings/signature.mapping.json @@ -0,0 +1,17 @@ +{ + "tableName": "signature_containers", + "schemaId": "b2c3d4e5-f6a7-8901-bcde-f12345678901", + "ownerEnamePath": "users(user.ename)", + "ownedJunctionTables": [], + "localToUniversalMap": { + "fileId": "files(file.id),fileId", + "userId": "users(user.id),userId", + "md5Hash": "md5Hash", + "signature": "signature", + "publicKey": "publicKey", + "message": "message", + "createdAt": "__date(createdAt)", + "updatedAt": "__date(updatedAt)" + } +} + diff --git a/platforms/esigner-api/src/web3adapter/watchers/subscriber.ts b/platforms/esigner-api/src/web3adapter/watchers/subscriber.ts index ce9dbe269..de478a608 100644 --- a/platforms/esigner-api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/esigner-api/src/web3adapter/watchers/subscriber.ts @@ -46,6 +46,45 @@ export class PostgresSubscriber implements EntitySubscriberInterface { async enrichEntity(entity: any, tableName: string, tableTarget: any) { try { const enrichedEntity = { ...entity }; + + // Special handling for File entities to ensure owner relation is loaded + if (tableName === "files" && (entity.ownerId || entity.owner)) { + const ownerId = entity.owner?.id || entity.ownerId; + if (ownerId) { + const owner = await AppDataSource.getRepository("User").findOne({ + where: { id: ownerId }, + select: ["id", "ename", "name"] + }); + if (owner) { + enrichedEntity.owner = owner; + } + } + } + + // Special handling for SignatureContainer entities to ensure file and user relations are loaded + if (tableName === "signature_containers") { + if (entity.fileId || entity.file?.id) { + const fileId = entity.file?.id || entity.fileId; + const file = await AppDataSource.getRepository("File").findOne({ + where: { id: fileId }, + relations: ["owner"] + }); + if (file) { + enrichedEntity.file = file; + } + } + if (entity.userId || entity.user?.id) { + const userId = entity.user?.id || entity.userId; + const user = await AppDataSource.getRepository("User").findOne({ + where: { id: userId }, + select: ["id", "ename", "name"] + }); + if (user) { + enrichedEntity.user = user; + } + } + } + return this.entityToPlain(enrichedEntity); } catch (error) { console.error("Error loading relations:", error); @@ -94,6 +133,31 @@ export class PostgresSubscriber implements EntitySubscriberInterface { async afterInsert(event: InsertEvent) { let entity = event.entity; + + // For files and signatures, reload with relations to ensure owner/user are loaded + if (entity && (event.metadata.tableName === "files" || event.metadata.tableName === "signature_containers")) { + const entityId = entity.id; + if (entityId) { + const repository = AppDataSource.getRepository(event.metadata.target); + let relations: string[] = []; + + if (event.metadata.tableName === "files") { + relations = ["owner"]; + } else if (event.metadata.tableName === "signature_containers") { + relations = ["file", "user", "file.owner"]; + } + + const fullEntity = await repository.findOne({ + where: { id: entityId }, + relations: relations.length > 0 ? relations : undefined + }); + + if (fullEntity) { + entity = fullEntity; + } + } + } + if (entity) { entity = (await this.enrichEntity( entity, @@ -140,6 +204,10 @@ export class PostgresSubscriber implements EntitySubscriberInterface { relations = ["sender", "group", "group.members", "group.admins", "group.participants"]; } else if (event.metadata.tableName === "groups") { relations = ["members", "admins", "participants"]; + } else if (event.metadata.tableName === "files") { + relations = ["owner", "signees", "signatures"]; + } else if (event.metadata.tableName === "signature_containers") { + relations = ["file", "user"]; } const fullEntity = await repository.findOne({ @@ -243,6 +311,11 @@ export class PostgresSubscriber implements EntitySubscriberInterface { return entity.toISOString(); } + // Handle Buffer objects - convert to base64 + if (Buffer.isBuffer(entity)) { + return entity.toString("base64"); + } + if (Array.isArray(entity)) { return entity.map((item) => this.entityToPlain(item)); } @@ -256,6 +329,9 @@ export class PostgresSubscriber implements EntitySubscriberInterface { plain[key] = value.map((item) => this.entityToPlain(item)); } else if (value instanceof Date) { plain[key] = value.toISOString(); + } else if (Buffer.isBuffer(value)) { + // Convert Buffer to base64 string + plain[key] = value.toString("base64"); } else { plain[key] = this.entityToPlain(value); } diff --git a/platforms/esigner/.svelte-kit/ambient.d.ts b/platforms/esigner/.svelte-kit/ambient.d.ts index a576735ad..d9bad4f81 100644 --- a/platforms/esigner/.svelte-kit/ambient.d.ts +++ b/platforms/esigner/.svelte-kit/ambient.d.ts @@ -162,6 +162,7 @@ declare module '$env/static/private' { export const FNM_LOGLEVEL: string; export const OLDPWD: string; export const TERM_PROGRAM: string; + export const NODE_ENV: string; } /** @@ -342,6 +343,7 @@ declare module '$env/dynamic/private' { FNM_LOGLEVEL: string; OLDPWD: string; TERM_PROGRAM: string; + NODE_ENV: string; [key: `PUBLIC_${string}`]: undefined; [key: `${string}`]: string | undefined; } diff --git a/platforms/esigner/.svelte-kit/generated/server/internal.js b/platforms/esigner/.svelte-kit/generated/server/internal.js index eeb5af337..7d73d9259 100644 --- a/platforms/esigner/.svelte-kit/generated/server/internal.js +++ b/platforms/esigner/.svelte-kit/generated/server/internal.js @@ -24,7 +24,7 @@ export const options = { app: ({ head, body, assets, nonce, env }) => "\n\n\t\n\t\t\n\t\t\n\t\t" + head + "\n\t\n\t\n\t\t
" + body + "
\n\t\n\n\n", error: ({ status, message }) => "\n\n\t\n\t\t\n\t\t" + message + "\n\n\t\t\n\t\n\t\n\t\t
\n\t\t\t" + status + "\n\t\t\t
\n\t\t\t\t

" + message + "

\n\t\t\t
\n\t\t
\n\t\n\n" }, - version_hash: "r5aqr4" + version_hash: "hch0zd" }; export async function get_hooks() { diff --git a/platforms/file-manager-api/src/controllers/FileController.ts b/platforms/file-manager-api/src/controllers/FileController.ts index 11719845f..218037483 100644 --- a/platforms/file-manager-api/src/controllers/FileController.ts +++ b/platforms/file-manager-api/src/controllers/FileController.ts @@ -28,13 +28,16 @@ export class FileController { const { displayName, description, folderId } = req.body; + // Normalize folderId - convert string "null" to actual null + const normalizedFolderId = folderId === 'null' || folderId === '' || folderId === null || folderId === undefined ? null : folderId; + const file = await this.fileService.createFile( req.file.originalname, req.file.mimetype, req.file.size, req.file.buffer, req.user.id, - folderId || null, + normalizedFolderId, displayName, description ); diff --git a/platforms/file-manager-api/src/controllers/WebhookController.ts b/platforms/file-manager-api/src/controllers/WebhookController.ts index c466fe950..f190a411e 100644 --- a/platforms/file-manager-api/src/controllers/WebhookController.ts +++ b/platforms/file-manager-api/src/controllers/WebhookController.ts @@ -2,22 +2,31 @@ import { Request, Response } from "express"; import { UserService } from "../services/UserService"; import { GroupService } from "../services/GroupService"; import { MessageService } from "../services/MessageService"; +import { FileService } from "../services/FileService"; import { Web3Adapter } from "web3-adapter"; import { User } from "../database/entities/User"; import { Group } from "../database/entities/Group"; import { Message } from "../database/entities/Message"; +import { File } from "../database/entities/File"; +import { Folder } from "../database/entities/Folder"; +import { SignatureContainer } from "../database/entities/SignatureContainer"; +import { AppDataSource } from "../database/data-source"; import axios from "axios"; export class WebhookController { userService: UserService; groupService: GroupService; messageService: MessageService; + fileService: FileService; adapter: Web3Adapter; + fileRepository = AppDataSource.getRepository(File); + signatureRepository = AppDataSource.getRepository(SignatureContainer); constructor(adapter: Web3Adapter) { this.userService = new UserService(); this.groupService = new GroupService(); this.messageService = new MessageService(); + this.fileService = new FileService(); this.adapter = adapter; } @@ -242,6 +251,182 @@ export class WebhookController { }); console.log("Stored mapping for message:", message.id, "->", req.body.id); } + } else if (mapping.tableName === "files") { + // Extract owner from the file data + // ownerId might be a global reference or local ID + let ownerId: string | null = null; + if (local.data.ownerId && typeof local.data.ownerId === "string") { + // Check if it's a reference format like "users(uuid)" + if (local.data.ownerId.includes("(")) { + ownerId = local.data.ownerId.split("(")[1].split(")")[0]; + } else { + ownerId = local.data.ownerId; + } + } + + // Resolve global ownerId to local ownerId if needed + if (ownerId) { + const localOwnerId = await this.adapter.mappingDb.getLocalId(ownerId); + ownerId = localOwnerId || ownerId; + } + + const owner = ownerId ? await this.userService.getUserById(ownerId) : null; + if (!owner) { + console.error("Owner not found for file"); + return res.status(500).send(); + } + + // Handle folderId - may not exist in esigner-api, so set to null if not found + let folderId: string | null = null; + if (local.data.folderId && local.data.folderId !== null) { + if (typeof local.data.folderId === "string" && local.data.folderId.includes("(")) { + // It's a reference, but folders don't sync, so ignore + folderId = null; + } else if (typeof local.data.folderId === "string") { + // Check if folder exists locally + const folderRepository = AppDataSource.getRepository(Folder); + const folder = await folderRepository.findOne({ where: { id: local.data.folderId } }); + folderId = folder ? folder.id : null; + } + } + + if (localId) { + // Update existing file + const file = await this.fileRepository.findOne({ + where: { id: localId }, + }); + if (!file) { + console.error("File not found for localId:", localId); + return res.status(500).send(); + } + + file.name = local.data.name as string; + file.displayName = local.data.displayName as string | null; + file.description = local.data.description as string | null; + file.mimeType = local.data.mimeType as string; + file.size = local.data.size as number; + file.md5Hash = local.data.md5Hash as string; + file.ownerId = owner.id; + file.folderId = folderId; + + // Decode base64 data if provided + if (local.data.data && typeof local.data.data === "string") { + file.data = Buffer.from(local.data.data, "base64"); + } + + this.adapter.addToLockedIds(localId); + await this.fileRepository.save(file); + } else { + // Create new file with binary data + // Decode base64 data if provided + let fileData: Buffer = Buffer.alloc(0); + if (local.data.data && typeof local.data.data === "string") { + fileData = Buffer.from(local.data.data, "base64"); + } + + const file = this.fileRepository.create({ + name: local.data.name as string, + displayName: local.data.displayName as string | null, + description: local.data.description as string | null, + mimeType: local.data.mimeType as string, + size: local.data.size as number, + md5Hash: local.data.md5Hash as string, + ownerId: owner.id, + folderId: folderId, + data: fileData, + }); + + this.adapter.addToLockedIds(file.id); + await this.fileRepository.save(file); + await this.adapter.mappingDb.storeMapping({ + localId: file.id, + globalId: req.body.id, + }); + localId = file.id; + } + } else if (mapping.tableName === "signature_containers") { + // Extract file and user from the signature data + let file: File | null = null; + let user: User | null = null; + + // Resolve fileId - might be global reference + let fileId: string | null = null; + if (local.data.fileId && typeof local.data.fileId === "string") { + if (local.data.fileId.includes("(")) { + const fileGlobalId = local.data.fileId.split("(")[1].split(")")[0]; + const fileLocalId = await this.adapter.mappingDb.getLocalId(fileGlobalId); + fileId = fileLocalId || fileGlobalId; + } else { + fileId = local.data.fileId; + } + } + + // Resolve userId - might be global reference + let userId: string | null = null; + if (local.data.userId && typeof local.data.userId === "string") { + if (local.data.userId.includes("(")) { + userId = local.data.userId.split("(")[1].split(")")[0]; + } else { + userId = local.data.userId; + } + } + + // Resolve global IDs to local IDs + if (fileId) { + const localFileId = await this.adapter.mappingDb.getLocalId(fileId); + fileId = localFileId || fileId; + } + if (userId) { + const localUserId = await this.adapter.mappingDb.getLocalId(userId); + userId = localUserId || userId; + } + + file = fileId ? await this.fileRepository.findOne({ where: { id: fileId } }) : null; + user = userId ? await this.userService.getUserById(userId) : null; + + if (!file || !user) { + console.error("File or user not found for signature"); + return res.status(500).send(); + } + + if (localId) { + // Update existing signature + const signature = await this.signatureRepository.findOne({ + where: { id: localId }, + }); + if (!signature) { + console.error("Signature not found for localId:", localId); + return res.status(500).send(); + } + + signature.fileId = file.id; + signature.userId = user.id; + signature.md5Hash = local.data.md5Hash as string; + signature.signature = local.data.signature as string; + signature.publicKey = local.data.publicKey as string; + signature.message = local.data.message as string; + + this.adapter.addToLockedIds(localId); + await this.signatureRepository.save(signature); + } else { + // Create new signature + const signature = this.signatureRepository.create({ + fileId: file.id, + userId: user.id, + md5Hash: local.data.md5Hash as string, + signature: local.data.signature as string, + publicKey: local.data.publicKey as string, + message: local.data.message as string, + }); + + this.adapter.addToLockedIds(signature.id); + await this.signatureRepository.save(signature); + await this.adapter.mappingDb.storeMapping({ + localId: signature.id, + globalId: req.body.id, + }); + localId = signature.id; + } } res.status(200).json({ success: true }); diff --git a/platforms/file-manager-api/src/services/FileService.ts b/platforms/file-manager-api/src/services/FileService.ts index 297213577..6b3bb88d8 100644 --- a/platforms/file-manager-api/src/services/FileService.ts +++ b/platforms/file-manager-api/src/services/FileService.ts @@ -10,6 +10,7 @@ export class FileService { private fileRepository = AppDataSource.getRepository(File); private fileAccessRepository = AppDataSource.getRepository(FileAccess); private folderRepository = AppDataSource.getRepository(Folder); + private signatureRepository = AppDataSource.getRepository(SignatureContainer); async calculateMD5(buffer: Buffer): Promise { return crypto.createHash('md5').update(buffer).digest('hex'); @@ -27,10 +28,13 @@ export class FileService { ): Promise { const md5Hash = await this.calculateMD5(data); + // Normalize folderId - convert string "null" to actual null + const normalizedFolderId = folderId === 'null' || folderId === '' || folderId === null || folderId === undefined ? null : folderId; + // Verify folder exists and user owns it if folderId is provided - if (folderId) { + if (normalizedFolderId) { const folder = await this.folderRepository.findOne({ - where: { id: folderId, ownerId }, + where: { id: normalizedFolderId, ownerId }, }); if (!folder) { throw new Error("Folder not found or user is not the owner"); @@ -45,7 +49,7 @@ export class FileService { md5Hash, data, ownerId, - folderId: folderId || null, + folderId: normalizedFolderId, }; if (description !== undefined) { diff --git a/platforms/file-manager-api/src/web3adapter/mappings/file.mapping.json b/platforms/file-manager-api/src/web3adapter/mappings/file.mapping.json new file mode 100644 index 000000000..1379c2ceb --- /dev/null +++ b/platforms/file-manager-api/src/web3adapter/mappings/file.mapping.json @@ -0,0 +1,19 @@ +{ + "tableName": "files", + "schemaId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "ownerEnamePath": "users(owner.ename)", + "ownedJunctionTables": [], + "localToUniversalMap": { + "name": "name", + "displayName": "displayName", + "description": "description", + "mimeType": "mimeType", + "size": "size", + "md5Hash": "md5Hash", + "data": "data", + "ownerId": "users(owner.id),ownerId", + "folderId": "folderId", + "createdAt": "__date(createdAt)", + "updatedAt": "__date(updatedAt)" + } +} \ No newline at end of file diff --git a/platforms/file-manager-api/src/web3adapter/mappings/signature.mapping.json b/platforms/file-manager-api/src/web3adapter/mappings/signature.mapping.json new file mode 100644 index 000000000..2b6fdfca4 --- /dev/null +++ b/platforms/file-manager-api/src/web3adapter/mappings/signature.mapping.json @@ -0,0 +1,17 @@ +{ + "tableName": "signature_containers", + "schemaId": "b2c3d4e5-f6a7-8901-bcde-f12345678901", + "ownerEnamePath": "users(user.ename)", + "ownedJunctionTables": [], + "localToUniversalMap": { + "fileId": "files(file.id),fileId", + "userId": "users(user.id),userId", + "md5Hash": "md5Hash", + "signature": "signature", + "publicKey": "publicKey", + "message": "message", + "createdAt": "__date(createdAt)", + "updatedAt": "__date(updatedAt)" + } +} + diff --git a/platforms/file-manager-api/src/web3adapter/watchers/subscriber.ts b/platforms/file-manager-api/src/web3adapter/watchers/subscriber.ts index 70725461d..8c852557b 100644 --- a/platforms/file-manager-api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/file-manager-api/src/web3adapter/watchers/subscriber.ts @@ -46,6 +46,45 @@ export class PostgresSubscriber implements EntitySubscriberInterface { async enrichEntity(entity: any, tableName: string, tableTarget: any) { try { const enrichedEntity = { ...entity }; + + // Special handling for File entities to ensure owner relation is loaded + if (tableName === "files" && (entity.ownerId || entity.owner)) { + const ownerId = entity.owner?.id || entity.ownerId; + if (ownerId) { + const owner = await AppDataSource.getRepository("User").findOne({ + where: { id: ownerId }, + select: ["id", "ename", "name"] + }); + if (owner) { + enrichedEntity.owner = owner; + } + } + } + + // Special handling for SignatureContainer entities to ensure file and user relations are loaded + if (tableName === "signature_containers") { + if (entity.fileId || entity.file?.id) { + const fileId = entity.file?.id || entity.fileId; + const file = await AppDataSource.getRepository("File").findOne({ + where: { id: fileId }, + relations: ["owner"] + }); + if (file) { + enrichedEntity.file = file; + } + } + if (entity.userId || entity.user?.id) { + const userId = entity.user?.id || entity.userId; + const user = await AppDataSource.getRepository("User").findOne({ + where: { id: userId }, + select: ["id", "ename", "name"] + }); + if (user) { + enrichedEntity.user = user; + } + } + } + return this.entityToPlain(enrichedEntity); } catch (error) { console.error("Error loading relations:", error); @@ -93,7 +132,33 @@ export class PostgresSubscriber implements EntitySubscriberInterface { } async afterInsert(event: InsertEvent) { + console.log(`📥 afterInsert called for table: ${event.metadata.tableName}`); let entity = event.entity; + + // For files and signatures, reload with relations to ensure owner/user are loaded + if (entity && (event.metadata.tableName === "files" || event.metadata.tableName === "signature_containers")) { + const entityId = entity.id; + if (entityId) { + const repository = AppDataSource.getRepository(event.metadata.target); + let relations: string[] = []; + + if (event.metadata.tableName === "files") { + relations = ["owner"]; + } else if (event.metadata.tableName === "signature_containers") { + relations = ["file", "user", "file.owner"]; + } + + const fullEntity = await repository.findOne({ + where: { id: entityId }, + relations: relations.length > 0 ? relations : undefined + }); + + if (fullEntity) { + entity = fullEntity; + } + } + } + if (entity) { entity = (await this.enrichEntity( entity, @@ -107,11 +172,14 @@ export class PostgresSubscriber implements EntitySubscriberInterface { entity = await this.enrichMessageEntity(entity); } + const tableName = event.metadata.tableName.endsWith("s") + ? event.metadata.tableName + : event.metadata.tableName + "s"; + + console.log(`📥 Processing insert for table: ${tableName}, entityId: ${entity?.id || event.entityId}`); this.handleChange( entity ?? event.entityId, - event.metadata.tableName.endsWith("s") - ? event.metadata.tableName - : event.metadata.tableName + "s" + tableName ); } @@ -140,6 +208,10 @@ export class PostgresSubscriber implements EntitySubscriberInterface { relations = ["sender", "group", "group.members", "group.admins", "group.participants"]; } else if (event.metadata.tableName === "groups") { relations = ["members", "admins", "participants"]; + } else if (event.metadata.tableName === "files") { + relations = ["owner", "folder", "signatures", "tags"]; + } else if (event.metadata.tableName === "signature_containers") { + relations = ["file", "user"]; } const fullEntity = await repository.findOne({ @@ -185,13 +257,32 @@ export class PostgresSubscriber implements EntitySubscriberInterface { } private async handleChange(entity: any, tableName: string): Promise { - // Handle users, groups, and messages - if (tableName !== "users" && tableName !== "groups" && tableName !== "messages") { + console.log(`🔍 handleChange called for: ${tableName}, entityId: ${entity?.id}`); + + if (!entity || !entity.id) { + console.log(`⏭️ Skipping handleChange for ${tableName}: entity or entity.id is missing`); + return; + } + + // Check if there's a mapping for this table + const mapping = Object.values(this.adapter.mapping).find( + (m) => m.tableName === tableName.toLowerCase() + ); + + if (!mapping) { + console.log(`⏭️ No mapping found for table: ${tableName}, skipping sync`); return; } + + console.log(`✅ Mapping found for ${tableName}, schemaId: ${mapping.schemaId}`); const data = this.entityToPlain(entity); - if (!data.id) return; + if (!data.id) { + console.log(`⏭️ Skipping handleChange for ${tableName}: data.id is missing`); + return; + } + + console.log(`📦 Prepared data for ${tableName}:`, { id: data.id, name: data.name || data.displayName || 'N/A' }); const changeKey = `${tableName}:${entity.id}`; @@ -210,24 +301,30 @@ export class PostgresSubscriber implements EntitySubscriberInterface { globalId = globalId ?? ""; if (this.adapter.lockedIds.includes(globalId)) { + console.log(`🔒 Skipping locked globalId: ${globalId}`); return; } // Check if this entity was recently created by a webhook if (this.adapter.lockedIds.includes(entity.id)) { + console.log(`🔒 Skipping locked localId: ${entity.id}`); return; } + console.log(`🚀 Sending change to adapter for ${tableName}:${entity.id}`); const envelope = await this.adapter.handleChange({ data, tableName: tableName.toLowerCase(), }); + console.log(`✅ Successfully synced ${tableName}:${entity.id}`); + } catch (error) { + console.error(`❌ Error in setTimeout for ${tableName}:`, error); } finally { this.pendingChanges.delete(changeKey); } }, 3_000); } catch (error) { - console.error(`Error processing change for ${tableName}:`, error); + console.error(`❌ Error processing change for ${tableName}:`, error); this.pendingChanges.delete(changeKey); } } @@ -243,6 +340,11 @@ export class PostgresSubscriber implements EntitySubscriberInterface { return entity.toISOString(); } + // Handle Buffer objects - convert to base64 + if (Buffer.isBuffer(entity)) { + return entity.toString("base64"); + } + if (Array.isArray(entity)) { return entity.map((item) => this.entityToPlain(item)); } @@ -256,6 +358,9 @@ export class PostgresSubscriber implements EntitySubscriberInterface { plain[key] = value.map((item) => this.entityToPlain(item)); } else if (value instanceof Date) { plain[key] = value.toISOString(); + } else if (Buffer.isBuffer(value)) { + // Convert Buffer to base64 string + plain[key] = value.toString("base64"); } else { plain[key] = this.entityToPlain(value); } diff --git a/platforms/file-manager/.svelte-kit/generated/server/internal.js b/platforms/file-manager/.svelte-kit/generated/server/internal.js index a359d726f..0e4049da6 100644 --- a/platforms/file-manager/.svelte-kit/generated/server/internal.js +++ b/platforms/file-manager/.svelte-kit/generated/server/internal.js @@ -24,7 +24,7 @@ export const options = { app: ({ head, body, assets, nonce, env }) => "\n\n\t\n\t\t\n\t\t\n\t\t" + head + "\n\t\n\t\n\t\t
" + body + "
\n\t\n\n\n", error: ({ status, message }) => "\n\n\t\n\t\t\n\t\t" + message + "\n\n\t\t\n\t\n\t\n\t\t
\n\t\t\t" + status + "\n\t\t\t
\n\t\t\t\t

" + message + "

\n\t\t\t
\n\t\t
\n\t\n\n" }, - version_hash: "9p1n1b" + version_hash: "1h8n1tp" }; export async function get_hooks() { diff --git a/services/ontology/schemas/file.json b/services/ontology/schemas/file.json new file mode 100644 index 000000000..cbfeaabbc --- /dev/null +++ b/services/ontology/schemas/file.json @@ -0,0 +1,66 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "title": "File", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "The unique identifier for the file" + }, + "name": { + "type": "string", + "description": "The original file name" + }, + "displayName": { + "type": "string", + "description": "Custom display name for the file" + }, + "description": { + "type": "string", + "description": "Optional description of the file" + }, + "mimeType": { + "type": "string", + "description": "MIME type of the file (e.g., application/pdf, image/png)" + }, + "size": { + "type": "integer", + "minimum": 0, + "description": "File size in bytes" + }, + "md5Hash": { + "type": "string", + "description": "MD5 hash of the file content for integrity verification" + }, + "data": { + "type": "string", + "format": "base64", + "description": "Base64-encoded file content (binary data)" + }, + "ownerId": { + "type": "string", + "format": "uuid", + "description": "ID of the user who owns the file" + }, + "folderId": { + "type": ["string", "null"], + "format": "uuid", + "description": "ID of the folder containing the file (null for root level)" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the file was created" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the file was last updated" + } + }, + "required": ["id", "name", "mimeType", "size", "md5Hash", "ownerId", "createdAt"], + "additionalProperties": false +} + diff --git a/services/ontology/schemas/signature.json b/services/ontology/schemas/signature.json new file mode 100644 index 000000000..733646c16 --- /dev/null +++ b/services/ontology/schemas/signature.json @@ -0,0 +1,52 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "b2c3d4e5-f6a7-8901-bcde-f12345678901", + "title": "Signature", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "The unique identifier for the signature" + }, + "fileId": { + "type": "string", + "format": "uuid", + "description": "ID of the file that was signed" + }, + "userId": { + "type": "string", + "format": "uuid", + "description": "ID of the user who created the signature" + }, + "md5Hash": { + "type": "string", + "description": "MD5 hash of the file content at the time of signing" + }, + "signature": { + "type": "string", + "description": "Cryptographic signature proving the user's agreement to the file" + }, + "publicKey": { + "type": "string", + "description": "User's public key for signature verification" + }, + "message": { + "type": "string", + "description": "Original message that was signed (usually contains file details)" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the signature was created" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the signature was last updated" + } + }, + "required": ["id", "fileId", "userId", "md5Hash", "signature", "publicKey", "message", "createdAt"], + "additionalProperties": false +} + From 3b7ee373bfb6c176cdc52ac79a49cd7de94e1b86 Mon Sep 17 00:00:00 2001 From: Merul Dhiman Date: Mon, 5 Jan 2026 16:30:37 +0530 Subject: [PATCH 3/8] feat: signature sync --- .../src/controllers/FileController.ts | 1 + .../src/services/InvitationService.ts | 11 +++ .../mappings/signature.mapping.json | 3 +- .../src/web3adapter/watchers/subscriber.ts | 16 +++- .../esigner/src/lib/stores/signatures.ts | 1 + .../(protected)/files/[id]/+page.svelte | 24 ++++-- .../routes/(protected)/files/new/+page.svelte | 6 +- .../src/controllers/FileController.ts | 86 +++++++++++++++---- platforms/file-manager-api/src/index.ts | 1 + .../src/services/FileService.ts | 8 ++ .../src/web3adapter/watchers/subscriber.ts | 13 --- .../file-manager/src/lib/stores/signatures.ts | 38 ++++++++ .../(protected)/files/[id]/+page.svelte | 35 ++++++++ 13 files changed, 201 insertions(+), 42 deletions(-) create mode 100644 platforms/file-manager/src/lib/stores/signatures.ts diff --git a/platforms/esigner-api/src/controllers/FileController.ts b/platforms/esigner-api/src/controllers/FileController.ts index 665d97a76..92c246538 100644 --- a/platforms/esigner-api/src/controllers/FileController.ts +++ b/platforms/esigner-api/src/controllers/FileController.ts @@ -199,6 +199,7 @@ export class FileController { res.json(signatures.map(sig => ({ id: sig.id, userId: sig.userId, + fileSigneeId: sig.fileSigneeId || null, user: sig.user ? { id: sig.user.id, name: sig.user.name, diff --git a/platforms/esigner-api/src/services/InvitationService.ts b/platforms/esigner-api/src/services/InvitationService.ts index 759d131cb..86aff82d7 100644 --- a/platforms/esigner-api/src/services/InvitationService.ts +++ b/platforms/esigner-api/src/services/InvitationService.ts @@ -1,6 +1,7 @@ import { AppDataSource } from "../database/data-source"; import { File } from "../database/entities/File"; import { FileSignee } from "../database/entities/FileSignee"; +import { SignatureContainer } from "../database/entities/SignatureContainer"; import { User } from "../database/entities/User"; import { In } from "typeorm"; import { NotificationService } from "./NotificationService"; @@ -8,6 +9,7 @@ import { NotificationService } from "./NotificationService"; export class InvitationService { private fileRepository = AppDataSource.getRepository(File); private fileSigneeRepository = AppDataSource.getRepository(FileSignee); + private signatureRepository = AppDataSource.getRepository(SignatureContainer); private userRepository = AppDataSource.getRepository(User); private notificationService = new NotificationService(); @@ -25,6 +27,15 @@ export class InvitationService { throw new Error("File not found or user is not the owner"); } + // Check if file already has signatures (single-use enforcement) + const existingSignatures = await this.signatureRepository.find({ + where: { fileId }, + }); + + if (existingSignatures.length > 0) { + throw new Error("This file has already been used in a signature container and cannot be reused"); + } + // Filter out the owner from userIds (they can't invite themselves) const filteredUserIds = userIds.filter(userId => userId !== invitedBy); diff --git a/platforms/esigner-api/src/web3adapter/mappings/signature.mapping.json b/platforms/esigner-api/src/web3adapter/mappings/signature.mapping.json index 2b6fdfca4..d62ee6437 100644 --- a/platforms/esigner-api/src/web3adapter/mappings/signature.mapping.json +++ b/platforms/esigner-api/src/web3adapter/mappings/signature.mapping.json @@ -13,5 +13,4 @@ "createdAt": "__date(createdAt)", "updatedAt": "__date(updatedAt)" } -} - +} \ No newline at end of file diff --git a/platforms/esigner-api/src/web3adapter/watchers/subscriber.ts b/platforms/esigner-api/src/web3adapter/watchers/subscriber.ts index de478a608..dd86beab5 100644 --- a/platforms/esigner-api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/esigner-api/src/web3adapter/watchers/subscriber.ts @@ -253,13 +253,23 @@ export class PostgresSubscriber implements EntitySubscriberInterface { } private async handleChange(entity: any, tableName: string): Promise { - // Handle users, groups, and messages - if (tableName !== "users" && tableName !== "groups" && tableName !== "messages") { + if (!entity || !entity.id) { + return; + } + + // Check if there's a mapping for this table + const mapping = Object.values(this.adapter.mapping).find( + (m) => m.tableName === tableName.toLowerCase() + ); + + if (!mapping) { return; } const data = this.entityToPlain(entity); - if (!data.id) return; + if (!data.id) { + return; + } const changeKey = `${tableName}:${entity.id}`; diff --git a/platforms/esigner/src/lib/stores/signatures.ts b/platforms/esigner/src/lib/stores/signatures.ts index 9458f8b0d..7525c4111 100644 --- a/platforms/esigner/src/lib/stores/signatures.ts +++ b/platforms/esigner/src/lib/stores/signatures.ts @@ -5,6 +5,7 @@ import type { Writable } from 'svelte/store'; export interface Signature { id: string; userId: string; + fileSigneeId?: string | null; md5Hash: string; message: string; signature: string; diff --git a/platforms/esigner/src/routes/(protected)/files/[id]/+page.svelte b/platforms/esigner/src/routes/(protected)/files/[id]/+page.svelte index 71341adc3..06b17c36d 100644 --- a/platforms/esigner/src/routes/(protected)/files/[id]/+page.svelte +++ b/platforms/esigner/src/routes/(protected)/files/[id]/+page.svelte @@ -66,11 +66,21 @@ } function checkIfUserSigned() { - if (!$currentUser || !$signatures) { + if (!$currentUser || !$signatures || !invitations) { hasUserSigned = false; return; } - hasUserSigned = $signatures.some(sig => sig.userId === $currentUser.id); + + // Check if current user has signed in THIS specific set of invitations + // Match by fileSigneeId to ensure we only check signatures for this container + const userInvitation = invitations.find(inv => inv.userId === $currentUser.id); + if (!userInvitation) { + hasUserSigned = false; + return; + } + + // Check if there's a signature linked to this specific invitation + hasUserSigned = $signatures.some(sig => sig.fileSigneeId === userInvitation.id); } async function createPreview() { @@ -160,15 +170,19 @@ } function getCombinedSignees() { - // Create a map of user IDs to their signature data + // Create a map of fileSigneeId to their signature data + // This ensures signatures are matched to the specific invitation, not just by userId const signatureMap = new Map(); $signatures.forEach(sig => { - signatureMap.set(sig.userId, sig); + if (sig.fileSigneeId) { + signatureMap.set(sig.fileSigneeId, sig); + } }); // Combine invitations with their signature data if they've signed + // Match by fileSigneeId to ensure signatures are tied to specific invitations return invitations.map(inv => { - const signature = signatureMap.get(inv.userId); + const signature = signatureMap.get(inv.id); return { ...inv, signature: signature || null, diff --git a/platforms/esigner/src/routes/(protected)/files/new/+page.svelte b/platforms/esigner/src/routes/(protected)/files/new/+page.svelte index a62f4a119..ea9bda249 100644 --- a/platforms/esigner/src/routes/(protected)/files/new/+page.svelte +++ b/platforms/esigner/src/routes/(protected)/files/new/+page.svelte @@ -280,11 +280,11 @@

Or Select Existing File

- {#if $files.length === 0} -

No files available

+ {#if $files.filter(file => !file.signatures || file.signatures.length === 0).length === 0} +

No unused files available

{:else}
- {#each $files as file} + {#each $files.filter(file => !file.signatures || file.signatures.length === 0) as file}
+ + {#if file.signatures && file.signatures.length > 0} +
+

+ Signatures ({file.signatures.length}) +

+
+ {#each file.signatures as sig} +
+
+ {#if sig.user?.avatarUrl} + {sig.user.name + {:else} + + {(sig.user?.name || sig.user?.ename || '?')[0].toUpperCase()} + + {/if} +
+
+

+ {sig.user?.name || sig.user?.ename || 'Unknown User'} +

+

+ Signed on {formatDate(sig.createdAt)} +

+
+ +
+ {/each} +
+
+ {/if} +
From 24272fd570d97ee1635c8a527ee5a2a0269f4b89 Mon Sep 17 00:00:00 2001 From: Merul Dhiman Date: Mon, 5 Jan 2026 16:52:35 +0530 Subject: [PATCH 4/8] chore: better UX --- .../file-manager/src/lib/stores/files.ts | 6 +- .../file-manager/src/lib/stores/folders.ts | 6 +- .../src/routes/(protected)/files/+page.svelte | 408 ++++++++++++++---- 3 files changed, 330 insertions(+), 90 deletions(-) diff --git a/platforms/file-manager/src/lib/stores/files.ts b/platforms/file-manager/src/lib/stores/files.ts index 840750ad3..7703c669e 100644 --- a/platforms/file-manager/src/lib/stores/files.ts +++ b/platforms/file-manager/src/lib/stores/files.ts @@ -91,7 +91,9 @@ export const updateFile = async ( }; export const moveFile = async (fileId: string, folderId: string | null): Promise => { - await apiClient.post(`/api/files/${fileId}/move`, { folderId: folderId || 'null' }); - await fetchFiles(); + const response = await apiClient.post(`/api/files/${fileId}/move`, { folderId: folderId || 'null' }); + const updatedFile = response.data; + // Update the file in the store with the new folderId + files.update(files => files.map(f => f.id === fileId ? { ...f, folderId: updatedFile.folderId } : f)); }; diff --git a/platforms/file-manager/src/lib/stores/folders.ts b/platforms/file-manager/src/lib/stores/folders.ts index 80f9b0977..8c43cd79e 100644 --- a/platforms/file-manager/src/lib/stores/folders.ts +++ b/platforms/file-manager/src/lib/stores/folders.ts @@ -79,8 +79,10 @@ export const updateFolder = async ( }; export const moveFolder = async (folderId: string, parentFolderId: string | null): Promise => { - await apiClient.post(`/api/folders/${folderId}/move`, { parentFolderId: parentFolderId || 'null' }); - await fetchFolders(); + const response = await apiClient.post(`/api/folders/${folderId}/move`, { parentFolderId: parentFolderId || 'null' }); + const updatedFolder = response.data; + // Update the folder in the store with the new parentFolderId + folders.update(folders => folders.map(f => f.id === folderId ? { ...f, parentFolderId: updatedFolder.parentFolderId } : f)); }; export const getFolderContents = async (folderId: string): Promise<{ files: any[]; folders: any[] }> => { diff --git a/platforms/file-manager/src/routes/(protected)/files/+page.svelte b/platforms/file-manager/src/routes/(protected)/files/+page.svelte index 5942dea0b..334bb0b69 100644 --- a/platforms/file-manager/src/routes/(protected)/files/+page.svelte +++ b/platforms/file-manager/src/routes/(protected)/files/+page.svelte @@ -3,8 +3,8 @@ import { page } from '$app/stores'; import { goto } from '$app/navigation'; import { isAuthenticated } from '$lib/stores/auth'; - import { files, fetchFiles, uploadFile, deleteFile } from '$lib/stores/files'; - import { folders, fetchFolders, fetchFolderTree, folderTree, createFolder, deleteFolder } from '$lib/stores/folders'; + import { files, fetchFiles, uploadFile, deleteFile, moveFile } from '$lib/stores/files'; + import { folders, fetchFolders, fetchFolderTree, folderTree, createFolder, deleteFolder, moveFolder } from '$lib/stores/folders'; import { apiClient } from '$lib/utils/axios'; import { PUBLIC_FILE_MANAGER_BASE_URL } from '$env/static/public'; import { toast } from '$lib/stores/toast'; @@ -15,9 +15,17 @@ let isLoading = $state(false); let showUploadModal = $state(false); let showFolderModal = $state(false); + let showMoveModal = $state(false); + let showDeleteModal = $state(false); let selectedFile = $state(null); + let itemToMove = $state(null); + let itemToDelete = $state<{ type: 'file' | 'folder'; id: string; name: string } | null>(null); + let moveModalFolderId = $state(null); + let moveModalBreadcrumbs = $state>([{ id: null, name: 'My Files' }]); + let moveModalFolders = $state([]); let folderName = $state(''); let dragOver = $state(false); + let uploadModalDragOver = $state(false); let previewFile = $state(null); let previewUrl = $state(null); let breadcrumbs = $state>([{ id: null, name: 'My Files' }]); @@ -88,35 +96,116 @@ } } - async function handleDeleteFile(fileId: string) { - if (!confirm('Are you sure you want to delete this file?')) { - return; - } + function openDeleteModal(item: any, type: 'file' | 'folder') { + itemToDelete = { + type, + id: item.id, + name: item.displayName || item.name + }; + showDeleteModal = true; + } + + async function handleDelete() { + if (!itemToDelete) return; try { - await deleteFile(fileId); - toast.success('File deleted successfully'); + if (itemToDelete.type === 'file') { + await deleteFile(itemToDelete.id); + toast.success('File deleted successfully'); + } else { + await deleteFolder(itemToDelete.id); + toast.success('Folder deleted successfully'); + await loadFiles(); + } + showDeleteModal = false; + itemToDelete = null; } catch (error) { - console.error('Failed to delete file:', error); - toast.error('Failed to delete file'); + console.error('Failed to delete:', error); + toast.error(`Failed to delete ${itemToDelete.type}`); } } - async function handleDeleteFolder(folderId: string) { - if (!confirm('Are you sure you want to delete this folder? All contents will be deleted.')) { - return; + async function handleMove(item: any, type: 'file' | 'folder', folderId: string | null) { + try { + isLoading = true; + if (type === 'file') { + await moveFile(item.id, folderId); + toast.success('File moved successfully'); + } else { + await moveFolder(item.id, folderId); + toast.success('Folder moved successfully'); + } + showMoveModal = false; + itemToMove = null; + moveModalFolderId = null; + moveModalBreadcrumbs = [{ id: null, name: 'My Files' }]; + await loadFiles(); + } catch (error) { + console.error('Failed to move:', error); + toast.error(`Failed to move ${type}`); + } finally { + isLoading = false; } + } + async function openMoveModal(item: any, type: 'file' | 'folder') { + itemToMove = { ...item, _type: type }; + moveModalFolderId = null; + moveModalBreadcrumbs = [{ id: null, name: 'My Files' }]; + showMoveModal = true; + await loadMoveModalFolders(null); + } + + async function loadMoveModalFolders(folderId: string | null) { try { - await deleteFolder(folderId); - toast.success('Folder deleted successfully'); - await loadFiles(); + const params: any = {}; + if (folderId === null || folderId === undefined) { + params.parentFolderId = 'null'; + } else { + params.parentFolderId = folderId; + } + const response = await apiClient.get('/api/folders', { params }); + moveModalFolders = response.data || []; } catch (error) { - console.error('Failed to delete folder:', error); - toast.error('Failed to delete folder'); + console.error('Failed to load folders for move modal:', error); + moveModalFolders = []; } } + async function navigateMoveModal(folderId: string | null) { + moveModalFolderId = folderId; + await loadMoveModalFolders(folderId); + await updateMoveModalBreadcrumbs(folderId); + } + + async function updateMoveModalBreadcrumbs(folderId: string | null) { + if (!folderId) { + moveModalBreadcrumbs = [{ id: null, name: 'My Files' }]; + return; + } + + const buildPath = async (fId: string | null, path: Array<{ id: string | null; name: string }> = []): Promise> => { + if (!fId) { + return [{ id: null, name: 'My Files' }, ...path]; + } + + try { + const response = await apiClient.get(`/api/folders/${fId}`); + const folder = response.data; + const newPath = [{ id: folder.id, name: folder.name }, ...path]; + if (folder.parentFolderId) { + return buildPath(folder.parentFolderId, newPath); + } + return [{ id: null, name: 'My Files' }, ...newPath]; + } catch (error) { + console.error('Failed to fetch folder for breadcrumb:', error); + return [{ id: null, name: 'My Files' }, ...path]; + } + }; + + moveModalBreadcrumbs = await buildPath(folderId); + } + function handleFileSelect(event: Event) { const target = event.target as HTMLInputElement; if (target.files && target.files[0]) { @@ -124,6 +213,28 @@ } } + function handleUploadModalDragOver(event: DragEvent) { + event.preventDefault(); + event.stopPropagation(); + uploadModalDragOver = true; + } + + function handleUploadModalDragLeave(event: DragEvent) { + event.preventDefault(); + event.stopPropagation(); + uploadModalDragOver = false; + } + + async function handleUploadModalDrop(event: DragEvent) { + event.preventDefault(); + event.stopPropagation(); + uploadModalDragOver = false; + + if (event.dataTransfer?.files && event.dataTransfer.files.length > 0) { + selectedFile = event.dataTransfer.files[0]; + } + } + function handleDragOver(event: DragEvent) { event.preventDefault(); dragOver = true; @@ -357,22 +468,32 @@ {formatDate(item.updatedAt || item.createdAt)} - +
+ + +
{/each} @@ -386,19 +507,52 @@ {#if showUploadModal}
-
+

Upload File

- - {#if selectedFile} -

Selected: {selectedFile.name}

- {/if} -
+ + +
+ {#if selectedFile} +
+ + + +

{selectedFile.name}

+

{(selectedFile.size / 1024 / 1024).toFixed(2)} MB

+
+ + {:else} + + + +

Drag and drop your file here

+

or

+ + {/if} +
+ +
-
-
-
-{/if} - - -{#if previewFile && previewUrl} -
-
e.stopPropagation()}> -
-

{previewFile.displayName || previewFile.name}

- -
- {#if previewFile.mimeType.startsWith('image/')} - {previewFile.name} - {:else if previewFile.mimeType === 'application/pdf'} - - {/if} -
- - Download - -
@@ -485,6 +600,126 @@
{/if} + +{#if showMoveModal && itemToMove} +
+
+

Move {itemToMove._type === 'file' ? 'File' : 'Folder'}

+

Moving: {itemToMove.displayName || itemToMove.name}

+ + +
+ +
+ + +
+ {#if moveModalFolders.length === 0} +
+

No folders in this location

+
+ {:else} +
+ {#each moveModalFolders.filter(f => itemToMove?._type !== 'folder' || f.id !== itemToMove?.id) as folder} + + {/each} +
+ {/if} +
+ + +
+

+ Current location: {moveModalBreadcrumbs[moveModalBreadcrumbs.length - 1].name} +

+
+ + +
+ + +
+
+
+{/if} + + +{#if showDeleteModal && itemToDelete} +
+
+

Delete {itemToDelete.type === 'file' ? 'File' : 'Folder'}

+

+ Are you sure you want to delete {itemToDelete.name}? +

+ {#if itemToDelete.type === 'folder'} +

+ ⚠️ This will delete the folder and all its contents permanently. +

+ {:else} +

+ ⚠️ This action cannot be undone. +

+ {/if} +
+ + +
+
+
+{/if} + {#if previewFile && previewUrl}
@@ -524,3 +759,4 @@
{/if} + From 657b1ddd220f3a126c6f71337b3e01b1c3fa0862 Mon Sep 17 00:00:00 2001 From: Merul Dhiman Date: Tue, 6 Jan 2026 01:49:45 +0530 Subject: [PATCH 5/8] fix: file manager UX --- .../routes/(protected)/files/new/+page.svelte | 15 +- .../src/controllers/AccessController.ts | 38 ++ .../src/controllers/FileController.ts | 21 +- .../src/controllers/FolderController.ts | 5 + platforms/file-manager-api/src/index.ts | 19 +- .../src/services/FileService.ts | 112 +++- .../src/services/FolderService.ts | 105 +++- .../src/services/GroupService.ts | 3 + .../src/services/NotificationService.ts | 10 +- .../file-manager/.svelte-kit/ambient.d.ts | 96 ++- .../.svelte-kit/generated/server/internal.js | 2 +- .../lib/components/UserMenuDropdown.svelte | 2 +- .../src/routes/(protected)/files/+page.svelte | 580 +++++++++++++++--- .../(protected)/files/[id]/+page.svelte | 214 ++++--- 14 files changed, 1011 insertions(+), 211 deletions(-) diff --git a/platforms/esigner/src/routes/(protected)/files/new/+page.svelte b/platforms/esigner/src/routes/(protected)/files/new/+page.svelte index ea9bda249..5d69c9006 100644 --- a/platforms/esigner/src/routes/(protected)/files/new/+page.svelte +++ b/platforms/esigner/src/routes/(protected)/files/new/+page.svelte @@ -64,10 +64,9 @@ // Don't upload immediately - just store the file uploadedFile = target.files[0]; selectedFile = null; - // Set default display name - if (!displayName.trim()) { - displayName = target.files[0].name; - } + // Reset display name and description for new upload + displayName = target.files[0].name; + description = ''; } } @@ -87,10 +86,9 @@ // Don't upload immediately - just store the file uploadedFile = event.dataTransfer.files[0]; selectedFile = null; - // Set default display name - if (!displayName.trim()) { - displayName = event.dataTransfer.files[0].name; - } + // Reset display name and description for new upload + displayName = event.dataTransfer.files[0].name; + description = ''; } } @@ -288,6 +286,7 @@
{#if $currentUser?.ename && $currentUser?.name} -
{$currentUser.ename}
+
@{$currentUser.ename.replace(/^@+/, '')}
{/if}
diff --git a/platforms/file-manager/src/routes/(protected)/files/+page.svelte b/platforms/file-manager/src/routes/(protected)/files/+page.svelte index 334bb0b69..ca718e5e3 100644 --- a/platforms/file-manager/src/routes/(protected)/files/+page.svelte +++ b/platforms/file-manager/src/routes/(protected)/files/+page.svelte @@ -2,24 +2,91 @@ import { onMount } from 'svelte'; import { page } from '$app/stores'; import { goto } from '$app/navigation'; - import { isAuthenticated } from '$lib/stores/auth'; + import { get } from 'svelte/store'; + import { isAuthenticated, currentUser } from '$lib/stores/auth'; import { files, fetchFiles, uploadFile, deleteFile, moveFile } from '$lib/stores/files'; import { folders, fetchFolders, fetchFolderTree, folderTree, createFolder, deleteFolder, moveFolder } from '$lib/stores/folders'; + import { grantFileAccess, grantFolderAccess, fetchFileAccess, fetchFolderAccess } from '$lib/stores/access'; import { apiClient } from '$lib/utils/axios'; import { PUBLIC_FILE_MANAGER_BASE_URL } from '$env/static/public'; import { toast } from '$lib/stores/toast'; const API_BASE_URL = PUBLIC_FILE_MANAGER_BASE_URL || 'http://localhost:3005'; + // Action to position dropdown using fixed positioning to avoid clipping + function dropdownPosition(node: HTMLElement) { + const updatePosition = () => { + // Get the button element directly from parent + const container = node.parentElement; + if (!container) return; + const button = container.querySelector('button[data-dropdown-button]') as HTMLElement; + if (!button) return; + + // Get button's viewport position using getBoundingClientRect + const buttonRect = button.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const spaceBelow = viewportHeight - buttonRect.bottom; + const spaceAbove = buttonRect.top; + + // Measure dropdown + const dropdownWidth = 192; // w-48 in tailwind + const dropdownHeight = node.offsetHeight || 120; + + // IMPORTANT: Use fixed positioning to escape table clipping + node.style.position = 'fixed'; + node.style.zIndex = '9999'; + + // Align right edge of dropdown with right edge of button + node.style.left = `${buttonRect.right - dropdownWidth}px`; + node.style.right = 'auto'; + + // Position vertically based on available space + if (spaceBelow < dropdownHeight && spaceAbove > spaceBelow) { + // Position above button + node.style.bottom = `${viewportHeight - buttonRect.top + 4}px`; + node.style.top = 'auto'; + } else { + // Position below button + node.style.top = `${buttonRect.bottom + 4}px`; + node.style.bottom = 'auto'; + } + }; + + // Use requestAnimationFrame to ensure DOM is ready + requestAnimationFrame(() => { + updatePosition(); + }); + + // Update on scroll and resize + const handleUpdate = () => updatePosition(); + window.addEventListener('scroll', handleUpdate, true); + window.addEventListener('resize', handleUpdate); + + return { + update: updatePosition, + destroy: () => { + window.removeEventListener('scroll', handleUpdate, true); + window.removeEventListener('resize', handleUpdate); + } + }; + } + + let currentView = $state<'my-files' | 'shared'>('my-files'); let currentFolderId = $state(null); let isLoading = $state(false); let showUploadModal = $state(false); let showFolderModal = $state(false); let showMoveModal = $state(false); let showDeleteModal = $state(false); + let showShareModal = $state(false); + let itemToShare = $state<{ type: 'file' | 'folder'; id: string; name: string } | null>(null); let selectedFile = $state(null); let itemToMove = $state(null); let itemToDelete = $state<{ type: 'file' | 'folder'; id: string; name: string } | null>(null); + let openDropdownId = $state(null); + let shareSearchQuery = $state(''); + let shareSearchResults = $state([]); + let shareSelectedUsers = $state([]); let moveModalFolderId = $state(null); let moveModalBreadcrumbs = $state>([{ id: null, name: 'My Files' }]); let moveModalFolders = $state([]); @@ -30,6 +97,21 @@ let previewUrl = $state(null); let breadcrumbs = $state>([{ id: null, name: 'My Files' }]); + // Subscribe to stores at top level to make them reactive + let user = $state(get(currentUser)); + let filesList = $state(get(files)); + let foldersList = $state(get(folders)); + + currentUser.subscribe(u => { + user = u; + }); + files.subscribe(f => { + filesList = f; + }); + folders.subscribe(f => { + foldersList = f; + }); + onMount(async () => { isAuthenticated.subscribe((auth) => { if (!auth) { @@ -40,6 +122,18 @@ await fetchFolderTree(); await loadFiles(); await updateBreadcrumbs(); + + // Close dropdown when clicking outside + function handleClickOutside(event: MouseEvent) { + const target = event.target as HTMLElement; + if (!target.closest('.dropdown-container') && !target.closest('button[title="Actions"]')) { + openDropdownId = null; + } + } + document.addEventListener('click', handleClickOutside); + return () => { + document.removeEventListener('click', handleClickOutside); + }; }); async function loadFiles() { @@ -206,6 +300,106 @@ moveModalBreadcrumbs = await buildPath(folderId); } + function openShareModal(item: any, type: 'file' | 'folder') { + itemToShare = { + type, + id: item.id, + name: item.displayName || item.name + }; + shareSearchQuery = ''; + shareSearchResults = []; + shareSelectedUsers = []; + showShareModal = true; + openDropdownId = null; + } + + async function searchUsersForShare() { + if (shareSearchQuery.length < 2) { + shareSearchResults = []; + return; + } + + try { + // Search both users and groups + const [usersResponse, groupsResponse] = await Promise.all([ + apiClient.get('/api/users/search', { + params: { query: shareSearchQuery } + }), + apiClient.get('/api/groups/search', { + params: { query: shareSearchQuery } + }) + ]); + + // Mark users with type 'user' and groups with type 'group' + const users = (usersResponse.data || []).map((u: any) => ({ ...u, type: 'user' })); + const groups = (groupsResponse.data || []).map((g: any) => ({ + ...g, + type: 'group', + memberCount: (g.members?.length || 0) + (g.participants?.length || 0) + (g.admins?.length || 0) + })); + + shareSearchResults = [...users, ...groups]; + } catch (error) { + console.error('Search failed:', error); + shareSearchResults = []; + } + } + + async function handleGrantShare() { + if (!itemToShare || shareSelectedUsers.length === 0) { + toast.error('Please select at least one user or group'); + return; + } + + try { + isLoading = true; + let shareCount = 0; + + for (const item of shareSelectedUsers) { + if (item.type === 'group') { + // Share with all members of the group + const groupMembers = [ + ...(item.members || []), + ...(item.participants || []), + ...(item.admins || []) + ]; + + // Remove duplicates by id + const uniqueMembers = Array.from(new Map(groupMembers.map(m => [m.id, m])).values()); + + for (const member of uniqueMembers) { + if (itemToShare.type === 'file') { + await grantFileAccess(itemToShare.id, member.id); + } else { + await grantFolderAccess(itemToShare.id, member.id); + } + shareCount++; + } + } else { + // Share with individual user + if (itemToShare.type === 'file') { + await grantFileAccess(itemToShare.id, item.id); + } else { + await grantFolderAccess(itemToShare.id, item.id); + } + shareCount++; + } + } + + toast.success(`${itemToShare.type === 'file' ? 'File' : 'Folder'} shared with ${shareCount} ${shareCount === 1 ? 'person' : 'people'}`); + showShareModal = false; + itemToShare = null; + shareSelectedUsers = []; + shareSearchQuery = ''; + shareSearchResults = []; + } catch (error) { + console.error('Failed to share:', error); + toast.error(`Failed to share ${itemToShare?.type || 'item'}`); + } finally { + isLoading = false; + } + } + function handleFileSelect(event: Event) { const target = event.target as HTMLInputElement; if (target.files && target.files[0]) { @@ -260,32 +454,52 @@ await updateBreadcrumbs(); } + async function switchView(view: 'my-files' | 'shared') { + if (currentView === view) return; // Don't reload if already on this view + currentView = view; + currentFolderId = null; // Reset to root when switching views + await loadFiles(); // Reload files when switching views + await updateBreadcrumbs(); // Update breadcrumbs with correct root name + } + async function updateBreadcrumbs() { + const rootName = currentView === 'shared' ? 'Shared with me' : 'My Files'; + if (!currentFolderId) { - breadcrumbs = [{ id: null, name: 'My Files' }]; + breadcrumbs = [{ id: null, name: rootName }]; return; } // Build breadcrumb path by fetching folder details - const buildPath = async (folderId: string | null, path: Array<{ id: string | null; name: string }> = []): Promise> => { + const buildPath = async (folderId: string | null, path: Array<{ id: string | null; name: string }> = [], visited: Set = new Set()): Promise> => { if (!folderId) { - return [{ id: null, name: 'My Files' }, ...path]; + return [{ id: null, name: rootName }, ...path]; } + // Prevent infinite loops + if (visited.has(folderId)) { + return [{ id: null, name: rootName }, ...path]; + } + visited.add(folderId); + try { // Fetch folder details const response = await apiClient.get(`/api/folders/${folderId}`); const folder = response.data; + if (!folder) { + return [{ id: null, name: rootName }, ...path]; + } + const newPath = [{ id: folder.id, name: folder.name }, ...path]; // Use parentFolderId (not parentId) as that's what the API returns if (folder.parentFolderId) { - return buildPath(folder.parentFolderId, newPath); + return buildPath(folder.parentFolderId, newPath, visited); } - return [{ id: null, name: 'My Files' }, ...newPath]; + return [{ id: null, name: rootName }, ...newPath]; } catch (error) { console.error('Failed to fetch folder for breadcrumb:', error); - return [{ id: null, name: 'My Files' }, ...path]; + return [{ id: null, name: rootName }, ...path]; } }; @@ -338,69 +552,126 @@ }); } - // Combine folders and files, with folders first - const allItems = $derived([ - ...$folders.map(f => ({ ...f, type: 'folder' as const })), - ...$files.map(f => ({ ...f, type: 'file' as const })) - ].sort((a, b) => { - // Folders first, then by date - if (a.type !== b.type) { - return a.type === 'folder' ? -1 : 1; + // Filter files and folders based on current view + const filteredFiles = $derived.by(() => { + const filesArray = Array.isArray(filesList) ? filesList : []; + if (!filesArray.length) { + return []; + } + if (!user?.id) { + // If user not loaded yet, return all files (will be filtered once user loads) + return filesArray; + } + if (currentView === 'my-files') { + // Only show files owned by current user + return filesArray.filter(f => f.ownerId === user.id); + } else { + // Only show files shared with current user (not owned by them) + return filesArray.filter(f => f.ownerId !== user.id); + } + }); + + const filteredFolders = $derived.by(() => { + const foldersArray = Array.isArray(foldersList) ? foldersList : []; + if (!foldersArray.length) { + return []; + } + if (!user?.id) { + // If user not loaded yet, return all folders (will be filtered once user loads) + return currentView === 'my-files' ? foldersArray : []; + } + if (currentView === 'my-files') { + // Only show folders owned by current user + return foldersArray.filter(f => f.ownerId === user.id); + } else { + // Show folders shared with current user (not owned by them) + return foldersArray.filter(f => f.ownerId !== user.id); } - return new Date(b.updatedAt || b.createdAt).getTime() - new Date(a.updatedAt || a.createdAt).getTime(); - })); + }); + + // Combine folders and files, with folders first + const allItems = $derived.by(() => { + const foldersList = Array.isArray(filteredFolders) ? filteredFolders : []; + const filesList = Array.isArray(filteredFiles) ? filteredFiles : []; + + return [ + ...foldersList.map(f => ({ ...f, type: 'folder' as const })), + ...filesList.map(f => ({ ...f, type: 'file' as const })) + ].sort((a, b) => { + // Folders first, then by date + if (a.type !== b.type) { + return a.type === 'folder' ? -1 : 1; + } + return new Date(b.updatedAt || b.createdAt).getTime() - new Date(a.updatedAt || a.createdAt).getTime(); + }); + });
- -
- -
- -
-
-

- {breadcrumbs[breadcrumbs.length - 1].name} -

-
-
+ +
+ +
+ {#if currentView === 'my-files'} + + + {/if}
+ + {#if breadcrumbs.length > 1 || (breadcrumbs.length === 1 && breadcrumbs[0].id !== null)} +
+ +
+ {/if} +
{:else if allItems.length === 0}
-

No files or folders yet

-

Drag and drop files here or click Upload

+ {#if currentView === 'my-files'} +

No files or folders yet

+

Drag and drop files here or click Upload

+ {:else} +

No files shared with you

+

Files that others share with you will appear here

+ {/if}
{:else} -
- +
+
- + {#each allItems as item} { + e.stopPropagation(); if (item.type === 'folder') { navigateToFolder(item.id); } else { @@ -452,8 +729,13 @@ {item.type === 'folder' ? '📁' : getFileIcon(item.type === 'file' ? item.mimeType : '')}
-
- {item.displayName || item.name} +
+
+ {item.displayName || item.name} +
+ {#if currentView === 'shared' && item.owner} + by {item.owner.name || item.owner.ename} + {/if}
{#if item.type === 'file' && item.description}
{item.description}
@@ -467,32 +749,90 @@
- @@ -720,6 +1060,82 @@ {/if} + +{#if showShareModal && itemToShare} +
{ showShareModal = false; itemToShare = null; shareSelectedUsers = []; shareSearchQuery = ''; shareSearchResults = []; }}> +
e.stopPropagation()}> +

Share {itemToShare.type === 'file' ? 'File' : 'Folder'}

+

Sharing: {itemToShare.name}

+ + {#if shareSearchResults.length > 0} +
+ {#each shareSearchResults as item} +
{ + if (shareSelectedUsers.find(u => u.id === item.id)) { + shareSelectedUsers = shareSelectedUsers.filter(u => u.id !== item.id); + } else { + shareSelectedUsers = [...shareSelectedUsers, item]; + } + }} + > +
+ {#if item.type === 'group'} + + + +
+
{item.name}
+
{item.memberCount} {item.memberCount === 1 ? 'member' : 'members'}
+
+ {:else} + + + +
+
{item.name || item.ename}
+ {#if item.name && item.ename} +
@{item.ename.replace(/^@+/, '')}
+ {/if} +
+ {/if} +
+
+ {/each} +
+ {/if} +
+ + +
+
+
+{/if} + {#if previewFile && previewUrl}
diff --git a/platforms/file-manager/src/routes/(protected)/files/[id]/+page.svelte b/platforms/file-manager/src/routes/(protected)/files/[id]/+page.svelte index f8a6d1e3a..84c339d67 100644 --- a/platforms/file-manager/src/routes/(protected)/files/[id]/+page.svelte +++ b/platforms/file-manager/src/routes/(protected)/files/[id]/+page.svelte @@ -38,8 +38,13 @@ return; } await loadFile(fileId); - await fetchFileAccess(fileId); - await fetchTags(); + + // Only fetch access and tags if user is the owner + if (file && file.ownerId === $currentUser?.id) { + await fetchFileAccess(fileId); + await fetchTags(); + } + await fetchFileSignatures(fileId); }); @@ -137,10 +142,25 @@ } try { - const response = await apiClient.get('/api/users/search', { - params: { query: searchQuery } - }); - searchResults = response.data; + // Search both users and groups + const [usersResponse, groupsResponse] = await Promise.all([ + apiClient.get('/api/users/search', { + params: { query: searchQuery } + }), + apiClient.get('/api/groups/search', { + params: { query: searchQuery } + }) + ]); + + // Mark users with type 'user' and groups with type 'group' + const users = (usersResponse.data || []).map((u: any) => ({ ...u, type: 'user' })); + const groups = (groupsResponse.data || []).map((g: any) => ({ + ...g, + type: 'group', + memberCount: (g.members?.length || 0) + (g.participants?.length || 0) + (g.admins?.length || 0) + })); + + searchResults = [...users, ...groups]; } catch (error) { console.error('Search failed:', error); searchResults = []; @@ -149,15 +169,37 @@ async function handleGrantAccess() { if (selectedUsers.length === 0) { - toast.error('Please select at least one user'); + toast.error('Please select at least one user or group'); return; } try { - for (const user of selectedUsers) { - await grantFileAccess(file.id, user.id); + let shareCount = 0; + + for (const item of selectedUsers) { + if (item.type === 'group') { + // Share with all members of the group + const groupMembers = [ + ...(item.members || []), + ...(item.participants || []), + ...(item.admins || []) + ]; + + // Remove duplicates by id + const uniqueMembers = Array.from(new Map(groupMembers.map(m => [m.id, m])).values()); + + for (const member of uniqueMembers) { + await grantFileAccess(file.id, member.id); + shareCount++; + } + } else { + // Share with individual user + await grantFileAccess(file.id, item.id); + shareCount++; + } } - toast.success('Access granted successfully'); + + toast.success(`File shared with ${shareCount} ${shareCount === 1 ? 'person' : 'people'}`); showAccessModal = false; selectedUsers = []; searchQuery = ''; @@ -358,29 +400,31 @@
- -
-
-

Tags

- -
- {#if file.tags && file.tags.length > 0} -
- {#each file.tags as tag} - - {tag.name} - - {/each} + + {#if file.ownerId === $currentUser?.id} +
+
+

Tags

+
- {:else} -

No tags yet

- {/if} -
+ {#if file.tags && file.tags.length > 0} +
+ {#each file.tags as tag} + + {tag.name} + + {/each} +
+ {:else} +

No tags yet

+ {/if} +
+ {/if} {#if file.signatures && file.signatures.length > 0} @@ -415,35 +459,35 @@
{/if} - -
-
-

Shared with

- -
- {#if $fileAccess && $fileAccess.length > 0} -
- {#each $fileAccess as access} -
-
- {#if access.user?.avatarUrl} - {access.user.name} - {:else} -
- {(access.user?.name || access.user?.ename || 'U')[0].toUpperCase()} + + {#if file.ownerId === $currentUser?.id} +
+
+

Shared with

+ +
+ {#if $fileAccess && $fileAccess.length > 0} +
+ {#each $fileAccess as access} +
+
+ {#if access.user?.avatarUrl} + {access.user.name} + {:else} +
+ {(access.user?.name || access.user?.ename || 'U')[0].toUpperCase()} +
+ {/if} +
+

{access.user?.name || access.user?.ename || 'Unknown'}

+

{access.permission}

- {/if} -
-

{access.user?.name || access.user?.ename || 'Unknown'}

-

{access.permission}

-
- {#if file.ownerId === $currentUser?.id} - {/if} -
- {/each} -
- {:else} -

Not shared with anyone

- {/if} -
+
+ {/each} +
+ {:else} +

Not shared with anyone

+ {/if} +
+ {/if}
{/if} @@ -484,23 +528,43 @@ type="text" bind:value={searchQuery} oninput={searchUsers} - placeholder="Search users..." + placeholder="Search users or groups..." class="w-full px-4 py-2 border border-gray-300 rounded-lg mb-4" /> {#if searchResults.length > 0} -
- {#each searchResults as user} +
+ {#each searchResults as item}
{ - if (selectedUsers.find(u => u.id === user.id)) { - selectedUsers = selectedUsers.filter(u => u.id !== user.id); + if (selectedUsers.find(u => u.id === item.id)) { + selectedUsers = selectedUsers.filter(u => u.id !== item.id); } else { - selectedUsers = [...selectedUsers, user]; + selectedUsers = [...selectedUsers, item]; } }} > - {user.name || user.ename} +
+ {#if item.type === 'group'} + + + +
+
{item.name}
+
{item.memberCount} {item.memberCount === 1 ? 'member' : 'members'}
+
+ {:else} + + + +
+
{item.name || item.ename}
+ {#if item.name && item.ename} +
@{item.ename.replace(/^@+/, '')}
+ {/if} +
+ {/if} +
{/each}
From e4456d4b68683a19304951c6ba8cbf484a357a32 Mon Sep 17 00:00:00 2001 From: Merul Dhiman Date: Tue, 6 Jan 2026 01:51:50 +0530 Subject: [PATCH 6/8] fix: refresh automatically on upload --- .../file-manager/src/routes/(protected)/files/+page.svelte | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/platforms/file-manager/src/routes/(protected)/files/+page.svelte b/platforms/file-manager/src/routes/(protected)/files/+page.svelte index ca718e5e3..e12d9aac6 100644 --- a/platforms/file-manager/src/routes/(protected)/files/+page.svelte +++ b/platforms/file-manager/src/routes/(protected)/files/+page.svelte @@ -161,6 +161,11 @@ toast.success('File uploaded successfully'); showUploadModal = false; selectedFile = null; + // Refresh files and folder tree after upload + await Promise.all([ + loadFiles(), + fetchFolderTree() + ]); } catch (error) { console.error('Upload failed:', error); toast.error('Failed to upload file'); From af30f9500617c3d47c61cb31c63eb74db20ef291 Mon Sep 17 00:00:00 2001 From: Merul Dhiman Date: Tue, 6 Jan 2026 01:55:11 +0530 Subject: [PATCH 7/8] chore: add base url to .env --- .env.example | 1 + 1 file changed, 1 insertion(+) diff --git a/.env.example b/.env.example index 048c9d7ec..e75699d1f 100644 --- a/.env.example +++ b/.env.example @@ -64,6 +64,7 @@ PUBLIC_PLAY_STORE_EID_WALLET="" NOTIFICATION_SHARED_SECRET=your-notification-secret-key PUBLIC_ESIGNER_BASE_URL="http://localhost:3004" +PUBLIC_FILE_MANAGER_BASE_URL="http://localhost:3005" DREAMSYNC_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/dreamsync VITE_DREAMSYNC_BASE_URL="http://localhost:8888" From 1796a385203bbc1f6094f689a1e7734c06a113f8 Mon Sep 17 00:00:00 2001 From: Merul Dhiman Date: Tue, 6 Jan 2026 01:57:10 +0530 Subject: [PATCH 8/8] fix: lint --- platforms/esigner/.svelte-kit/ambient.d.ts | 126 +----------- .../.svelte-kit/generated/server/internal.js | 2 +- .../file-manager/.svelte-kit/ambient.d.ts | 182 +----------------- .../.svelte-kit/generated/server/internal.js | 2 +- .../src/routes/(protected)/files/+page.svelte | 24 ++- .../(protected)/files/[id]/+page.svelte | 9 +- 6 files changed, 37 insertions(+), 308 deletions(-) diff --git a/platforms/esigner/.svelte-kit/ambient.d.ts b/platforms/esigner/.svelte-kit/ambient.d.ts index d9bad4f81..08999d2ef 100644 --- a/platforms/esigner/.svelte-kit/ambient.d.ts +++ b/platforms/esigner/.svelte-kit/ambient.d.ts @@ -66,101 +66,42 @@ declare module '$env/static/private' { export const FILE_MANAGER_MAPPING_DB_PATH: string; export const LOAD_TEST_USER_COUNT: string; export const SHELL: string; - export const LSCOLORS: string; export const npm_command: string; - export const GHOSTTY_BIN_DIR: string; export const COLORTERM: string; - export const TERM_PROGRAM_VERSION: string; export const npm_config_optional: string; - export const FNM_ARCH: string; - export const TMUX: string; export const npm_config_npm_globalconfig: string; export const NODE: string; - export const JAVA_HOME: string; export const npm_config_verify_deps_before_run: string; export const npm_config__jsr_registry: string; - export const CLOJURE_HOME: string; - export const MEMORY_PRESSURE_WRITE: string; - export const TMUX_PLUGIN_MANAGER_PATH: string; - export const FNM_NODE_DIST_MIRROR: string; export const npm_config_strict_peer_dependencies: string; - export const DESKTOP_SESSION: string; - export const ELECTRON_OZONE_PLATFORM_HINT: string; - export const XCURSOR_SIZE: string; export const npm_config_globalconfig: string; - export const EDITOR: string; - export const XDG_SEAT: string; export const PWD: string; - export const LOGNAME: string; - export const XDG_SESSION_DESKTOP: string; - export const QT_QPA_PLATFORMTHEME: string; - export const XDG_SESSION_TYPE: string; - export const SYSTEMD_EXEC_PID: string; - export const TERMINAL: string; - export const QT_QPA_PLATFORMTHEME_QT6: string; - export const MOTD_SHOWN: string; - export const GDM_LANG: string; - export const GHOSTTY_SHELL_FEATURES: string; export const HOME: string; - export const USERNAME: string; export const LANG: string; - export const FNM_COREPACK_ENABLED: string; - export const LS_COLORS: string; - export const XDG_CURRENT_DESKTOP: string; export const npm_package_version: string; - export const MESA_GLSL_CACHE_MAX_SIZE: string; - export const MEMORY_PRESSURE_WATCH: string; - export const STARSHIP_SHELL: string; - export const WAYLAND_DISPLAY: string; + export const TURBO_IS_TUI: string; export const pnpm_config_verify_deps_before_run: string; - export const NIRI_SOCKET: string; - export const MANAGERPID: string; export const INIT_CWD: string; - export const STARSHIP_SESSION_KEY: string; - export const QT_QPA_PLATFORM: string; export const npm_lifecycle_script: string; - export const GHOSTTY_RESOURCES_DIR: string; - export const XDG_SESSION_CLASS: string; - export const ANDROID_HOME: string; + export const TURBO_HASH: string; export const TERM: string; - export const TERMINFO: string; export const npm_package_name: string; - export const ZSH: string; export const USER: string; export const npm_config_frozen_lockfile: string; - export const NDK_HOME: string; - export const TMUX_PANE: string; export const DISPLAY: string; export const npm_lifecycle_event: string; export const SHLVL: string; - export const PAGER: string; - export const npm_config_manage_package_manager_versions: string; - export const FNM_VERSION_FILE_STRATEGY: string; - export const XDG_VTNR: string; - export const XDG_SESSION_ID: string; - export const MANAGERPIDFDID: string; export const npm_config_user_agent: string; export const PNPM_SCRIPT_SRC_DIR: string; export const npm_execpath: string; export const XDG_RUNTIME_DIR: string; - export const FNM_RESOLVE_ENGINES: string; - export const mesa_glthread: string; export const NODE_PATH: string; - export const DEBUGINFOD_URLS: string; export const npm_package_json: string; - export const XCURSOR_THEME: string; export const PATH: string; export const npm_config_node_gyp: string; - export const GDMSESSION: string; export const DBUS_SESSION_BUS_ADDRESS: string; - export const MAIL: string; export const npm_config_registry: string; - export const MESA_SHADER_CACHE_DIR: string; - export const FNM_DIR: string; - export const FNM_MULTISHELL_PATH: string; export const npm_node_execpath: string; - export const FNM_LOGLEVEL: string; - export const OLDPWD: string; export const TERM_PROGRAM: string; export const NODE_ENV: string; } @@ -247,101 +188,42 @@ declare module '$env/dynamic/private' { FILE_MANAGER_MAPPING_DB_PATH: string; LOAD_TEST_USER_COUNT: string; SHELL: string; - LSCOLORS: string; npm_command: string; - GHOSTTY_BIN_DIR: string; COLORTERM: string; - TERM_PROGRAM_VERSION: string; npm_config_optional: string; - FNM_ARCH: string; - TMUX: string; npm_config_npm_globalconfig: string; NODE: string; - JAVA_HOME: string; npm_config_verify_deps_before_run: string; npm_config__jsr_registry: string; - CLOJURE_HOME: string; - MEMORY_PRESSURE_WRITE: string; - TMUX_PLUGIN_MANAGER_PATH: string; - FNM_NODE_DIST_MIRROR: string; npm_config_strict_peer_dependencies: string; - DESKTOP_SESSION: string; - ELECTRON_OZONE_PLATFORM_HINT: string; - XCURSOR_SIZE: string; npm_config_globalconfig: string; - EDITOR: string; - XDG_SEAT: string; PWD: string; - LOGNAME: string; - XDG_SESSION_DESKTOP: string; - QT_QPA_PLATFORMTHEME: string; - XDG_SESSION_TYPE: string; - SYSTEMD_EXEC_PID: string; - TERMINAL: string; - QT_QPA_PLATFORMTHEME_QT6: string; - MOTD_SHOWN: string; - GDM_LANG: string; - GHOSTTY_SHELL_FEATURES: string; HOME: string; - USERNAME: string; LANG: string; - FNM_COREPACK_ENABLED: string; - LS_COLORS: string; - XDG_CURRENT_DESKTOP: string; npm_package_version: string; - MESA_GLSL_CACHE_MAX_SIZE: string; - MEMORY_PRESSURE_WATCH: string; - STARSHIP_SHELL: string; - WAYLAND_DISPLAY: string; + TURBO_IS_TUI: string; pnpm_config_verify_deps_before_run: string; - NIRI_SOCKET: string; - MANAGERPID: string; INIT_CWD: string; - STARSHIP_SESSION_KEY: string; - QT_QPA_PLATFORM: string; npm_lifecycle_script: string; - GHOSTTY_RESOURCES_DIR: string; - XDG_SESSION_CLASS: string; - ANDROID_HOME: string; + TURBO_HASH: string; TERM: string; - TERMINFO: string; npm_package_name: string; - ZSH: string; USER: string; npm_config_frozen_lockfile: string; - NDK_HOME: string; - TMUX_PANE: string; DISPLAY: string; npm_lifecycle_event: string; SHLVL: string; - PAGER: string; - npm_config_manage_package_manager_versions: string; - FNM_VERSION_FILE_STRATEGY: string; - XDG_VTNR: string; - XDG_SESSION_ID: string; - MANAGERPIDFDID: string; npm_config_user_agent: string; PNPM_SCRIPT_SRC_DIR: string; npm_execpath: string; XDG_RUNTIME_DIR: string; - FNM_RESOLVE_ENGINES: string; - mesa_glthread: string; NODE_PATH: string; - DEBUGINFOD_URLS: string; npm_package_json: string; - XCURSOR_THEME: string; PATH: string; npm_config_node_gyp: string; - GDMSESSION: string; DBUS_SESSION_BUS_ADDRESS: string; - MAIL: string; npm_config_registry: string; - MESA_SHADER_CACHE_DIR: string; - FNM_DIR: string; - FNM_MULTISHELL_PATH: string; npm_node_execpath: string; - FNM_LOGLEVEL: string; - OLDPWD: string; TERM_PROGRAM: string; NODE_ENV: string; [key: `PUBLIC_${string}`]: undefined; diff --git a/platforms/esigner/.svelte-kit/generated/server/internal.js b/platforms/esigner/.svelte-kit/generated/server/internal.js index 7d73d9259..360a59214 100644 --- a/platforms/esigner/.svelte-kit/generated/server/internal.js +++ b/platforms/esigner/.svelte-kit/generated/server/internal.js @@ -24,7 +24,7 @@ export const options = { app: ({ head, body, assets, nonce, env }) => "\n\n\t\n\t\t\n\t\t\n\t\t" + head + "\n\t\n\t\n\t\t
" + body + "
\n\t\n\n\n", error: ({ status, message }) => "\n\n\t\n\t\t\n\t\t" + message + "\n\n\t\t\n\t\n\t\n\t\t
\n\t\t\t" + status + "\n\t\t\t
\n\t\t\t\t

" + message + "

\n\t\t\t
\n\t\t
\n\t\n\n" }, - version_hash: "hch0zd" + version_hash: "jgao1c" }; export async function get_hooks() { diff --git a/platforms/file-manager/.svelte-kit/ambient.d.ts b/platforms/file-manager/.svelte-kit/ambient.d.ts index 6c55d9339..08999d2ef 100644 --- a/platforms/file-manager/.svelte-kit/ambient.d.ts +++ b/platforms/file-manager/.svelte-kit/ambient.d.ts @@ -66,126 +66,43 @@ declare module '$env/static/private' { export const FILE_MANAGER_MAPPING_DB_PATH: string; export const LOAD_TEST_USER_COUNT: string; export const SHELL: string; - export const LSCOLORS: string; export const npm_command: string; - export const _ZO_DOCTOR: string; + export const COLORTERM: string; export const npm_config_optional: string; - export const FNM_ARCH: string; - export const COMPOSER_NO_INTERACTION: string; export const npm_config_npm_globalconfig: string; - export const LANGUAGE: string; export const NODE: string; - export const LC_ADDRESS: string; - export const JAVA_HOME: string; - export const VSCODE_PROCESS_TITLE: string; - export const QT_LOGGING_RULES: string; - export const LC_NAME: string; export const npm_config_verify_deps_before_run: string; export const npm_config__jsr_registry: string; - export const CLOJURE_HOME: string; - export const MEMORY_PRESSURE_WRITE: string; - export const FNM_NODE_DIST_MIRROR: string; export const npm_config_strict_peer_dependencies: string; - export const DESKTOP_SESSION: string; - export const LC_MONETARY: string; - export const ELECTRON_OZONE_PLATFORM_HINT: string; - export const NO_AT_BRIDGE: string; export const npm_config_globalconfig: string; - export const EDITOR: string; - export const XDG_SEAT: string; export const PWD: string; - export const LOGNAME: string; - export const XDG_SESSION_DESKTOP: string; - export const QT_QPA_PLATFORMTHEME: string; - export const XDG_SESSION_TYPE: string; - export const VSCODE_ESM_ENTRYPOINT: string; - export const SYSTEMD_EXEC_PID: string; - export const VSCODE_CODE_CACHE_PATH: string; - export const TERMINAL: string; - export const QT_QPA_PLATFORMTHEME_QT6: string; - export const DMS_SOCKET: string; - export const MOTD_SHOWN: string; - export const GDM_LANG: string; export const HOME: string; - export const USERNAME: string; export const LANG: string; - export const LC_PAPER: string; - export const FNM_COREPACK_ENABLED: string; - export const LS_COLORS: string; - export const XDG_CURRENT_DESKTOP: string; export const npm_package_version: string; - export const MESA_GLSL_CACHE_MAX_SIZE: string; - export const MEMORY_PRESSURE_WATCH: string; - export const VSCODE_IPC_HOOK: string; - export const STARSHIP_SHELL: string; - export const WAYLAND_DISPLAY: string; - export const FORCE_COLOR: string; - export const VSCODE_CLI: string; - export const INVOCATION_ID: string; + export const TURBO_IS_TUI: string; export const pnpm_config_verify_deps_before_run: string; - export const NIRI_SOCKET: string; - export const MANAGERPID: string; - export const DMS_DISABLE_HOT_RELOAD: string; export const INIT_CWD: string; - export const CHROME_DESKTOP: string; - export const STARSHIP_SESSION_KEY: string; - export const QT_QPA_PLATFORM: string; export const npm_lifecycle_script: string; - export const CURSOR_AGENT: string; - export const XDG_SESSION_CLASS: string; - export const ANDROID_HOME: string; + export const TURBO_HASH: string; export const TERM: string; - export const LC_IDENTIFICATION: string; export const npm_package_name: string; - export const ZSH: string; export const USER: string; export const npm_config_frozen_lockfile: string; - export const NDK_HOME: string; - export const EVERYSPHERE_RIPGREP_PATH: string; export const DISPLAY: string; export const npm_lifecycle_event: string; - export const VSCODE_PID: string; export const SHLVL: string; - export const PAGER: string; - export const LC_TELEPHONE: string; - export const npm_config_manage_package_manager_versions: string; - export const LC_MEASUREMENT: string; - export const VSCODE_CWD: string; - export const FNM_VERSION_FILE_STRATEGY: string; - export const XDG_VTNR: string; - export const XDG_SESSION_ID: string; - export const MANAGERPIDFDID: string; export const npm_config_user_agent: string; - export const NO_COLOR: string; export const PNPM_SCRIPT_SRC_DIR: string; export const npm_execpath: string; - export const VSCODE_CRASH_REPORTER_PROCESS_TYPE: string; export const XDG_RUNTIME_DIR: string; - export const FNM_RESOLVE_ENGINES: string; - export const mesa_glthread: string; - export const DMS_DEFAULT_LAUNCH_PREFIX: string; export const NODE_PATH: string; - export const DEBUGINFOD_URLS: string; export const npm_package_json: string; - export const ELECTRON_NO_ATTACH_CONSOLE: string; - export const JOURNAL_STREAM: string; - export const GDK_BACKEND: string; export const PATH: string; export const npm_config_node_gyp: string; - export const GDMSESSION: string; - export const ORIGINAL_XDG_CURRENT_DESKTOP: string; export const DBUS_SESSION_BUS_ADDRESS: string; - export const VSCODE_NLS_CONFIG: string; - export const MAIL: string; export const npm_config_registry: string; - export const MESA_SHADER_CACHE_DIR: string; - export const FNM_DIR: string; - export const FNM_MULTISHELL_PATH: string; export const npm_node_execpath: string; - export const VSCODE_HANDLES_UNCAUGHT_ERRORS: string; - export const FNM_LOGLEVEL: string; - export const OLDPWD: string; - export const CURSOR_TRACE_ID: string; + export const TERM_PROGRAM: string; export const NODE_ENV: string; } @@ -271,126 +188,43 @@ declare module '$env/dynamic/private' { FILE_MANAGER_MAPPING_DB_PATH: string; LOAD_TEST_USER_COUNT: string; SHELL: string; - LSCOLORS: string; npm_command: string; - _ZO_DOCTOR: string; + COLORTERM: string; npm_config_optional: string; - FNM_ARCH: string; - COMPOSER_NO_INTERACTION: string; npm_config_npm_globalconfig: string; - LANGUAGE: string; NODE: string; - LC_ADDRESS: string; - JAVA_HOME: string; - VSCODE_PROCESS_TITLE: string; - QT_LOGGING_RULES: string; - LC_NAME: string; npm_config_verify_deps_before_run: string; npm_config__jsr_registry: string; - CLOJURE_HOME: string; - MEMORY_PRESSURE_WRITE: string; - FNM_NODE_DIST_MIRROR: string; npm_config_strict_peer_dependencies: string; - DESKTOP_SESSION: string; - LC_MONETARY: string; - ELECTRON_OZONE_PLATFORM_HINT: string; - NO_AT_BRIDGE: string; npm_config_globalconfig: string; - EDITOR: string; - XDG_SEAT: string; PWD: string; - LOGNAME: string; - XDG_SESSION_DESKTOP: string; - QT_QPA_PLATFORMTHEME: string; - XDG_SESSION_TYPE: string; - VSCODE_ESM_ENTRYPOINT: string; - SYSTEMD_EXEC_PID: string; - VSCODE_CODE_CACHE_PATH: string; - TERMINAL: string; - QT_QPA_PLATFORMTHEME_QT6: string; - DMS_SOCKET: string; - MOTD_SHOWN: string; - GDM_LANG: string; HOME: string; - USERNAME: string; LANG: string; - LC_PAPER: string; - FNM_COREPACK_ENABLED: string; - LS_COLORS: string; - XDG_CURRENT_DESKTOP: string; npm_package_version: string; - MESA_GLSL_CACHE_MAX_SIZE: string; - MEMORY_PRESSURE_WATCH: string; - VSCODE_IPC_HOOK: string; - STARSHIP_SHELL: string; - WAYLAND_DISPLAY: string; - FORCE_COLOR: string; - VSCODE_CLI: string; - INVOCATION_ID: string; + TURBO_IS_TUI: string; pnpm_config_verify_deps_before_run: string; - NIRI_SOCKET: string; - MANAGERPID: string; - DMS_DISABLE_HOT_RELOAD: string; INIT_CWD: string; - CHROME_DESKTOP: string; - STARSHIP_SESSION_KEY: string; - QT_QPA_PLATFORM: string; npm_lifecycle_script: string; - CURSOR_AGENT: string; - XDG_SESSION_CLASS: string; - ANDROID_HOME: string; + TURBO_HASH: string; TERM: string; - LC_IDENTIFICATION: string; npm_package_name: string; - ZSH: string; USER: string; npm_config_frozen_lockfile: string; - NDK_HOME: string; - EVERYSPHERE_RIPGREP_PATH: string; DISPLAY: string; npm_lifecycle_event: string; - VSCODE_PID: string; SHLVL: string; - PAGER: string; - LC_TELEPHONE: string; - npm_config_manage_package_manager_versions: string; - LC_MEASUREMENT: string; - VSCODE_CWD: string; - FNM_VERSION_FILE_STRATEGY: string; - XDG_VTNR: string; - XDG_SESSION_ID: string; - MANAGERPIDFDID: string; npm_config_user_agent: string; - NO_COLOR: string; PNPM_SCRIPT_SRC_DIR: string; npm_execpath: string; - VSCODE_CRASH_REPORTER_PROCESS_TYPE: string; XDG_RUNTIME_DIR: string; - FNM_RESOLVE_ENGINES: string; - mesa_glthread: string; - DMS_DEFAULT_LAUNCH_PREFIX: string; NODE_PATH: string; - DEBUGINFOD_URLS: string; npm_package_json: string; - ELECTRON_NO_ATTACH_CONSOLE: string; - JOURNAL_STREAM: string; - GDK_BACKEND: string; PATH: string; npm_config_node_gyp: string; - GDMSESSION: string; - ORIGINAL_XDG_CURRENT_DESKTOP: string; DBUS_SESSION_BUS_ADDRESS: string; - VSCODE_NLS_CONFIG: string; - MAIL: string; npm_config_registry: string; - MESA_SHADER_CACHE_DIR: string; - FNM_DIR: string; - FNM_MULTISHELL_PATH: string; npm_node_execpath: string; - VSCODE_HANDLES_UNCAUGHT_ERRORS: string; - FNM_LOGLEVEL: string; - OLDPWD: string; - CURSOR_TRACE_ID: string; + TERM_PROGRAM: string; NODE_ENV: string; [key: `PUBLIC_${string}`]: undefined; [key: `${string}`]: string | undefined; diff --git a/platforms/file-manager/.svelte-kit/generated/server/internal.js b/platforms/file-manager/.svelte-kit/generated/server/internal.js index c3e317107..ddd9f4286 100644 --- a/platforms/file-manager/.svelte-kit/generated/server/internal.js +++ b/platforms/file-manager/.svelte-kit/generated/server/internal.js @@ -24,7 +24,7 @@ export const options = { app: ({ head, body, assets, nonce, env }) => "\n\n\t\n\t\t\n\t\t\n\t\t" + head + "\n\t\n\t\n\t\t
" + body + "
\n\t\n\n\n", error: ({ status, message }) => "\n\n\t\n\t\t\n\t\t" + message + "\n\n\t\t\n\t\n\t\n\t\t
\n\t\t\t" + status + "\n\t\t\t
\n\t\t\t\t

" + message + "

\n\t\t\t
\n\t\t
\n\t\n\n" }, - version_hash: "1w1e91v" + version_hash: "1n2nyvr" }; export async function get_hooks() { diff --git a/platforms/file-manager/src/routes/(protected)/files/+page.svelte b/platforms/file-manager/src/routes/(protected)/files/+page.svelte index e12d9aac6..8b7b9d779 100644 --- a/platforms/file-manager/src/routes/(protected)/files/+page.svelte +++ b/platforms/file-manager/src/routes/(protected)/files/+page.svelte @@ -112,16 +112,19 @@ foldersList = f; }); - onMount(async () => { + onMount(() => { isAuthenticated.subscribe((auth) => { if (!auth) { goto('/auth'); } }); - await fetchFolderTree(); - await loadFiles(); - await updateBreadcrumbs(); + // Load data asynchronously + (async () => { + await fetchFolderTree(); + await loadFiles(); + await updateBreadcrumbs(); + })(); // Close dropdown when clicking outside function handleClickOutside(event: MouseEvent) { @@ -207,20 +210,27 @@ async function handleDelete() { if (!itemToDelete) return; + const itemType = itemToDelete.type; + const itemName = itemToDelete.name; + try { - if (itemToDelete.type === 'file') { + isLoading = true; + if (itemType === 'file') { await deleteFile(itemToDelete.id); toast.success('File deleted successfully'); } else { await deleteFolder(itemToDelete.id); toast.success('Folder deleted successfully'); - await loadFiles(); } showDeleteModal = false; itemToDelete = null; + await loadFiles(); + await fetchFolderTree(); } catch (error) { console.error('Failed to delete:', error); - toast.error(`Failed to delete ${itemToDelete.type}`); + toast.error(`Failed to delete ${itemType === 'file' ? 'file' : 'folder'}`); + } finally { + isLoading = false; } } diff --git a/platforms/file-manager/src/routes/(protected)/files/[id]/+page.svelte b/platforms/file-manager/src/routes/(protected)/files/[id]/+page.svelte index 84c339d67..79b1ec5ed 100644 --- a/platforms/file-manager/src/routes/(protected)/files/[id]/+page.svelte +++ b/platforms/file-manager/src/routes/(protected)/files/[id]/+page.svelte @@ -232,17 +232,20 @@ // Check if tag already exists const existingTag = $tags.find(t => t.name.toLowerCase() === tagName.toLowerCase()); + let tagId: string; if (existingTag) { // Use existing tag - selectedTag = existingTag.id; + tagId = existingTag.id; + selectedTag = tagId; } else { // Create new tag const newTag = await createTag(tagName, null); - selectedTag = newTag.id; + tagId = newTag.id; + selectedTag = tagId; } // Add tag to file - await addTagToFile(file.id, selectedTag); + await addTagToFile(file.id, tagId); toast.success('Tag added successfully'); showTagModal = false; selectedTag = null;
@@ -434,11 +710,12 @@
{formatDate(item.updatedAt || item.createdAt)} -
+
+