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" 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/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/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/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..d62ee6437 --- /dev/null +++ b/platforms/esigner-api/src/web3adapter/mappings/signature.mapping.json @@ -0,0 +1,16 @@ +{ + "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)" + } +} \ 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 ce9dbe269..dd86beab5 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({ @@ -185,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}`; @@ -243,6 +321,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 +339,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 b8199036d..08999d2ef 100644 --- a/platforms/esigner/.svelte-kit/ambient.d.ts +++ b/platforms/esigner/.svelte-kit/ambient.d.ts @@ -62,6 +62,8 @@ 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 npm_command: string; @@ -129,6 +131,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,6 +184,8 @@ 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; npm_command: string; @@ -253,6 +258,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/esigner/.svelte-kit/generated/server/internal.js b/platforms/esigner/.svelte-kit/generated/server/internal.js index eeb5af337..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: "r5aqr4" + version_hash: "jgao1c" }; export async function get_hooks() { 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..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 = ''; } } @@ -280,14 +278,15 @@

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} +
+ {/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..f3b059125 --- /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.replace(/^@+/, '')}
+ {/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..7703c669e --- /dev/null +++ b/platforms/file-manager/src/lib/stores/files.ts @@ -0,0 +1,99 @@ +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 => { + 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 new file mode 100644 index 000000000..8c43cd79e --- /dev/null +++ b/platforms/file-manager/src/lib/stores/folders.ts @@ -0,0 +1,92 @@ +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 => { + 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[] }> => { + const response = await apiClient.get(`/api/folders/${folderId}/contents`); + return response.data; +}; + diff --git a/platforms/file-manager/src/lib/stores/signatures.ts b/platforms/file-manager/src/lib/stores/signatures.ts new file mode 100644 index 000000000..fd4f9c5d7 --- /dev/null +++ b/platforms/file-manager/src/lib/stores/signatures.ts @@ -0,0 +1,38 @@ +import { writable } from 'svelte/store'; +import { apiClient } from '$lib/utils/axios'; +import type { Writable } from 'svelte/store'; + +export interface Signature { + id: string; + userId: string; + md5Hash: string; + message: string; + signature: string; + publicKey: string; + createdAt: string; + user?: { + id: string; + name: string; + ename: string; + avatarUrl?: string; + } | null; +} + +export const signatures: Writable = writable([]); +export const isLoading = writable(false); +export const error = writable(null); + +export const fetchFileSignatures = async (fileId: string) => { + try { + isLoading.set(true); + error.set(null); + const response = await apiClient.get(`/api/files/${fileId}/signatures`); + signatures.set(response.data); + } catch (err) { + error.set(err instanceof Error ? err.message : 'Failed to fetch signatures'); + throw err; + } finally { + isLoading.set(false); + } +}; + 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..8b7b9d779 --- /dev/null +++ b/platforms/file-manager/src/routes/(protected)/files/+page.svelte @@ -0,0 +1,1193 @@ + + +
+ +
+ +
+ {#if currentView === 'my-files'} + + + {/if} +
+
+ + + {#if breadcrumbs.length > 1 || (breadcrumbs.length === 1 && breadcrumbs[0].id !== null)} +
+ +
+ {/if} + +
+ {#if isLoading} +
+
+

Loading...

+
+ {:else if allItems.length === 0} +
+ {#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 { + handlePreviewFile(item, e); + } + }} + > + + + + + + {/each} + +
+ Name + + Size + + Modified + + Actions +
+
+ + {item.type === 'folder' ? '📁' : getFileIcon(item.type === 'file' ? item.mimeType : '')} + +
+
+
+ {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}
+ {/if} +
+
+
+ {item.type === 'folder' ? '—' : formatFileSize(item.type === 'file' ? item.size : 0)} + + {formatDate(item.updatedAt || item.createdAt)} + + +
+
+ {/if} +
+
+ + +{#if showUploadModal} +
+
+

Upload File

+ + +
+ {#if selectedFile} +
+ + + +

{selectedFile.name}

+

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

+
+ + {:else} + + + +

Drag and drop your file here

+

or

+ + {/if} +
+ +
+ + +
+
+
+{/if} + + +{#if showFolderModal} +
+
+

Create Folder

+ e.key === 'Enter' && handleCreateFolder()} + /> +
+ + +
+
+
+{/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 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} +
+
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..79b1ec5ed --- /dev/null +++ b/platforms/file-manager/src/routes/(protected)/files/[id]/+page.svelte @@ -0,0 +1,658 @@ + + +
+ +
+ +
+ + {#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} +
+
+ + + {#if file.ownerId === $currentUser?.id} +
+
+

Tags

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

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

+
+
+ +
+ {/each} +
+ {:else} +

Not shared with anyone

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

Share File

+ + {#if searchResults.length > 0} +
+ {#each searchResults as item} +
{ + if (selectedUsers.find(u => u.id === item.id)) { + selectedUsers = selectedUsers.filter(u => u.id !== item.id); + } else { + selectedUsers = [...selectedUsers, 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 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': 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 +} +