Add MinIO file browser with upload, preview, and management features
- Implement file browser UI with upload/download capabilities - Add API endpoints for file operations (list, upload, delete, preview) - Create FileUploader and FilePreviewModal components - Configure MinIO integration with environment variables - Add documentation for MinIO file browser setup
This commit is contained in:
parent
42efcf3ce1
commit
61cefa530e
|
|
@ -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
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
<template>
|
||||
<v-dialog
|
||||
v-model="isOpen"
|
||||
max-width="90vw"
|
||||
max-height="90vh"
|
||||
scrollable
|
||||
>
|
||||
<v-card v-if="file">
|
||||
<v-card-title class="d-flex align-center justify-space-between">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon class="mr-2">{{ file.icon }}</v-icon>
|
||||
<span>{{ file.displayName }}</span>
|
||||
</div>
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
@click="closeModal"
|
||||
>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-0">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center pa-10">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="primary"
|
||||
size="64"
|
||||
/>
|
||||
<p class="mt-4">Loading preview...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="text-center pa-10">
|
||||
<v-icon size="64" color="error">mdi-alert-circle</v-icon>
|
||||
<p class="mt-4">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Image Preview -->
|
||||
<div v-else-if="isImage" class="image-preview-container">
|
||||
<img
|
||||
:src="previewUrl"
|
||||
:alt="file.displayName"
|
||||
class="image-preview"
|
||||
@load="loading = false"
|
||||
@error="handlePreviewError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- PDF Preview -->
|
||||
<div v-else-if="isPdf" class="pdf-preview-container">
|
||||
<iframe
|
||||
:src="previewUrl"
|
||||
width="100%"
|
||||
height="100%"
|
||||
frameborder="0"
|
||||
@load="loading = false"
|
||||
@error="handlePreviewError"
|
||||
/>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
@click="downloadFile"
|
||||
prepend-icon="mdi-download"
|
||||
>
|
||||
Download
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="closeModal"
|
||||
>
|
||||
Close
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
size: number;
|
||||
sizeFormatted: string;
|
||||
lastModified: string;
|
||||
extension: string;
|
||||
icon: string;
|
||||
displayName: string;
|
||||
isFolder: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
file: FileItem | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const previewUrl = ref('');
|
||||
|
||||
// Computed property for v-model binding
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value),
|
||||
});
|
||||
|
||||
// Check if file is an image
|
||||
const isImage = computed(() => {
|
||||
if (!props.file) return false;
|
||||
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'];
|
||||
return imageExtensions.includes(props.file.extension.toLowerCase());
|
||||
});
|
||||
|
||||
// Check if file is a PDF
|
||||
const isPdf = computed(() => {
|
||||
if (!props.file) return false;
|
||||
return props.file.extension.toLowerCase() === 'pdf';
|
||||
});
|
||||
|
||||
// Watch for file changes and load preview
|
||||
watch(() => props.file, async (newFile) => {
|
||||
if (newFile && props.modelValue) {
|
||||
await loadPreview();
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for dialog open and load preview
|
||||
watch(() => props.modelValue, async (isOpen) => {
|
||||
if (isOpen && props.file) {
|
||||
await loadPreview();
|
||||
}
|
||||
});
|
||||
|
||||
// Load preview URL
|
||||
const loadPreview = async () => {
|
||||
if (!props.file) return;
|
||||
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
previewUrl.value = '';
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/files/preview', {
|
||||
params: { fileName: props.file.name },
|
||||
});
|
||||
|
||||
previewUrl.value = response.url;
|
||||
} catch (err: any) {
|
||||
error.value = err.data?.statusMessage || 'Failed to load preview';
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle preview load error
|
||||
const handlePreviewError = () => {
|
||||
error.value = 'Failed to load preview';
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
// Download file
|
||||
const downloadFile = async () => {
|
||||
if (!props.file) return;
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/files/download', {
|
||||
params: { fileName: props.file.name },
|
||||
});
|
||||
|
||||
window.open(response.url, '_blank');
|
||||
} catch (err) {
|
||||
console.error('Failed to download file:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Close modal
|
||||
const closeModal = () => {
|
||||
isOpen.value = false;
|
||||
previewUrl.value = '';
|
||||
error.value = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-preview-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.pdf-preview-container {
|
||||
width: 100%;
|
||||
height: 70vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
<template>
|
||||
<div>
|
||||
<!-- Drop Zone -->
|
||||
<div
|
||||
class="drop-zone pa-8 text-center rounded-lg"
|
||||
:class="{ 'drop-zone-active': isDragging }"
|
||||
@drop="handleDrop"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
>
|
||||
<v-icon size="64" color="primary" class="mb-4">
|
||||
mdi-cloud-upload-outline
|
||||
</v-icon>
|
||||
<h3 class="text-h6 mb-2">Drag and drop files here</h3>
|
||||
<p class="text-body-2 text-grey mb-4">or</p>
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="openFileDialog"
|
||||
prepend-icon="mdi-folder-open"
|
||||
>
|
||||
Browse Files
|
||||
</v-btn>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
<p class="text-caption text-grey mt-4">
|
||||
Maximum file size: 50MB
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Selected Files -->
|
||||
<v-list v-if="selectedFiles.length > 0" class="mt-4">
|
||||
<v-list-subheader>Selected Files ({{ selectedFiles.length }})</v-list-subheader>
|
||||
<v-list-item
|
||||
v-for="(file, index) in selectedFiles"
|
||||
:key="index"
|
||||
:title="file.name"
|
||||
:subtitle="formatFileSize(file.size)"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon>{{ getFileIcon(file.name) }}</v-icon>
|
||||
</template>
|
||||
<template v-slot:append>
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="removeFile(index)"
|
||||
>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<!-- Upload Progress -->
|
||||
<v-progress-linear
|
||||
v-if="uploading && uploadProgress > 0"
|
||||
:model-value="uploadProgress"
|
||||
color="primary"
|
||||
height="8"
|
||||
class="mt-4"
|
||||
/>
|
||||
|
||||
<!-- Actions -->
|
||||
<v-card-actions v-if="selectedFiles.length > 0" class="mt-4">
|
||||
<v-spacer />
|
||||
<v-btn @click="clearFiles">Clear All</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="uploadFiles"
|
||||
:loading="uploading"
|
||||
:disabled="selectedFiles.length === 0"
|
||||
>
|
||||
Upload {{ selectedFiles.length }} File{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
interface Props {
|
||||
uploading: boolean;
|
||||
currentPath?: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'upload', files: File[]): void;
|
||||
(e: 'close'): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const selectedFiles = ref<File[]>([]);
|
||||
const isDragging = ref(false);
|
||||
const uploadProgress = ref(0);
|
||||
const fileInput = ref<HTMLInputElement>();
|
||||
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||
|
||||
// Handle drag and drop
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
isDragging.value = false;
|
||||
|
||||
const files = Array.from(e.dataTransfer?.files || []);
|
||||
addFiles(files);
|
||||
};
|
||||
|
||||
// Handle file selection
|
||||
const handleFileSelect = (e: Event) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const files = Array.from(input.files || []);
|
||||
addFiles(files);
|
||||
|
||||
// Reset input value to allow selecting same file again
|
||||
input.value = '';
|
||||
};
|
||||
|
||||
// Add files to selection with validation
|
||||
const addFiles = (files: File[]) => {
|
||||
const validFiles = files.filter(file => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
alert(`File "${file.name}" exceeds 50MB limit`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
selectedFiles.value = [...selectedFiles.value, ...validFiles];
|
||||
};
|
||||
|
||||
// Remove file from selection
|
||||
const removeFile = (index: number) => {
|
||||
selectedFiles.value.splice(index, 1);
|
||||
};
|
||||
|
||||
// Clear all files
|
||||
const clearFiles = () => {
|
||||
selectedFiles.value = [];
|
||||
uploadProgress.value = 0;
|
||||
};
|
||||
|
||||
// Upload files
|
||||
const uploadFiles = () => {
|
||||
if (selectedFiles.value.length === 0) return;
|
||||
emit('upload', selectedFiles.value);
|
||||
};
|
||||
|
||||
// Open file dialog
|
||||
const openFileDialog = () => {
|
||||
if (fileInput.value) {
|
||||
fileInput.value.click();
|
||||
}
|
||||
};
|
||||
|
||||
// Helpers
|
||||
const 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];
|
||||
};
|
||||
|
||||
const getFileIcon = (filename: string): string => {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
const iconMap: Record<string, string> = {
|
||||
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';
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drop-zone {
|
||||
border: 2px dashed #ccc;
|
||||
transition: all 0.3s;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.drop-zone-active {
|
||||
border-color: #1976d2;
|
||||
background-color: rgba(25, 118, 210, 0.05);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(() =>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,485 @@
|
|||
<template>
|
||||
<v-container fluid class="pa-6">
|
||||
<!-- Header -->
|
||||
<v-row class="mb-6">
|
||||
<v-col>
|
||||
<h1 class="text-h4 font-weight-bold">
|
||||
<v-icon class="mr-2" color="primary">mdi-folder</v-icon>
|
||||
File Browser
|
||||
</h1>
|
||||
<p class="text-subtitle-1 text-grey mt-1">
|
||||
Manage your NDA documents and other files
|
||||
</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Breadcrumb Navigation -->
|
||||
<v-row class="mb-4" v-if="currentPath">
|
||||
<v-col>
|
||||
<v-breadcrumbs :items="breadcrumbItems" class="pa-0">
|
||||
<template v-slot:item="{ item }">
|
||||
<v-breadcrumbs-item
|
||||
:to="item.to"
|
||||
@click="navigateToFolder(item.path)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</v-breadcrumbs-item>
|
||||
</template>
|
||||
</v-breadcrumbs>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Action Bar -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="searchQuery"
|
||||
placeholder="Search files..."
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
clearable
|
||||
@update:model-value="filterFiles"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6" class="d-flex justify-end ga-2">
|
||||
<v-btn
|
||||
color="secondary"
|
||||
size="large"
|
||||
@click="newFolderDialog = true"
|
||||
prepend-icon="mdi-folder-plus"
|
||||
variant="outlined"
|
||||
>
|
||||
New Folder
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
size="large"
|
||||
@click="uploadDialog = true"
|
||||
prepend-icon="mdi-upload"
|
||||
>
|
||||
Upload Files
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- File List -->
|
||||
<v-card>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="filteredFiles"
|
||||
:loading="loading"
|
||||
:items-per-page="25"
|
||||
class="elevation-0"
|
||||
>
|
||||
<template v-slot:item.displayName="{ item }">
|
||||
<div
|
||||
class="d-flex align-center py-2 cursor-pointer"
|
||||
@click="handleFileClick(item)"
|
||||
>
|
||||
<v-icon :icon="item.icon" class="mr-3" :color="item.isFolder ? 'primary' : ''" />
|
||||
<div>
|
||||
<div class="font-weight-medium">{{ item.displayName }}</div>
|
||||
<div class="text-caption text-grey" v-if="!item.isFolder">
|
||||
{{ item.extension.toUpperCase() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.sizeFormatted="{ item }">
|
||||
<span class="text-body-2">{{ item.sizeFormatted }}</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.lastModified="{ item }">
|
||||
<span class="text-body-2">{{ formatDate(item.lastModified) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.actions="{ item }">
|
||||
<div class="d-flex justify-end">
|
||||
<v-btn
|
||||
v-if="canPreview(item)"
|
||||
icon
|
||||
variant="text"
|
||||
size="small"
|
||||
@click.stop="previewFile(item)"
|
||||
>
|
||||
<v-icon>mdi-eye</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Preview</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="!item.isFolder"
|
||||
icon
|
||||
variant="text"
|
||||
size="small"
|
||||
@click.stop="downloadFile(item)"
|
||||
:loading="downloadingFiles[item.name]"
|
||||
>
|
||||
<v-icon>mdi-download</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Download</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click.stop="confirmDelete(item)"
|
||||
>
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Delete</v-tooltip>
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:no-data>
|
||||
<v-empty-state
|
||||
icon="mdi-folder-open-outline"
|
||||
title="No files found"
|
||||
:text="currentPath ? 'This folder is empty' : 'Upload your first file to get started'"
|
||||
class="my-6"
|
||||
/>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Upload Dialog -->
|
||||
<v-dialog v-model="uploadDialog" max-width="600">
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon class="mr-2">mdi-upload</v-icon>
|
||||
Upload Files
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<FileUploader
|
||||
@upload="handleFileUpload"
|
||||
@close="uploadDialog = false"
|
||||
:uploading="uploading"
|
||||
:current-path="currentPath"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- New Folder Dialog -->
|
||||
<v-dialog v-model="newFolderDialog" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<v-icon class="mr-2">mdi-folder-plus</v-icon>
|
||||
Create New Folder
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="newFolderName"
|
||||
label="Folder Name"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
autofocus
|
||||
@keyup.enter="createNewFolder"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="newFolderDialog = false">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="createNewFolder"
|
||||
:loading="creatingFolder"
|
||||
:disabled="!newFolderName"
|
||||
>
|
||||
Create
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<v-dialog v-model="deleteDialog" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<v-icon class="mr-2" color="error">mdi-alert</v-icon>
|
||||
Confirm Delete
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
Are you sure you want to delete "{{ fileToDelete?.displayName }}"?
|
||||
<span v-if="fileToDelete?.isFolder">
|
||||
This will delete all files and folders inside it.
|
||||
</span>
|
||||
This action cannot be undone.
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="deleteDialog = false">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="flat"
|
||||
@click="deleteFile"
|
||||
:loading="deleting"
|
||||
>
|
||||
Delete
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- File Preview Dialog -->
|
||||
<FilePreviewModal
|
||||
v-model="previewDialog"
|
||||
:file="fileToPreview"
|
||||
/>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import FileUploader from '~/components/FileUploader.vue';
|
||||
import FilePreviewModal from '~/components/FilePreviewModal.vue';
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
size: number;
|
||||
sizeFormatted: string;
|
||||
lastModified: string;
|
||||
extension: string;
|
||||
icon: string;
|
||||
displayName: string;
|
||||
isFolder: boolean;
|
||||
}
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
// Data
|
||||
const files = ref<FileItem[]>([]);
|
||||
const filteredFiles = ref<FileItem[]>([]);
|
||||
const searchQuery = ref('');
|
||||
const loading = ref(false);
|
||||
const uploading = ref(false);
|
||||
const deleting = ref(false);
|
||||
const creatingFolder = ref(false);
|
||||
const uploadDialog = ref(false);
|
||||
const deleteDialog = ref(false);
|
||||
const newFolderDialog = ref(false);
|
||||
const previewDialog = ref(false);
|
||||
const fileToDelete = ref<FileItem | null>(null);
|
||||
const fileToPreview = ref<FileItem | null>(null);
|
||||
const downloadingFiles = ref<Record<string, boolean>>({});
|
||||
const currentPath = ref('');
|
||||
const newFolderName = ref('');
|
||||
|
||||
// Table headers
|
||||
const headers = [
|
||||
{ title: 'Name', key: 'displayName', sortable: true },
|
||||
{ title: 'Size', key: 'sizeFormatted', sortable: true },
|
||||
{ title: 'Modified', key: 'lastModified', sortable: true },
|
||||
{ title: 'Actions', key: 'actions', sortable: false, align: 'end' as const },
|
||||
];
|
||||
|
||||
// Breadcrumb items
|
||||
const breadcrumbItems = computed(() => {
|
||||
const items = [
|
||||
{ title: 'Root', path: '', to: '#' }
|
||||
];
|
||||
|
||||
if (currentPath.value) {
|
||||
const parts = currentPath.value.split('/').filter(Boolean);
|
||||
let accumulated = '';
|
||||
|
||||
parts.forEach(part => {
|
||||
accumulated += part + '/';
|
||||
items.push({
|
||||
title: part,
|
||||
path: accumulated,
|
||||
to: '#'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
// Load files
|
||||
const loadFiles = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await $fetch('/api/files/list', {
|
||||
params: {
|
||||
prefix: currentPath.value,
|
||||
recursive: false,
|
||||
}
|
||||
});
|
||||
files.value = response.files;
|
||||
filteredFiles.value = response.files;
|
||||
} catch (error) {
|
||||
toast.error('Failed to load files');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Filter files based on search
|
||||
const filterFiles = () => {
|
||||
if (!searchQuery.value) {
|
||||
filteredFiles.value = files.value;
|
||||
return;
|
||||
}
|
||||
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
filteredFiles.value = files.value.filter(file =>
|
||||
file.displayName.toLowerCase().includes(query)
|
||||
);
|
||||
};
|
||||
|
||||
// Handle file/folder click
|
||||
const handleFileClick = (item: FileItem) => {
|
||||
if (item.isFolder) {
|
||||
navigateToFolder(item.name);
|
||||
} else if (canPreview(item)) {
|
||||
previewFile(item);
|
||||
}
|
||||
};
|
||||
|
||||
// Navigate to folder
|
||||
const navigateToFolder = (folderPath: string) => {
|
||||
currentPath.value = folderPath;
|
||||
searchQuery.value = '';
|
||||
loadFiles();
|
||||
};
|
||||
|
||||
// Check if file can be previewed
|
||||
const canPreview = (file: FileItem) => {
|
||||
if (file.isFolder) return false;
|
||||
const previewableExtensions = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'pdf'];
|
||||
return previewableExtensions.includes(file.extension);
|
||||
};
|
||||
|
||||
// Preview file
|
||||
const previewFile = (file: FileItem) => {
|
||||
fileToPreview.value = file;
|
||||
previewDialog.value = true;
|
||||
};
|
||||
|
||||
// Handle file upload
|
||||
const handleFileUpload = async (uploadedFiles: File[]) => {
|
||||
uploading.value = true;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
uploadedFiles.forEach(file => {
|
||||
formData.append('file', file);
|
||||
});
|
||||
|
||||
await $fetch('/api/files/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
params: {
|
||||
path: currentPath.value,
|
||||
}
|
||||
});
|
||||
|
||||
toast.success(`${uploadedFiles.length} file(s) uploaded successfully`);
|
||||
uploadDialog.value = false;
|
||||
await loadFiles();
|
||||
} catch (error) {
|
||||
toast.error('Failed to upload files');
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Create new folder
|
||||
const createNewFolder = async () => {
|
||||
if (!newFolderName.value) return;
|
||||
|
||||
creatingFolder.value = true;
|
||||
try {
|
||||
const folderPath = currentPath.value
|
||||
? `${currentPath.value}${newFolderName.value}/`
|
||||
: `${newFolderName.value}/`;
|
||||
|
||||
await $fetch('/api/files/create-folder', {
|
||||
method: 'POST',
|
||||
body: { folderPath },
|
||||
});
|
||||
|
||||
toast.success('Folder created successfully');
|
||||
newFolderDialog.value = false;
|
||||
newFolderName.value = '';
|
||||
await loadFiles();
|
||||
} catch (error) {
|
||||
toast.error('Failed to create folder');
|
||||
} finally {
|
||||
creatingFolder.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Download file
|
||||
const downloadFile = async (file: FileItem) => {
|
||||
downloadingFiles.value[file.name] = true;
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/files/download', {
|
||||
params: { fileName: file.name },
|
||||
});
|
||||
|
||||
// Open download URL in new tab
|
||||
window.open(response.url, '_blank');
|
||||
} catch (error) {
|
||||
toast.error('Failed to generate download link');
|
||||
} finally {
|
||||
downloadingFiles.value[file.name] = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Confirm delete
|
||||
const confirmDelete = (file: FileItem) => {
|
||||
fileToDelete.value = file;
|
||||
deleteDialog.value = true;
|
||||
};
|
||||
|
||||
// Delete file
|
||||
const deleteFile = async () => {
|
||||
if (!fileToDelete.value) return;
|
||||
|
||||
deleting.value = true;
|
||||
try {
|
||||
await $fetch('/api/files/delete', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
fileName: fileToDelete.value.name,
|
||||
isFolder: fileToDelete.value.isFolder,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success(fileToDelete.value.isFolder ? 'Folder deleted successfully' : 'File deleted successfully');
|
||||
deleteDialog.value = false;
|
||||
await loadFiles();
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete');
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
fileToDelete.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Helpers
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// Load files on mount
|
||||
onMounted(() => {
|
||||
loadFiles();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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<string, string> = {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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<string>();
|
||||
|
||||
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…
Reference in New Issue