diff --git a/deploy/helm-chart/templates/deployment.yaml b/deploy/helm-chart/templates/deployment.yaml index dc8949d..68b8e1d 100644 --- a/deploy/helm-chart/templates/deployment.yaml +++ b/deploy/helm-chart/templates/deployment.yaml @@ -111,6 +111,8 @@ spec: value: "{{ .Values.maestro.redis_tls }}" - name: PLATFORM_API_URL value: {{ .Values.maestro.platform_api_url }} + - name: STORAGE_EXPLORER_API_URL + value: {{ .Values.maestro.storage_explorer_api_url | quote }} - name: JWT_PRIVATE_KEY valueFrom: secretKeyRef: diff --git a/deploy/helm-chart/values.yaml b/deploy/helm-chart/values.yaml index 37cd6be..3c8ebcc 100644 --- a/deploy/helm-chart/values.yaml +++ b/deploy/helm-chart/values.yaml @@ -47,6 +47,7 @@ maestro: open_customer_id: f239718a-a271-4ef9-ae7e-02a2f0f3aa6e open_group_id: 401573bb-334f-44b2-b30e-88d4cea31ae9 platform_api_url: https://oz8v2zid1e.execute-api.us-east-1.amazonaws.com + storage_explorer_api_url: "https://storage-explorer-{customer_id}.prd.dadosfera.ai/api" dedicated_proxy: "" restricted_ip: "" redis_host: "aaapzppmlyamkocqwstpo7zvopczyyiyuy6xzm2g6c5k4mq3a66be4a-0.redis.sa-saopaulo-1.oci.oraclecloud.com" diff --git a/docsfera.json b/docsfera.json index 8942a26..ec330d1 100644 --- a/docsfera.json +++ b/docsfera.json @@ -7767,6 +7767,748 @@ ] } }, + "/storage-explorer/tables/validate-name": { + "post": { + "operationId": "StorageExplorerController_validateTableName", + "summary": "Validate table name in PostgreSQL and Snowflake", + "parameters": [], + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Storage Explorer" + ], + "security": [ + { + "access-token": [] + }, + { + "access-token": [] + } + ] + } + }, + "/storage-explorer/tables": { + "post": { + "operationId": "StorageExplorerController_createTable", + "summary": "Create a new table", + "parameters": [], + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Storage Explorer" + ], + "security": [ + { + "access-token": [] + }, + { + "access-token": [] + } + ] + }, + "get": { + "operationId": "StorageExplorerController_listTables", + "summary": "List all tables with pagination", + "parameters": [ + { + "name": "page", + "required": true, + "in": "query", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Storage Explorer" + ], + "security": [ + { + "access-token": [] + }, + { + "access-token": [] + } + ] + } + }, + "/storage-explorer/tables/{tableId}": { + "get": { + "operationId": "StorageExplorerController_getTable", + "summary": "Get table details by ID", + "parameters": [ + { + "name": "tableId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Storage Explorer" + ], + "security": [ + { + "access-token": [] + }, + { + "access-token": [] + } + ] + } + }, + "/storage-explorer/tables/{tableId}/datasets/{datasetId}": { + "post": { + "operationId": "StorageExplorerController_linkDatasetToTable", + "summary": "Link a dataset to a table", + "parameters": [ + { + "name": "tableId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "datasetId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Storage Explorer" + ], + "security": [ + { + "access-token": [] + }, + { + "access-token": [] + } + ] + } + }, + "/storage-explorer/tables/{tableId}/datasets": { + "get": { + "operationId": "StorageExplorerController_getTableDatasets", + "summary": "Get all datasets linked to a table", + "parameters": [ + { + "name": "tableId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Storage Explorer" + ], + "security": [ + { + "access-token": [] + }, + { + "access-token": [] + } + ] + } + }, + "/storage-explorer/tables/{tableId}/schema": { + "get": { + "operationId": "StorageExplorerController_getTableSchema", + "summary": "Get table schema", + "parameters": [ + { + "name": "tableId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Storage Explorer" + ], + "security": [ + { + "access-token": [] + }, + { + "access-token": [] + } + ] + } + }, + "/storage-explorer/tables/{tableId}/validate-compatibility/{datasetId}": { + "post": { + "operationId": "StorageExplorerController_validateSchemaCompatibility", + "summary": "Validate schema compatibility between table and dataset", + "parameters": [ + { + "name": "tableId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "datasetId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Storage Explorer" + ], + "security": [ + { + "access-token": [] + }, + { + "access-token": [] + } + ] + } + }, + "/storage-explorer/datasets/{datasetId}/preview": { + "get": { + "operationId": "StorageExplorerController_getDatasetPreview", + "summary": "Get dataset preview data", + "parameters": [ + { + "name": "datasetId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "required": true, + "in": "query", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Storage Explorer" + ], + "security": [ + { + "access-token": [] + }, + { + "access-token": [] + } + ] + } + }, + "/storage-explorer/datasets/{datasetId}/schema": { + "get": { + "operationId": "StorageExplorerController_getDatasetSchema", + "summary": "Get dataset schema information", + "parameters": [ + { + "name": "datasetId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "force_refresh", + "required": true, + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Storage Explorer" + ], + "security": [ + { + "access-token": [] + }, + { + "access-token": [] + } + ] + } + }, + "/storage-explorer/datasets/upload/{uploadId}": { + "get": { + "operationId": "StorageExplorerController_listDatasetsByUpload", + "summary": "List all datasets for a specific upload", + "parameters": [ + { + "name": "uploadId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Storage Explorer" + ], + "security": [ + { + "access-token": [] + }, + { + "access-token": [] + } + ] + } + }, + "/storage-explorer/datasets/{datasetId}/refresh-schema": { + "put": { + "operationId": "StorageExplorerController_refreshDatasetSchema", + "summary": "Refresh dataset schema with new parsing options (Excel)", + "parameters": [ + { + "name": "datasetId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Storage Explorer" + ], + "security": [ + { + "access-token": [] + }, + { + "access-token": [] + } + ] + } + }, + "/storage-explorer/storage/uploads/history": { + "get": { + "operationId": "StorageExplorerController_listFileExplorerUploads", + "summary": "List file explorer uploads with pagination", + "parameters": [ + { + "name": "page", + "required": true, + "in": "query", + "schema": { + "type": "number" + } + }, + { + "name": "limit", + "required": true, + "in": "query", + "schema": { + "type": "number" + } + }, + { + "name": "folder_path", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Storage Explorer" + ], + "security": [ + { + "access-token": [] + }, + { + "access-token": [] + } + ] + } + }, + "/storage-explorer/storage/browse": { + "get": { + "operationId": "StorageExplorerController_browseStorage", + "summary": "Browse folders and files in storage", + "parameters": [ + { + "name": "path", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Storage Explorer" + ], + "security": [ + { + "access-token": [] + }, + { + "access-token": [] + } + ] + } + }, + "/storage-explorer/storage/upload/batch": { + "post": { + "operationId": "StorageExplorerController_batchUpload", + "summary": "Upload multiple files to storage", + "parameters": [], + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Storage Explorer" + ], + "security": [ + { + "access-token": [] + }, + { + "access-token": [] + } + ] + } + }, + "/storage-explorer/storage/folder/create": { + "post": { + "operationId": "StorageExplorerController_createFolder", + "summary": "Create a new folder in storage", + "parameters": [], + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Storage Explorer" + ], + "security": [ + { + "access-token": [] + }, + { + "access-token": [] + } + ] + } + }, + "/storage-explorer/storage/download": { + "get": { + "operationId": "StorageExplorerController_downloadFile", + "summary": "Download a file from storage", + "parameters": [ + { + "name": "file_path", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Storage Explorer" + ], + "security": [ + { + "access-token": [] + }, + { + "access-token": [] + } + ] + } + }, + "/storage-explorer/storage/link-to-table": { + "post": { + "operationId": "StorageExplorerController_linkFileToTable", + "summary": "Link an existing file in storage to a table", + "parameters": [], + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Storage Explorer" + ], + "security": [ + { + "access-token": [] + }, + { + "access-token": [] + } + ] + } + }, + "/storage-explorer/storage/metadata": { + "get": { + "operationId": "StorageExplorerController_getFileMetadata", + "summary": "Get detailed file metadata", + "parameters": [ + { + "name": "file_path", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Storage Explorer" + ], + "security": [ + { + "access-token": [] + }, + { + "access-token": [] + } + ] + } + }, "/health": { "get": { "operationId": "HealthController_check", diff --git a/package-lock.json b/package-lock.json index c27510d..4d90eb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1747,7 +1747,6 @@ "version": "3.38.0-beta.26", "resolved": "https://dadosfera-611330257153.d.codeartifact.us-east-1.amazonaws.com/npm/dadosfera-npm/@dadosfera/protospack-v2/-/protospack-v2-3.38.0-beta.26.tgz", "integrity": "sha512-N8NS7+djLGy0wJXk00+4oupqd/wBIQ1f+YBBhK2y9x4guFXYK1KWIrPZhPj+gaU8g2KNkqKoT7SnE9PXNxlLSQ==", - "license": "ISC", "dependencies": { "@grpc/grpc-js": "^1.9.3", "rxjs": "^7.5.5" diff --git a/src/app.module.ts b/src/app.module.ts index 27d042c..0865ad2 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -34,6 +34,7 @@ import { AssignModule } from './modules/assign/assign.module'; import { ShareMetadataModule } from './modules/share-metadata/share-metadata.module'; import { ApiKeyModule } from './modules/api-key/api-key.module'; import { PlatformApiModule } from './modules/platform-api/platform-api.module'; +import { StorageExplorerModule } from './modules/storage-explorer/storage-explorer.module'; @Module({ providers: [ @@ -75,6 +76,7 @@ import { PlatformApiModule } from './modules/platform-api/platform-api.module'; IdentityProviderModule, NetworkPolicyModule, PlatformApiModule, + StorageExplorerModule, //Always leave HealthModule last, so it is on the bottom of swagger HealthModule, ], diff --git a/src/authentication/permissions.enum.ts b/src/authentication/permissions.enum.ts index 3eeb2a7..ca5e9ac 100644 --- a/src/authentication/permissions.enum.ts +++ b/src/authentication/permissions.enum.ts @@ -668,6 +668,35 @@ export const PERMISSIONS_GROUPS = { }, }, }, + STORAGE_EXPLORER: { + title: { + 'pt-br': 'Storage Explorer', + 'en-us': 'Storage Explorer', + 'es-es': 'Storage Explorer', + }, + permissions: { + READ: { + seqid: 51, + claim: 'storage-explorer:read', + usage: PermissionUsages.PUBLIC, + name: { + 'pt-br': 'Ler dados do Storage Explorer', + 'en-us': 'Read Storage Explorer data', + 'es-es': 'Leer datos del Storage Explorer', + }, + }, + WRITE: { + seqid: 52, + claim: 'storage-explorer:write', + usage: PermissionUsages.PUBLIC, + name: { + 'pt-br': 'Escrever dados no Storage Explorer', + 'en-us': 'Write Storage Explorer data', + 'es-es': 'Escribir datos en Storage Explorer', + }, + }, + }, + }, }; export interface DadosferaModule { name: string; diff --git a/src/modules/storage-explorer/storage-explorer.config.ts b/src/modules/storage-explorer/storage-explorer.config.ts new file mode 100644 index 0000000..417eb15 --- /dev/null +++ b/src/modules/storage-explorer/storage-explorer.config.ts @@ -0,0 +1,13 @@ +export const STORAGE_EXPLORER_CONFIG = { + getUrl: (customerId: string): string => { + const urlTemplate = process.env.STORAGE_EXPLORER_API_URL; + if (!urlTemplate) { + throw new Error('STORAGE_EXPLORER_API_URL environment variable is not set'); + } + // Replace {customer_id} placeholder with actual customer ID + // For local: http://172.17.0.1:8000/api (no placeholder) + // For prod: https://storage-explorer-{customer_id}.stg.dadosfera.ai/api + return urlTemplate.replace('{customer_id}', customerId); + }, + timeout: parseInt(process.env.STORAGE_EXPLORER_TIMEOUT || '30000', 10), +}; diff --git a/src/modules/storage-explorer/storage-explorer.controller.ts b/src/modules/storage-explorer/storage-explorer.controller.ts new file mode 100644 index 0000000..ef6a071 --- /dev/null +++ b/src/modules/storage-explorer/storage-explorer.controller.ts @@ -0,0 +1,350 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Param, + Body, + Query, + Inject, + UseInterceptors, + UploadedFiles, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiConsumes } from '@nestjs/swagger'; +import { FilesInterceptor } from '@nestjs/platform-express'; +import { DadosferaLogger } from '@dadosfera/dadosfera-logs'; +import FormData from 'form-data'; + +import { + Authenticated, + RequireAllPermissions, +} from '../../decorators/authentication.decorator'; +import { User, RequestUser } from '../../decorators/user.decorator'; +import { StorageExplorerService } from './storage-explorer.service'; +import { PERMISSIONS_GROUPS } from '../../authentication/permissions.enum'; + +@ApiTags('Storage Explorer') +@Controller('storage-explorer') +@Authenticated() +export class StorageExplorerController { + private logger: any; + + constructor( + private readonly storageExplorerService: StorageExplorerService, + @Inject(DadosferaLogger) dadosferaLogger: DadosferaLogger, + ) { + this.logger = dadosferaLogger.logger; + } + + // ============================================ + // TABLE OPERATIONS + // ============================================ + + @ApiOperation({ summary: 'Validate table name in PostgreSQL and Snowflake' }) + @Post('tables/validate-name') + @RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.WRITE) + async validateTableName( + @Body() body: any, + @User() user: RequestUser, + ) { + return this.storageExplorerService.proxy( + 'POST', + '/tables/validate-name', + user, + body, + ); + } + + @ApiOperation({ summary: 'Create a new table' }) + @Post('tables') + @RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.WRITE) + async createTable( + @Body() body: any, + @User() user: RequestUser, + ) { + return this.storageExplorerService.proxy( + 'POST', + '/tables/', + user, + body, + ); + } + + @ApiOperation({ summary: 'List all tables with pagination' }) + @Get('tables') + @RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.READ) + async listTables( + @Query('page') page: number, + @User() user: RequestUser, + ) { + return this.storageExplorerService.proxy( + 'GET', + '/tables/', + user, + undefined, + { page }, + ); + } + + @ApiOperation({ summary: 'Get table details by ID' }) + @Get('tables/:tableId') + @RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.READ) + async getTable( + @Param('tableId') tableId: string, + @User() user: RequestUser, + ) { + return this.storageExplorerService.proxy( + 'GET', + `/tables/${tableId}`, + user, + ); + } + + @ApiOperation({ summary: 'Link a dataset to a table' }) + @Post('tables/:tableId/datasets/:datasetId') + @RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.WRITE) + async linkDatasetToTable( + @Param('tableId') tableId: string, + @Param('datasetId') datasetId: string, + @User() user: RequestUser, + ) { + return this.storageExplorerService.proxy( + 'POST', + `/tables/${tableId}/datasets/${datasetId}`, + user, + ); + } + + @ApiOperation({ summary: 'Get all datasets linked to a table' }) + @Get('tables/:tableId/datasets') + @RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.READ) + async getTableDatasets( + @Param('tableId') tableId: string, + @User() user: RequestUser, + ) { + return this.storageExplorerService.proxy( + 'GET', + `/tables/${tableId}/datasets`, + user, + ); + } + + @ApiOperation({ summary: 'Get table schema' }) + @Get('tables/:tableId/schema') + @RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.READ) + async getTableSchema( + @Param('tableId') tableId: string, + @User() user: RequestUser, + ) { + return this.storageExplorerService.proxy( + 'GET', + `/tables/${tableId}/schema`, + user, + ); + } + + @ApiOperation({ summary: 'Validate schema compatibility between table and dataset' }) + @Post('tables/:tableId/validate-compatibility/:datasetId') + @RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.READ) + async validateSchemaCompatibility( + @Param('tableId') tableId: string, + @Param('datasetId') datasetId: string, + @User() user: RequestUser, + ) { + return this.storageExplorerService.proxy( + 'POST', + `/tables/${tableId}/validate-compatibility/${datasetId}`, + user, + ); + } + + // ============================================ + // DATASET OPERATIONS + // ============================================ + + @ApiOperation({ summary: 'Get dataset preview data' }) + @Get('datasets/:datasetId/preview') + @RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.READ) + async getDatasetPreview( + @Param('datasetId') datasetId: string, + @Query('limit') limit: number, + @User() user: RequestUser, + ) { + return this.storageExplorerService.proxy( + 'GET', + `/datasets/${datasetId}/preview`, + user, + undefined, + { limit }, + ); + } + + @ApiOperation({ summary: 'Get dataset schema information' }) + @Get('datasets/:datasetId/schema') + @RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.READ) + async getDatasetSchema( + @Param('datasetId') datasetId: string, + @Query('force_refresh') forceRefresh: boolean, + @User() user: RequestUser, + ) { + return this.storageExplorerService.proxy( + 'GET', + `/datasets/${datasetId}/schema`, + user, + undefined, + { force_refresh: forceRefresh }, + ); + } + + @ApiOperation({ summary: 'List all datasets for a specific upload' }) + @Get('datasets/upload/:uploadId') + @RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.READ) + async listDatasetsByUpload( + @Param('uploadId') uploadId: string, + @User() user: RequestUser, + ) { + return this.storageExplorerService.proxy( + 'GET', + `/datasets/upload/${uploadId}`, + user, + ); + } + + @ApiOperation({ summary: 'Refresh dataset schema with new parsing options (Excel)' }) + @Put('datasets/:datasetId/refresh-schema') + @RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.WRITE) + async refreshDatasetSchema( + @Param('datasetId') datasetId: string, + @Body() body: any, + @User() user: RequestUser, + ) { + return this.storageExplorerService.proxy( + 'PUT', + `/datasets/${datasetId}/refresh-schema`, + user, + body, + ); + } + + // ============================================ + // STORAGE OPERATIONS + // ============================================ + + @ApiOperation({ summary: 'List file explorer uploads with pagination' }) + @Get('storage/uploads/history') + @RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.READ) + async listFileExplorerUploads( + @Query('page') page: number, + @Query('limit') limit: number, + @Query('folder_path') folderPath: string, + @User() user: RequestUser, + ) { + return this.storageExplorerService.proxy( + 'GET', + '/storage/uploads/history', + user, + undefined, + { page, limit, folder_path: folderPath }, + ); + } + + @ApiOperation({ summary: 'Browse folders and files in storage' }) + @Get('storage/browse') + @RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.READ) + async browseStorage( + @Query('path') path: string, + @User() user: RequestUser, + ) { + return this.storageExplorerService.proxy( + 'GET', + '/storage/browse', + user, + undefined, + { path }, + ); + } + + @ApiOperation({ summary: 'Upload multiple files to storage' }) + @Post('storage/upload/batch') + @ApiConsumes('multipart/form-data') + @UseInterceptors(FilesInterceptor('files')) + @RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.WRITE) + async batchUpload( + @UploadedFiles() files: Array, + @Body('folder_path') folderPath: string, + @User() user: RequestUser, + ) { + // Create FormData to forward files to storage-explorer API + const formData = new FormData(); + + // Add files + if (files && files.length > 0) { + files.forEach((file) => { + formData.append('files', file.buffer, { + filename: file.originalname, + contentType: file.mimetype, + }); + }); + } + + // Add folder_path + if (folderPath) { + formData.append('folder_path', folderPath); + } + + return this.storageExplorerService.proxyFormData( + 'POST', + '/storage/upload/batch', + user, + formData, + ); + } + + @ApiOperation({ summary: 'Create a new folder in storage' }) + @Post('storage/folder/create') + @RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.WRITE) + async createFolder( + @Body() body: any, + @User() user: RequestUser, + ) { + return this.storageExplorerService.proxy( + 'POST', + '/storage/folder/create', + user, + body, + ); + } + + @ApiOperation({ summary: 'Download a file from storage' }) + @Get('storage/download') + @RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.READ) + async downloadFile( + @Query('file_path') filePath: string, + @User() user: RequestUser, + ) { + return this.storageExplorerService.proxy( + 'GET', + '/storage/download', + user, + undefined, + { file_path: filePath }, + ); + } + + @ApiOperation({ summary: 'Get detailed file metadata' }) + @Get('storage/metadata') + @RequireAllPermissions(PERMISSIONS_GROUPS.STORAGE_EXPLORER.permissions.READ) + async getFileMetadata( + @Query('file_path') filePath: string, + @User() user: RequestUser, + ) { + return this.storageExplorerService.proxy( + 'GET', + '/storage/metadata', + user, + undefined, + { file_path: filePath }, + ); + } +} diff --git a/src/modules/storage-explorer/storage-explorer.module.ts b/src/modules/storage-explorer/storage-explorer.module.ts new file mode 100644 index 0000000..b0f0e86 --- /dev/null +++ b/src/modules/storage-explorer/storage-explorer.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { DadosferaLogger } from '@dadosfera/dadosfera-logs'; + +import { StorageExplorerController } from './storage-explorer.controller'; +import { StorageExplorerService } from './storage-explorer.service'; + +@Module({ + imports: [], + controllers: [StorageExplorerController], + providers: [StorageExplorerService, DadosferaLogger], + exports: [StorageExplorerService], +}) +export class StorageExplorerModule {} diff --git a/src/modules/storage-explorer/storage-explorer.service.ts b/src/modules/storage-explorer/storage-explorer.service.ts new file mode 100644 index 0000000..ba149c0 --- /dev/null +++ b/src/modules/storage-explorer/storage-explorer.service.ts @@ -0,0 +1,178 @@ +import { Injectable, Inject, HttpException } from '@nestjs/common'; +import axios, { AxiosResponse, Method } from 'axios'; +import { DadosferaLogger } from '@dadosfera/dadosfera-logs'; + +import { RequestUser } from '../../decorators/user.decorator'; +import { STORAGE_EXPLORER_CONFIG } from './storage-explorer.config'; + +@Injectable() +export class StorageExplorerService { + private logger: any; + + constructor( + @Inject(DadosferaLogger) dadosferaLogger: DadosferaLogger, + ) { + this.logger = dadosferaLogger.logger; + } + + async proxy( + method: string, + path: string, + user: RequestUser, + body?: any, + query?: Record, + ): Promise { + // Validate customer_id is present for multi-tenant isolation + if (!user.customer_id) { + throw new HttpException('Customer ID is required for storage operations', 400); + } + + // Get customer-specific storage-explorer URL + const baseUrl = STORAGE_EXPLORER_CONFIG.getUrl(user.customer_id); + const url = new URL(`${baseUrl}${path}`); + + // Add query params + if (query) { + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + url.searchParams.set(key, String(value)); + } + }); + } + + const headers: Record = { + 'content-type': 'application/json', + }; + + this.logger.info('Proxying request to storage-explorer', { + method: method.toUpperCase(), + path, + customer_id: user.customer_id, + storage_url: baseUrl, + user_id: user.user_id, + }); + + try { + const response: AxiosResponse = await axios({ + method: method as Method, + url: url.href, + headers, + data: body, + timeout: STORAGE_EXPLORER_CONFIG.timeout, + validateStatus: () => true, // Don't throw on non-2xx + }); + + // Propagate non-2xx responses as HttpExceptions + if (response.status >= 400) { + throw new HttpException(response.data, response.status); + } + + return response.data; + } catch (error) { + this.logger.error('Storage Explorer API proxy error', { + error: error.message, + status: error.response?.status, + path, + storage_url: baseUrl, + method: method.toUpperCase(), + }); + + if (error instanceof HttpException) { + throw error; + } + + if (error.response) { + throw new HttpException(error.response.data, error.response.status); + } + + if (error.code === 'ECONNREFUSED') { + throw new HttpException('Storage Explorer API service unavailable', 503); + } + + if (error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED') { + throw new HttpException('Storage Explorer API request timeout', 504); + } + + throw new HttpException('Internal server error', 500); + } + } + + /** + * Proxy with file upload support (multipart/form-data) + */ + async proxyFormData( + method: string, + path: string, + user: RequestUser, + formData: any, + query?: Record, + ): Promise { + // Validate customer_id is present for multi-tenant isolation + if (!user.customer_id) { + throw new HttpException('Customer ID is required for storage operations', 400); + } + + // Get customer-specific storage-explorer URL + const baseUrl = STORAGE_EXPLORER_CONFIG.getUrl(user.customer_id); + const url = new URL(`${baseUrl}${path}`); + + // Add query params + if (query) { + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + url.searchParams.set(key, String(value)); + } + }); + } + + const headers: Record = { + // Let axios set Content-Type for multipart/form-data with boundary + ...formData.getHeaders?.(), + }; + + this.logger.info('Proxying form data request to storage-explorer', { + method: method.toUpperCase(), + path, + customer_id: user.customer_id, + storage_url: baseUrl, + user_id: user.user_id, + }); + + try { + const response: AxiosResponse = await axios({ + method: method as Method, + url: url.href, + headers, + data: formData, + timeout: STORAGE_EXPLORER_CONFIG.timeout, + maxContentLength: Infinity, + maxBodyLength: Infinity, + validateStatus: () => true, + }); + + if (response.status >= 400) { + throw new HttpException(response.data, response.status); + } + + return response.data; + } catch (error) { + this.logger.error('Storage Explorer API form data proxy error', { + error: error.message, + status: error.response?.status, + path, + storage_url: baseUrl, + method: method.toUpperCase(), + }); + + if (error instanceof HttpException) { + throw error; + } + + if (error.response) { + throw new HttpException(error.response.data, error.response.status); + } + + throw new HttpException('Internal server error', 500); + } + } +}