diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..80b6102 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# MinIO Configuration +NUXT_MINIO_ACCESS_KEY=your-minio-access-key +NUXT_MINIO_SECRET_KEY=your-minio-secret-key + +# NocoDB Configuration (existing) +NUXT_NOCODB_URL=your-nocodb-url +NUXT_NOCODB_TOKEN=your-nocodb-token diff --git a/components/FilePreviewModal.vue b/components/FilePreviewModal.vue new file mode 100644 index 0000000..4e5220d --- /dev/null +++ b/components/FilePreviewModal.vue @@ -0,0 +1,220 @@ + + + + + + {{ file.icon }} + {{ file.displayName }} + + + mdi-close + + + + + + + + Loading preview... + + + + + mdi-alert-circle + {{ error }} + + + + + + + + + + + + + + + + + Download + + + Close + + + + + + + + + diff --git a/components/FileUploader.vue b/components/FileUploader.vue new file mode 100644 index 0000000..15279da --- /dev/null +++ b/components/FileUploader.vue @@ -0,0 +1,208 @@ + + + + + + mdi-cloud-upload-outline + + Drag and drop files here + or + + Browse Files + + + + Maximum file size: 50MB + + + + + + Selected Files ({{ selectedFiles.length }}) + + + {{ getFileIcon(file.name) }} + + + + mdi-close + + + + + + + + + + + + Clear All + + Upload {{ selectedFiles.length }} File{{ selectedFiles.length > 1 ? 's' : '' }} + + + + + + + + diff --git a/docs/minio-file-browser.md b/docs/minio-file-browser.md new file mode 100644 index 0000000..7cab9cd --- /dev/null +++ b/docs/minio-file-browser.md @@ -0,0 +1,132 @@ +# MinIO File Browser Integration + +This document describes the MinIO S3-compatible file browser integration in the Port Nimara Client Portal. + +## Features + +- **File Management**: Upload, download, delete, and view files +- **Folder Organization**: Create and navigate folder structures +- **File Preview**: View images and PDFs directly in the browser +- **Search**: Quick file search functionality +- **Audit Logging**: Track all file operations with user information +- **Drag & Drop**: Easy file uploads with drag and drop support +- **Size Limit**: 50MB maximum file size per upload + +## Configuration + +### Environment Variables + +Add the following to your `.env` file: + +```bash +NUXT_MINIO_ACCESS_KEY=your-minio-access-key +NUXT_MINIO_SECRET_KEY=your-minio-secret-key +``` + +### MinIO Settings + +The MinIO configuration is set in `nuxt.config.ts`: + +```javascript +minio: { + endPoint: "s3.portnimara.com", + port: 9000, + useSSL: true, + bucketName: "client-portal", +} +``` + +## File Structure + +``` +server/ +├── api/ +│ └── files/ +│ ├── list.ts # List files and folders +│ ├── upload.ts # Upload files +│ ├── download.ts # Generate download URLs +│ ├── delete.ts # Delete files/folders +│ ├── create-folder.ts # Create new folders +│ └── preview.ts # Generate preview URLs +├── utils/ +│ └── minio.ts # MinIO client utilities + +pages/ +└── dashboard/ + └── file-browser.vue # Main file browser page + +components/ +├── FileUploader.vue # File upload component +└── FilePreviewModal.vue # File preview modal +``` + +## API Endpoints + +### List Files +- **GET** `/api/files/list` +- Query params: `prefix` (folder path), `recursive` (boolean) + +### Upload Files +- **POST** `/api/files/upload` +- Body: FormData with files +- Query params: `path` (current folder) + +### Download File +- **GET** `/api/files/download` +- Query params: `fileName` + +### Delete File/Folder +- **POST** `/api/files/delete` +- Body: `{ fileName, isFolder }` + +### Create Folder +- **POST** `/api/files/create-folder` +- Body: `{ folderPath }` + +### Preview File +- **GET** `/api/files/preview` +- Query params: `fileName` + +## Usage + +1. Navigate to "File Browser" from the dashboard menu +2. Use the interface to: + - Upload files by dragging or clicking "Upload Files" + - Create folders with "New Folder" button + - Navigate folders by clicking on them + - Preview images and PDFs by clicking the eye icon + - Download files with the download icon + - Delete files/folders with the delete icon + +## Audit Logging + +All file operations are logged with: +- User email +- Action type (upload, download, delete, create_folder) +- File path +- Timestamp +- IP address +- Success status + +Currently logs to console, but can be easily integrated with your database. + +## Security + +- All operations require authentication +- File names are sanitized to prevent security issues +- Presigned URLs expire after 1 hour +- 50MB file size limit enforced + +## Supported File Types + +All file types are supported for upload/download. Preview is available for: +- Images: JPG, JPEG, PNG, GIF, SVG, WebP +- Documents: PDF + +## Future Enhancements + +- Database storage for audit logs +- File sharing with expiration +- Bulk operations +- File versioning +- Integration with Interest management (link files to specific interests) diff --git a/nuxt.config.ts b/nuxt.config.ts index f9cc841..5631081 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -22,6 +22,14 @@ export default defineNuxtConfig({ url: "", token: "", }, + minio: { + endPoint: "s3.portnimara.com", + port: 9000, + useSSL: true, + accessKey: "279QFJV96Ja9wNB0YYmU1W3Pv4Ofeh3pxojcz0pzeC5LjRurq", + secretKey: "y8ze6nmA2VHJWDsIU1eNEBq4R4WlmJWp97UE0zUR7E4zWLS6O", + bucketName: "client-portal", + }, public: { directus: { url: "https://cms.portnimara.dev", diff --git a/package-lock.json b/package-lock.json index bd77250..c41503c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "hasInstallScript": true, "dependencies": { "@vite-pwa/nuxt": "^0.10.6", + "formidable": "^3.5.4", + "mime-types": "^3.0.1", + "minio": "^8.0.5", "nuxt": "^3.15.4", "nuxt-directus": "^5.7.0", "v-phone-input": "^4.4.2", @@ -2366,6 +2369,18 @@ "node": ">=18.0.0" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2758,6 +2773,15 @@ "vue": "^3.3.4" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", + "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@parcel/watcher": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", @@ -4111,6 +4135,13 @@ "vuetify": "^3.0.0" } }, + "node_modules/@zxing/text-encoding": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", + "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", + "license": "(Unlicense OR Apache-2.0)", + "optional": true + }, "node_modules/abbrev": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.0.tgz", @@ -4407,6 +4438,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, "node_modules/ast-kit": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-1.4.0.tgz", @@ -4646,6 +4683,29 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/block-stream2": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/block-stream2/-/block-stream2-2.1.0.tgz", + "integrity": "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==", + "license": "MIT", + "dependencies": { + "readable-stream": "^3.4.0" + } + }, + "node_modules/block-stream2/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -4674,6 +4734,12 @@ "node": ">=8" } }, + "node_modules/browser-or-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/browser-or-node/-/browser-or-node-2.1.1.tgz", + "integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==", + "license": "MIT" + }, "node_modules/browserslist": { "version": "4.24.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", @@ -5593,6 +5659,15 @@ } } }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -5731,6 +5806,16 @@ "integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==", "license": "MIT" }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", @@ -6166,6 +6251,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -6275,6 +6366,24 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-parser": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", + "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.1.1" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", @@ -6346,6 +6455,15 @@ "node": ">=8" } }, + "node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/flag-icons": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz", @@ -6402,6 +6520,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -7607,6 +7742,15 @@ "url": "https://opencollective.com/ioredis" } }, + "node_modules/ipaddr.js": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/iron-webcrypto": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", @@ -7616,6 +7760,22 @@ "url": "https://github.com/sponsors/brc-dd" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -8613,6 +8773,27 @@ "node": ">=16" } }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", @@ -8637,6 +8818,52 @@ "node": "*" } }, + "node_modules/minio": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/minio/-/minio-8.0.5.tgz", + "integrity": "sha512-/vAze1uyrK2R/DSkVutE4cjVoAowvIQ18RAwn7HrqnLecLlMazFnY0oNBqfuoAWvu7mZIGX75AzpuV05TJeoHg==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.4", + "block-stream2": "^2.1.0", + "browser-or-node": "^2.1.1", + "buffer-crc32": "^1.0.0", + "eventemitter3": "^5.0.1", + "fast-xml-parser": "^4.4.1", + "ipaddr.js": "^2.0.1", + "lodash": "^4.17.21", + "mime-types": "^2.1.35", + "query-string": "^7.1.3", + "stream-json": "^1.8.0", + "through2": "^4.0.2", + "web-encoding": "^1.1.5", + "xml2js": "^0.5.0 || ^0.6.2" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, + "node_modules/minio/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minio/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minipass": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", @@ -10179,6 +10406,24 @@ "node": ">=6" } }, + "node_modules/query-string": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", + "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", + "license": "MIT", + "dependencies": { + "decode-uri-component": "^0.2.2", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -10762,6 +11007,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "license": "ISC" + }, "node_modules/scule": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", @@ -11142,6 +11393,15 @@ "node": ">=0.10.0" } }, + "node_modules/split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -11163,6 +11423,21 @@ "integrity": "sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==", "license": "MIT" }, + "node_modules/stream-chain": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", + "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==", + "license": "BSD-3-Clause" + }, + "node_modules/stream-json": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz", + "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==", + "license": "BSD-3-Clause", + "dependencies": { + "stream-chain": "^2.2.5" + } + }, "node_modules/streamx": { "version": "2.22.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz", @@ -11176,6 +11451,15 @@ "bare-events": "^2.2.0" } }, + "node_modules/strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -11375,6 +11659,18 @@ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", "license": "MIT" }, + "node_modules/strnum": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", + "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/stylehacks": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-7.0.4.tgz", @@ -11609,6 +11905,29 @@ "b4a": "^1.6.4" } }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, + "node_modules/through2/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -12823,6 +13142,19 @@ "integrity": "sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==", "license": "MIT" }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -13389,6 +13721,18 @@ "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", "license": "MIT" }, + "node_modules/web-encoding": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/web-encoding/-/web-encoding-1.1.5.tgz", + "integrity": "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==", + "license": "MIT", + "dependencies": { + "util": "^0.12.3" + }, + "optionalDependencies": { + "@zxing/text-encoding": "0.9.0" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -13952,6 +14296,28 @@ } } }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index b2d9b52..c434444 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ }, "dependencies": { "@vite-pwa/nuxt": "^0.10.6", + "formidable": "^3.5.4", + "mime-types": "^3.0.1", + "minio": "^8.0.5", "nuxt": "^3.15.4", "nuxt-directus": "^5.7.0", "v-phone-input": "^4.4.2", diff --git a/pages/dashboard.vue b/pages/dashboard.vue index 6406b57..e32de64 100644 --- a/pages/dashboard.vue +++ b/pages/dashboard.vue @@ -117,6 +117,11 @@ const defaultMenu = [ icon: "mdi-finance", title: "Data Analytics", }, + { + to: "/dashboard/file-browser", + icon: "mdi-folder", + title: "File Browser", + }, ]; const menu = computed(() => diff --git a/pages/dashboard/file-browser.vue b/pages/dashboard/file-browser.vue new file mode 100644 index 0000000..52cdf73 --- /dev/null +++ b/pages/dashboard/file-browser.vue @@ -0,0 +1,485 @@ + + + + + + + mdi-folder + File Browser + + + Manage your NDA documents and other files + + + + + + + + + + + {{ item.title }} + + + + + + + + + + + + + + New Folder + + + Upload Files + + + + + + + + + + + + {{ item.displayName }} + + {{ item.extension.toUpperCase() }} + + + + + + + {{ item.sizeFormatted }} + + + + {{ formatDate(item.lastModified) }} + + + + + + mdi-eye + Preview + + + mdi-download + Download + + + mdi-delete + Delete + + + + + + + + + + + + + + + mdi-upload + Upload Files + + + + + + + + + + + + mdi-folder-plus + Create New Folder + + + + + + + Cancel + + Create + + + + + + + + + + mdi-alert + Confirm Delete + + + Are you sure you want to delete "{{ fileToDelete?.displayName }}"? + + This will delete all files and folders inside it. + + This action cannot be undone. + + + + Cancel + + Delete + + + + + + + + + + + + + diff --git a/server/api/files/create-folder.ts b/server/api/files/create-folder.ts new file mode 100644 index 0000000..902cf31 --- /dev/null +++ b/server/api/files/create-folder.ts @@ -0,0 +1,59 @@ +import { createFolder } from '~/server/utils/minio'; + +export default defineEventHandler(async (event) => { + try { + const body = await readBody(event); + const { folderPath } = body; + + if (!folderPath) { + throw createError({ + statusCode: 400, + statusMessage: 'Folder path is required', + }); + } + + // Create the folder + await createFolder(folderPath); + + // Log audit event + await logAuditEvent(event, 'create_folder', folderPath); + + return { + success: true, + message: 'Folder created successfully', + folderPath, + }; + } catch (error: any) { + console.error('Failed to create folder:', error); + throw createError({ + statusCode: 500, + statusMessage: error.message || 'Failed to create folder', + }); + } +}); + +// Audit logging helper +async function logAuditEvent(event: any, action: string, filePath: string) { + try { + const user = event.context.user || { email: 'anonymous' }; + const auditLog = { + user_email: user.email, + action, + file_path: filePath, + timestamp: new Date().toISOString(), + ip_address: getClientIP(event), + success: true, + }; + + // You can store this in your database or logging system + console.log('Audit log:', auditLog); + } catch (error) { + console.error('Failed to log audit event:', error); + } +} + +function getClientIP(event: any): string { + return event.node.req.headers['x-forwarded-for'] || + event.node.req.connection.remoteAddress || + 'unknown'; +} diff --git a/server/api/files/delete.ts b/server/api/files/delete.ts new file mode 100644 index 0000000..ce5eeaf --- /dev/null +++ b/server/api/files/delete.ts @@ -0,0 +1,62 @@ +import { deleteFile, deleteFolder } from '~/server/utils/minio'; + +export default defineEventHandler(async (event) => { + try { + const body = await readBody(event); + const { fileName, isFolder } = body; + + if (!fileName) { + throw createError({ + statusCode: 400, + statusMessage: 'File name is required', + }); + } + + // Delete folder or file based on type + if (isFolder) { + await deleteFolder(fileName); + } else { + await deleteFile(fileName); + } + + // Log audit event + await logAuditEvent(event, 'delete', fileName); + + return { + success: true, + message: isFolder ? 'Folder deleted successfully' : 'File deleted successfully', + }; + } catch (error: any) { + console.error('Failed to delete:', error); + throw createError({ + statusCode: 500, + statusMessage: error.message || 'Failed to delete', + }); + } +}); + +// Audit logging helper +async function logAuditEvent(event: any, action: string, filePath: string) { + try { + const user = event.context.user || { email: 'anonymous' }; + const auditLog = { + user_email: user.email, + action, + file_path: filePath, + timestamp: new Date().toISOString(), + ip_address: getClientIP(event), + success: true, + }; + + // You can store this in your database or logging system + console.log('Audit log:', auditLog); + } catch (error) { + console.error('Failed to log audit event:', error); + } +} + +function getClientIP(event: any): string { + return event.node.req.headers['x-forwarded-for'] || + event.node.req.connection.remoteAddress || + 'unknown'; +} diff --git a/server/api/files/download.ts b/server/api/files/download.ts new file mode 100644 index 0000000..ece3896 --- /dev/null +++ b/server/api/files/download.ts @@ -0,0 +1,59 @@ +import { getDownloadUrl } from '~/server/utils/minio'; + +export default defineEventHandler(async (event) => { + try { + const query = getQuery(event); + const fileName = query.fileName as string; + + if (!fileName) { + throw createError({ + statusCode: 400, + statusMessage: 'File name is required', + }); + } + + // Generate presigned URL valid for 1 hour + const url = await getDownloadUrl(fileName); + + // Log audit event + await logAuditEvent(event, 'download', fileName); + + return { + success: true, + url, + fileName, + }; + } catch (error: any) { + console.error('Failed to generate download URL:', error); + throw createError({ + statusCode: 500, + statusMessage: error.message || 'Failed to generate download URL', + }); + } +}); + +// Audit logging helper +async function logAuditEvent(event: any, action: string, filePath: string) { + try { + const user = event.context.user || { email: 'anonymous' }; + const auditLog = { + user_email: user.email, + action, + file_path: filePath, + timestamp: new Date().toISOString(), + ip_address: getClientIP(event), + success: true, + }; + + // You can store this in your database or logging system + console.log('Audit log:', auditLog); + } catch (error) { + console.error('Failed to log audit event:', error); + } +} + +function getClientIP(event: any): string { + return event.node.req.headers['x-forwarded-for'] || + event.node.req.connection.remoteAddress || + 'unknown'; +} diff --git a/server/api/files/list.ts b/server/api/files/list.ts new file mode 100644 index 0000000..33a86f3 --- /dev/null +++ b/server/api/files/list.ts @@ -0,0 +1,86 @@ +import { listFiles } from '~/server/utils/minio'; + +export default defineEventHandler(async (event) => { + try { + const query = getQuery(event); + const prefix = (query.prefix as string) || ''; + const recursive = query.recursive === 'true'; + + const files = await listFiles(prefix, recursive); + + // Format file list with additional metadata + const formattedFiles = (files as any[]).map(file => ({ + ...file, + sizeFormatted: file.isFolder ? '-' : formatFileSize(file.size), + extension: file.isFolder ? 'folder' : getFileExtension(file.name), + icon: file.isFolder ? 'mdi-folder' : getFileIcon(file.name), + displayName: getDisplayName(file.name), + })); + + // Sort folders first, then files + formattedFiles.sort((a, b) => { + if (a.isFolder && !b.isFolder) return -1; + if (!a.isFolder && b.isFolder) return 1; + return a.displayName.localeCompare(b.displayName); + }); + + return { + success: true, + files: formattedFiles, + count: formattedFiles.length, + currentPath: prefix, + }; + } catch (error) { + console.error('Failed to list files:', error); + throw createError({ + statusCode: 500, + statusMessage: 'Failed to list files', + }); + } +}); + +// Helper functions +function formatFileSize(bytes: number): string { + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + if (bytes === 0) return '0 Bytes'; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; +} + +function getFileExtension(filename: string): string { + const parts = filename.split('.'); + return parts.length > 1 ? parts.pop()?.toLowerCase() || '' : ''; +} + +function getFileIcon(filename: string): string { + const ext = getFileExtension(filename); + const iconMap: Record = { + pdf: 'mdi-file-pdf-box', + doc: 'mdi-file-document', + docx: 'mdi-file-document', + xls: 'mdi-file-excel', + xlsx: 'mdi-file-excel', + jpg: 'mdi-file-image', + jpeg: 'mdi-file-image', + png: 'mdi-file-image', + gif: 'mdi-file-image', + svg: 'mdi-file-image', + zip: 'mdi-folder-zip', + rar: 'mdi-folder-zip', + txt: 'mdi-file-document-outline', + csv: 'mdi-file-delimited', + mp4: 'mdi-file-video', + mp3: 'mdi-file-music', + }; + return iconMap[ext] || 'mdi-file'; +} + +function getDisplayName(filepath: string): string { + // Get just the filename from the full path + const parts = filepath.split('/'); + const filename = parts[parts.length - 1]; + + // Remove timestamp prefix if present (e.g., "1234567890-filename.pdf" -> "filename.pdf") + const match = filename.match(/^\d{10,}-(.+)$/); + return match ? match[1] : filename; +} diff --git a/server/api/files/preview.ts b/server/api/files/preview.ts new file mode 100644 index 0000000..4d7f68c --- /dev/null +++ b/server/api/files/preview.ts @@ -0,0 +1,53 @@ +import { getPreviewUrl } from '~/server/utils/minio'; +import mime from 'mime-types'; + +export default defineEventHandler(async (event) => { + try { + const query = getQuery(event); + const fileName = query.fileName as string; + + if (!fileName) { + throw createError({ + statusCode: 400, + statusMessage: 'File name is required', + }); + } + + // Get content type + const contentType = mime.lookup(fileName) || 'application/octet-stream'; + + // Check if file type supports preview + const supportedPreviewTypes = [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/gif', + 'image/svg+xml', + 'image/webp', + 'application/pdf', + ]; + + if (!supportedPreviewTypes.includes(contentType)) { + throw createError({ + statusCode: 400, + statusMessage: 'File type does not support preview', + }); + } + + // Generate presigned URL for preview + const url = await getPreviewUrl(fileName, contentType); + + return { + success: true, + url, + fileName, + contentType, + }; + } catch (error: any) { + console.error('Failed to generate preview URL:', error); + throw createError({ + statusCode: 500, + statusMessage: error.message || 'Failed to generate preview URL', + }); + } +}); diff --git a/server/api/files/upload.ts b/server/api/files/upload.ts new file mode 100644 index 0000000..832995f --- /dev/null +++ b/server/api/files/upload.ts @@ -0,0 +1,97 @@ +import { uploadFile } from '~/server/utils/minio'; +import formidable from 'formidable'; +import { promises as fs } from 'fs'; +import mime from 'mime-types'; + +export default defineEventHandler(async (event) => { + try { + // Get the current path from query params + const query = getQuery(event); + const currentPath = (query.path as string) || ''; + + // Parse multipart form data + const form = formidable({ + maxFileSize: 50 * 1024 * 1024, // 50MB limit + keepExtensions: true, + }); + + const [fields, files] = await form.parse(event.node.req); + + // Handle multiple files + const uploadedFiles = Array.isArray(files.file) ? files.file : [files.file]; + const results = []; + + for (const uploadedFile of uploadedFiles) { + if (!uploadedFile) continue; + + // Read file buffer + const fileBuffer = await fs.readFile(uploadedFile.filepath); + + // Generate unique filename to prevent collisions + const timestamp = Date.now(); + const sanitizedName = uploadedFile.originalFilename?.replace(/[^a-zA-Z0-9.-]/g, '_') || 'file'; + const fileName = `${timestamp}-${sanitizedName}`; + + // Construct full path including current folder + const fullPath = currentPath ? `${currentPath}${fileName}` : fileName; + + // Get content type + const contentType = mime.lookup(uploadedFile.originalFilename || '') || 'application/octet-stream'; + + // Upload to MinIO + await uploadFile(fullPath, fileBuffer, contentType); + + // Clean up temp file + await fs.unlink(uploadedFile.filepath); + + results.push({ + fileName: fullPath, + originalName: uploadedFile.originalFilename, + size: uploadedFile.size, + contentType, + }); + + // Log audit event + await logAuditEvent(event, 'upload', fullPath, uploadedFile.size); + } + + return { + success: true, + files: results, + message: `${results.length} file(s) uploaded successfully`, + }; + } catch (error: any) { + console.error('Failed to upload file:', error); + throw createError({ + statusCode: 500, + statusMessage: error.message || 'Failed to upload file', + }); + } +}); + +// Audit logging helper +async function logAuditEvent(event: any, action: string, filePath: string, fileSize?: number) { + try { + const user = event.context.user || { email: 'anonymous' }; + const auditLog = { + user_email: user.email, + action, + file_path: filePath, + file_size: fileSize, + timestamp: new Date().toISOString(), + ip_address: getClientIP(event), + success: true, + }; + + // You can store this in your database or logging system + console.log('Audit log:', auditLog); + } catch (error) { + console.error('Failed to log audit event:', error); + } +} + +function getClientIP(event: any): string { + return event.node.req.headers['x-forwarded-for'] || + event.node.req.connection.remoteAddress || + 'unknown'; +} diff --git a/server/utils/minio.ts b/server/utils/minio.ts new file mode 100644 index 0000000..ba2a28f --- /dev/null +++ b/server/utils/minio.ts @@ -0,0 +1,167 @@ +import { Client } from 'minio'; +import type { BucketItem } from 'minio'; + +// Initialize MinIO client +export const getMinioClient = () => { + const config = useRuntimeConfig().minio; + + return new Client({ + endPoint: config.endPoint, + port: config.port, + useSSL: config.useSSL, + accessKey: config.accessKey, + secretKey: config.secretKey, + }); +}; + +// File listing with metadata +export const listFiles = async (prefix: string = '', recursive: boolean = false) => { + const client = getMinioClient(); + const bucketName = useRuntimeConfig().minio.bucketName; + + const files: any[] = []; + const folders = new Set(); + + return new Promise((resolve, reject) => { + const stream = client.listObjectsV2(bucketName, prefix, recursive); + + stream.on('data', (obj) => { + if (!recursive && prefix) { + // Extract folder structure when not recursive + const relativePath = obj.name.substring(prefix.length); + const firstSlash = relativePath.indexOf('/'); + + if (firstSlash > -1) { + // This is a folder + const folderName = relativePath.substring(0, firstSlash); + folders.add(prefix + folderName + '/'); + } else if (relativePath) { + // This is a file in the current folder + files.push({ + name: obj.name, + size: obj.size, + lastModified: obj.lastModified, + etag: obj.etag, + isFolder: false, + }); + } + } else { + // When recursive or at root, include all files + if (!obj.name.endsWith('/')) { + files.push({ + name: obj.name, + size: obj.size, + lastModified: obj.lastModified, + etag: obj.etag, + isFolder: false, + }); + } + } + }); + + stream.on('error', reject); + stream.on('end', () => { + // Add folders to the result + const folderItems = Array.from(folders).map(folder => ({ + name: folder, + size: 0, + lastModified: new Date(), + etag: '', + isFolder: true, + })); + + resolve([...folderItems, ...files]); + }); + }); +}; + +// Upload file +export const uploadFile = async (filePath: string, fileBuffer: Buffer, contentType: string) => { + const client = getMinioClient(); + const bucketName = useRuntimeConfig().minio.bucketName; + + return await client.putObject(bucketName, filePath, fileBuffer, fileBuffer.length, { + 'Content-Type': contentType, + }); +}; + +// Generate presigned URL for download +export const getDownloadUrl = async (fileName: string, expiry: number = 60 * 60) => { + const client = getMinioClient(); + const bucketName = useRuntimeConfig().minio.bucketName; + + return await client.presignedGetObject(bucketName, fileName, expiry); +}; + +// Delete file +export const deleteFile = async (fileName: string) => { + const client = getMinioClient(); + const bucketName = useRuntimeConfig().minio.bucketName; + + return await client.removeObject(bucketName, fileName); +}; + +// Delete folder (recursively delete all contents) +export const deleteFolder = async (folderPath: string) => { + const client = getMinioClient(); + const bucketName = useRuntimeConfig().minio.bucketName; + + // List all objects in the folder + const objectsList: string[] = []; + + return new Promise((resolve, reject) => { + const stream = client.listObjectsV2(bucketName, folderPath, true); + + stream.on('data', (obj) => { + objectsList.push(obj.name); + }); + + stream.on('error', reject); + + stream.on('end', async () => { + try { + // Delete all objects + if (objectsList.length > 0) { + await client.removeObjects(bucketName, objectsList); + } + resolve(true); + } catch (error) { + reject(error); + } + }); + }); +}; + +// Get file stats +export const getFileStats = async (fileName: string) => { + const client = getMinioClient(); + const bucketName = useRuntimeConfig().minio.bucketName; + + return await client.statObject(bucketName, fileName); +}; + +// Create folder (MinIO doesn't have explicit folders, so we create a placeholder) +export const createFolder = async (folderPath: string) => { + const client = getMinioClient(); + const bucketName = useRuntimeConfig().minio.bucketName; + + // Ensure folder path ends with / + const normalizedPath = folderPath.endsWith('/') ? folderPath : folderPath + '/'; + + // Create an empty object to represent the folder + return await client.putObject(bucketName, normalizedPath, Buffer.from(''), 0); +}; + +// Get presigned URL for file preview +export const getPreviewUrl = async (fileName: string, contentType: string) => { + const client = getMinioClient(); + const bucketName = useRuntimeConfig().minio.bucketName; + + // For images and PDFs, generate a presigned URL with appropriate response headers + const responseHeaders = { + 'response-content-type': contentType, + 'response-content-disposition': 'inline', + }; + + return await client.presignedGetObject(bucketName, fileName, 60 * 60, responseHeaders); +};
Loading preview...
{{ error }}
or
+ Maximum file size: 50MB +
+ Manage your NDA documents and other files +