diff --git a/packages/nc-gui/components/smartsheet/toolbar/ExportSubActions.vue b/packages/nc-gui/components/smartsheet/toolbar/ExportSubActions.vue index 969c2e13bc59..935b137c43bd 100644 --- a/packages/nc-gui/components/smartsheet/toolbar/ExportSubActions.vue +++ b/packages/nc-gui/components/smartsheet/toolbar/ExportSubActions.vue @@ -1,83 +1,95 @@ @@ -89,20 +101,10 @@ const exportFile = async (exportType: ExportTypes) => {
- + CSV
- - -
- - - - - Excel -
-
diff --git a/packages/nc-gui/plugins/api.ts b/packages/nc-gui/plugins/api.ts index 89fefac594f5..d2edbed78a5c 100644 --- a/packages/nc-gui/plugins/api.ts +++ b/packages/nc-gui/plugins/api.ts @@ -1,3 +1,5 @@ +import type { Api } from 'nocodb-sdk' + const apiPlugin = (nuxtApp) => { const { api } = useApi() @@ -7,7 +9,7 @@ const apiPlugin = (nuxtApp) => { declare module '#app' { interface NuxtApp { - $api: ReturnType + $api: Api } } diff --git a/packages/nocodb/src/controllers/data-alias-export.controller.spec.ts b/packages/nocodb/src/controllers/data-alias-export.controller.spec.ts deleted file mode 100644 index 7670b7a3c5c4..000000000000 --- a/packages/nocodb/src/controllers/data-alias-export.controller.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Test } from '@nestjs/testing'; -import { DataAliasExportController } from './data-alias-export.controller'; -import type { TestingModule } from '@nestjs/testing'; - -describe('DataAliasExportController', () => { - let controller: DataAliasExportController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [DataAliasExportController], - }).compile(); - - controller = module.get( - DataAliasExportController, - ); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/packages/nocodb/src/controllers/data-alias-export.controller.ts b/packages/nocodb/src/controllers/data-alias-export.controller.ts deleted file mode 100644 index 164445a8e6d5..000000000000 --- a/packages/nocodb/src/controllers/data-alias-export.controller.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common'; -import { Response } from 'express'; -import * as XLSX from 'xlsx'; -import { AppEvents } from 'nocodb-sdk'; -import { GlobalGuard } from '~/guards/global/global.guard'; -import { DatasService } from '~/services/datas.service'; -import { extractCsvData, extractXlsxData } from '~/helpers/dataHelpers'; -import { View } from '~/models'; -import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware'; -import { DataApiLimiterGuard } from '~/guards/data-api-limiter.guard'; -import { TenantContext } from '~/decorators/tenant-context.decorator'; -import { NcContext, NcRequest } from '~/interface/config'; -import { AppHooksService } from '~/services/app-hooks/app-hooks.service'; - -@Controller() -@UseGuards(DataApiLimiterGuard, GlobalGuard) -export class DataAliasExportController { - constructor( - private datasService: DatasService, - private readonly appHooksService: AppHooksService, - ) {} - - @Get([ - '/api/v1/db/data/:orgs/:baseName/:tableName/export/excel', - '/api/v1/db/data/:orgs/:baseName/:tableName/views/:viewName/export/excel', - ]) - @Acl('exportExcel') - async excelDataExport( - @TenantContext() context: NcContext, - @Req() req: NcRequest, - @Res() res: Response, - ) { - const { model, view } = - await this.datasService.getViewAndModelFromRequestByAliasOrId( - context, - req, - ); - let targetView = view; - if (!targetView) { - targetView = await View.getDefaultView(context, model.id); - } - const { offset, elapsed, data } = await extractXlsxData( - context, - targetView, - req, - ); - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, data, targetView.title); - const buf = XLSX.write(wb, { - type: req.query.encoding === 'base64' ? 'base64' : 'buffer', - bookType: 'xlsx', - }); - - this.appHooksService.emit(AppEvents.DATA_EXPORT, { - context, - req, - table: model, - view: targetView, - type: 'excel', - }); - - res.set({ - 'Access-Control-Expose-Headers': 'nc-export-offset', - 'nc-export-offset': offset, - 'nc-export-elapsed-time': elapsed, - 'Content-Disposition': `attachment; filename="${encodeURI( - targetView.title, - )}-export.xlsx"`, - }); - res.end(buf); - } - - @Get([ - '/api/v1/db/data/:orgs/:baseName/:tableName/views/:viewName/export/csv', - '/api/v1/db/data/:orgs/:baseName/:tableName/export/csv', - ]) - @Acl('exportCsv') - async csvDataExport( - @TenantContext() context: NcContext, - @Req() req: NcRequest, - @Res() res: Response, - ) { - const { model, view } = - await this.datasService.getViewAndModelFromRequestByAliasOrId( - context, - req, - ); - let targetView = view; - if (!targetView) { - targetView = await View.getDefaultView(context, model.id); - } - const { offset, elapsed, data } = await extractCsvData( - context, - targetView, - req, - ); - - this.appHooksService.emit(AppEvents.DATA_EXPORT, { - context, - req, - table: model, - view: targetView, - type: 'csv', - }); - - res.set({ - 'Access-Control-Expose-Headers': 'nc-export-offset', - 'nc-export-offset': offset, - 'nc-export-elapsed-time': elapsed, - 'Content-Disposition': `attachment; filename="${encodeURI( - targetView.title, - )}-export.csv"`, - }); - res.send(data); - } -} diff --git a/packages/nocodb/src/controllers/public-datas-export.controller.spec.ts b/packages/nocodb/src/controllers/public-datas-export.controller.spec.ts deleted file mode 100644 index ef682aac2977..000000000000 --- a/packages/nocodb/src/controllers/public-datas-export.controller.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Test } from '@nestjs/testing'; -import { PublicDatasExportService } from '../services/public-datas-export.service'; -import { PublicDatasExportController } from './public-datas-export.controller'; -import type { TestingModule } from '@nestjs/testing'; - -describe('PublicDatasExportController', () => { - let controller: PublicDatasExportController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [PublicDatasExportController], - providers: [PublicDatasExportService], - }).compile(); - - controller = module.get( - PublicDatasExportController, - ); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/packages/nocodb/src/controllers/public-datas-export.controller.ts b/packages/nocodb/src/controllers/public-datas-export.controller.ts deleted file mode 100644 index cbcf275baa46..000000000000 --- a/packages/nocodb/src/controllers/public-datas-export.controller.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { - Controller, - Get, - Param, - Request, - Response, - UseGuards, -} from '@nestjs/common'; -import { isSystemColumn, ViewTypes } from 'nocodb-sdk'; -import * as XLSX from 'xlsx'; -import papaparse from 'papaparse'; -import { fromEntries } from '~/utils'; -import { nocoExecute } from '~/utils'; -import { NcError } from '~/helpers/catchError'; -import getAst from '~/helpers/getAst'; -import { serializeCellValue } from '~/helpers/dataHelpers'; -import { PublicDatasExportService } from '~/services/public-datas-export.service'; -import NcConnectionMgrv2 from '~/utils/common/NcConnectionMgrv2'; -import { Column, Model, Source, View } from '~/models'; -import { PublicApiLimiterGuard } from '~/guards/public-api-limiter.guard'; -import { TenantContext } from '~/decorators/tenant-context.decorator'; -import { NcContext } from '~/interface/config'; - -@UseGuards(PublicApiLimiterGuard) -@Controller() -export class PublicDatasExportController { - constructor( - private readonly publicDatasExportService: PublicDatasExportService, - ) {} - - @Get([ - '/api/v1/db/public/shared-view/:publicDataUuid/rows/export/excel', - '/api/v2/public/shared-view/:publicDataUuid/rows/export/excel', - ]) - async exportExcel( - @TenantContext() context: NcContext, - @Request() req, - @Response() res, - @Param('publicDataUuid') publicDataUuid: string, - ) { - const view = await View.getByUUID(context, publicDataUuid); - if (!view) NcError.viewNotFound(publicDataUuid); - if ( - view.type !== ViewTypes.GRID && - view.type !== ViewTypes.KANBAN && - view.type !== ViewTypes.GALLERY && - view.type !== ViewTypes.CALENDAR && - view.type !== ViewTypes.MAP - ) - NcError.notFound('Not found'); - - if (view.password && view.password !== req.headers?.['xc-password']) { - NcError.invalidSharedViewPassword(); - } - - // check if download is allowed, in general it's called as CSV download - if (!view.meta?.allowCSVDownload) { - NcError.forbidden('Download is not allowed for this view'); - } - - const model = await view.getModelWithInfo(context); - - await view.getColumns(context); - const modelColumnMap = model.columns.reduce((mapObj, cur) => { - mapObj[cur.id] = cur; - return mapObj; - }, {}); - - const { offset, dbRows, elapsed } = await this.getDbRows( - context, - model, - view, - req, - ); - - let fields = req.query.fields as string[]; - const allowedColumns = view.columns - .filter((c) => c.show) - .map((k) => modelColumnMap[k.fk_column_id]) - .filter((column) => !isSystemColumn(column) || view.show_system_fields) - .map((k) => k.title); - if (!fields || fields.length === 0 || !Array.isArray(fields)) { - fields = allowedColumns; - } else { - fields = fields.filter((field) => allowedColumns.includes(field)); - } - - const data = XLSX.utils.json_to_sheet( - dbRows.map((o: Record) => - fromEntries(fields.map((f) => [f, o[f]])), - ), - { header: fields }, - ); - - const wb = XLSX.utils.book_new(); - - XLSX.utils.book_append_sheet(wb, data, view.title); - const buf = XLSX.write(wb, { - type: req.query.encoding === 'base64' ? 'base64' : 'buffer', - bookType: 'xlsx', - }); - - res.set({ - 'Access-Control-Expose-Headers': 'nc-export-offset', - 'nc-export-offset': offset, - 'nc-export-elapsed-time': elapsed, - 'Content-Disposition': `attachment; filename="${encodeURI( - view.title, - )}-export.xlsx"`, - }); - res.end(buf); - } - - @Get([ - '/api/v1/db/public/shared-view/:publicDataUuid/rows/export/csv', - '/api/v2/public/shared-view/:publicDataUuid/rows/export/csv', - ]) - async exportCsv( - @TenantContext() context: NcContext, - @Request() req, - @Response() res, - ) { - const view = await View.getByUUID(context, req.params.publicDataUuid); - const fields = req.query.fields; - - if (!view) NcError.viewNotFound(req.params.publicDataUuid); - if ( - view.type !== ViewTypes.GRID && - view.type !== ViewTypes.KANBAN && - view.type !== ViewTypes.GALLERY && - view.type !== ViewTypes.CALENDAR && - view.type !== ViewTypes.MAP - ) - NcError.notFound('Not found'); - - if (view.password && view.password !== req.headers?.['xc-password']) { - NcError.invalidSharedViewPassword(); - } - - // check if download is allowed - if (!view.meta?.allowCSVDownload) { - NcError.forbidden('Download is not allowed for this view'); - } - - const model = await view.getModelWithInfo(context); - await view.getColumns(context); - - const { offset, dbRows, elapsed } = await this.getDbRows( - context, - model, - view, - req, - ); - - const data = papaparse.unparse( - { - fields: model.columns - .sort((c1, c2) => - Array.isArray(fields) - ? fields.indexOf(c1.title as any) - - fields.indexOf(c2.title as any) - : 0, - ) - .filter( - (c) => - !fields || - !Array.isArray(fields) || - fields.includes(c.title as any), - ) - .map((c) => c.title), - data: dbRows, - }, - { - escapeFormulae: true, - }, - ); - - res.set({ - 'Access-Control-Expose-Headers': 'nc-export-offset', - 'nc-export-offset': offset, - 'nc-export-elapsed-time': elapsed, - 'Content-Disposition': `attachment; filename="${encodeURI( - view.title, - )}-export.csv"`, - }); - res.send(data); - } - - async getDbRows(@TenantContext() context: NcContext, model, view: View, req) { - view.model.columns = view.columns - .filter((c) => c.show) - .map( - (c) => - new Column({ - ...c, - ...view.model.columnsById[c.fk_column_id], - } as any), - ) - .filter((column) => !isSystemColumn(column) || view.show_system_fields); - - if (!model) NcError.notFound('Table not found'); - - const listArgs: any = { ...req.query }; - try { - listArgs.filterArr = JSON.parse(listArgs.filterArrJson); - } catch (e) {} - try { - listArgs.sortArr = JSON.parse(listArgs.sortArrJson); - } catch (e) {} - - const source = await Source.get(context, model.source_id); - const baseModel = await Model.getBaseModelSQL(context, { - id: model.id, - viewId: view?.id, - dbDriver: await NcConnectionMgrv2.get(source), - }); - - const { ast } = await getAst(context, { - query: req.query, - model, - view, - includePkByDefault: false, - }); - - let offset = +req.query.offset || 0; - const limit = 100; - // const size = +process.env.NC_EXPORT_MAX_SIZE || 1024; - const timeout = +process.env.NC_EXPORT_MAX_TIMEOUT || 5000; - const dbRows = []; - const startTime = process.hrtime(); - let elapsed, temp; - - for ( - elapsed = 0; - elapsed < timeout; - offset += limit, - temp = process.hrtime(startTime), - elapsed = temp[0] * 1000 + temp[1] / 1000000 - ) { - const rows = await nocoExecute( - ast, - await baseModel.list({ ...listArgs, offset, limit }), - {}, - listArgs, - ); - - if (!rows?.length) { - offset = -1; - break; - } - - for (const row of rows) { - const dbRow = { ...row }; - - for (const column of view.model.columns) { - dbRow[column.title] = await serializeCellValue(context, { - value: row[column.title], - column, - siteUrl: req.ncSiteUrl, - }); - } - dbRows.push(dbRow); - } - } - return { offset, dbRows, elapsed }; - } -} diff --git a/packages/nocodb/src/interface/Jobs.ts b/packages/nocodb/src/interface/Jobs.ts index 682fd45095f1..1a97e448aa75 100644 --- a/packages/nocodb/src/interface/Jobs.ts +++ b/packages/nocodb/src/interface/Jobs.ts @@ -35,6 +35,7 @@ export enum JobTypes { HandleWebhook = 'handle-webhook', CleanUp = 'clean-up', DataExport = 'data-export', + DataExportCleanUp = 'data-export-clean-up', ThumbnailGenerator = 'thumbnail-generator', AttachmentCleanUp = 'attachment-clean-up', InitMigrationJobs = 'init-migration-jobs', diff --git a/packages/nocodb/src/modules/jobs/fallback/fallback-queue.service.ts b/packages/nocodb/src/modules/jobs/fallback/fallback-queue.service.ts index 6e747d5077c1..582758f30be3 100644 --- a/packages/nocodb/src/modules/jobs/fallback/fallback-queue.service.ts +++ b/packages/nocodb/src/modules/jobs/fallback/fallback-queue.service.ts @@ -1,6 +1,7 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import PQueue from 'p-queue'; import Emittery from 'emittery'; +import { CronExpressionParser } from 'cron-parser'; import { JobsEventService } from '~/modules/jobs/jobs-event.service'; import { JobStatus } from '~/interface/Jobs'; import { JobsMap } from '~/modules/jobs/jobs-map.service'; @@ -10,6 +11,8 @@ export interface Job { name: string; status: string; data: any; + repeat?: { cron: string }; + delay?: number; } @Injectable() @@ -102,7 +105,11 @@ export class QueueService { QueueService.queueIdCounter = index; } - add(name: string, data: any, opts?: { jobId?: string; delay?: number }) { + add( + name: string, + data: any, + opts?: { jobId?: string; delay?: number; repeat?: { cron: string } }, + ) { const id = opts?.jobId || `${this.queueIndex++}`; const existingJob = this.queueMemory.find((q) => q.id === id); @@ -131,6 +138,11 @@ export class QueueService { }; if (existingJob) { + // if job is already in memory and has a repeat, return the job + if (existingJob.repeat) { + return existingJob; + } + if (existingJob.status !== JobStatus.WAITING) { existingJob.status = JobStatus.WAITING; } @@ -141,11 +153,51 @@ export class QueueService { name, status: JobStatus.WAITING, data, + repeat: opts?.repeat, + delay: opts?.delay, ...helperFns(), }; } - if (opts?.delay) { + if (opts?.repeat) { + // Initialize recurring job execution based on cron pattern + const scheduleNextExecution = () => { + try { + const cron = CronExpressionParser.parse(opts.repeat.cron); + const nextExecutionTime = cron.next().toDate(); + const delayMs = Math.max(0, nextExecutionTime.getTime() - Date.now()); + + const recurringJobId = `${id}-${Date.now()}`; + const childJob = { ...job, id: recurringJobId }; + + setTimeout(() => { + this.queueMemory.push(childJob); + // Execute the current job + this.queue + .add(() => this.jobWrapper(childJob)) + .then(() => { + // Schedule the next execution only after current job completes + this.queue.add(() => scheduleNextExecution()); + }) + .catch((error) => { + console.error( + `Failed to schedule recurring job ${name}:`, + error, + ); + // Try to reschedule despite the error + this.queue.add(() => scheduleNextExecution()); + }); + }, delayMs); + } catch (error) { + console.error(`Invalid cron expression for job ${name}:`, error); + } + }; + + this.queueMemory.push(job); + + // Start the recurring job process + scheduleNextExecution(); + } else if (opts?.delay) { if (!existingJob) { this.queueMemory.push(job); } diff --git a/packages/nocodb/src/modules/jobs/fallback/jobs.service.ts b/packages/nocodb/src/modules/jobs/fallback/jobs.service.ts index c419d989bbae..4bccf618fac4 100644 --- a/packages/nocodb/src/modules/jobs/fallback/jobs.service.ts +++ b/packages/nocodb/src/modules/jobs/fallback/jobs.service.ts @@ -16,6 +16,16 @@ export class JobsService implements OnModuleInit { constructor(private readonly fallbackQueueService: QueueService) {} async onModuleInit() { + await this.fallbackQueueService.add( + JobTypes.DataExportCleanUp, + {}, + { + jobId: JobTypes.DataExportCleanUp, + // run every 5 hours + repeat: { cron: '* * * * *' }, + }, + ); + await this.add(JobTypes.InitMigrationJobs, {}); } diff --git a/packages/nocodb/src/modules/jobs/jobs-map.service.ts b/packages/nocodb/src/modules/jobs/jobs-map.service.ts index bf7b92b446a8..28bbefa2cdcd 100644 --- a/packages/nocodb/src/modules/jobs/jobs-map.service.ts +++ b/packages/nocodb/src/modules/jobs/jobs-map.service.ts @@ -10,6 +10,7 @@ import { ThumbnailGeneratorProcessor } from '~/modules/jobs/jobs/thumbnail-gener import { AttachmentCleanUpProcessor } from '~/modules/jobs/jobs/attachment-clean-up/attachment-clean-up'; import { InitMigrationJobs } from '~/modules/jobs/migration-jobs/init-migration-jobs'; import { UseWorkerProcessor } from '~/modules/jobs/jobs/use-worker/use-worker.processor'; +import { DataExportCleanUpProcessor } from '~/modules/jobs/jobs/data-export-clean-up/data-export-clean-up.processor'; import { JobTypes } from '~/interface/Jobs'; @Injectable() @@ -26,6 +27,7 @@ export class JobsMap { protected readonly attachmentCleanUpProcessor: AttachmentCleanUpProcessor, protected readonly initMigrationJobs: InitMigrationJobs, protected readonly useWorkerProcessor: UseWorkerProcessor, + protected readonly dataExportCleanUpProcessor: DataExportCleanUpProcessor, ) {} protected get _jobMap(): { @@ -65,6 +67,9 @@ export class JobsMap { [JobTypes.DataExport]: { this: this.dataExportProcessor, }, + [JobTypes.DataExportCleanUp]: { + this: this.dataExportCleanUpProcessor, + }, [JobTypes.ThumbnailGenerator]: { this: this.thumbnailGeneratorProcessor, }, diff --git a/packages/nocodb/src/modules/jobs/jobs.module.ts b/packages/nocodb/src/modules/jobs/jobs.module.ts index 1b354b3d0b18..53bff8a7f7e1 100644 --- a/packages/nocodb/src/modules/jobs/jobs.module.ts +++ b/packages/nocodb/src/modules/jobs/jobs.module.ts @@ -23,6 +23,8 @@ import { SourceDeleteProcessor } from '~/modules/jobs/jobs/source-delete/source- import { WebhookHandlerProcessor } from '~/modules/jobs/jobs/webhook-handler/webhook-handler.processor'; import { DataExportProcessor } from '~/modules/jobs/jobs/data-export/data-export.processor'; import { DataExportController } from '~/modules/jobs/jobs/data-export/data-export.controller'; +import { DataExportCleanUpProcessor } from '~/modules/jobs/jobs/data-export-clean-up/data-export-clean-up.processor'; +import { PublicDataExportController } from '~/modules/jobs/jobs/data-export/public-data-export.controller'; import { ThumbnailGeneratorProcessor } from '~/modules/jobs/jobs/thumbnail-generator/thumbnail-generator.processor'; import { AttachmentCleanUpProcessor } from '~/modules/jobs/jobs/attachment-clean-up/attachment-clean-up'; import { UseWorkerProcessor } from '~/modules/jobs/jobs/use-worker/use-worker.processor'; @@ -84,6 +86,7 @@ export const JobsModuleMetadata = { SourceCreateController, SourceDeleteController, DataExportController, + PublicDataExportController, ] : []), ], @@ -110,6 +113,7 @@ export const JobsModuleMetadata = { SourceDeleteProcessor, WebhookHandlerProcessor, DataExportProcessor, + DataExportCleanUpProcessor, ThumbnailGeneratorProcessor, AttachmentCleanUpProcessor, UseWorkerProcessor, diff --git a/packages/nocodb/src/modules/jobs/jobs/data-export-clean-up/data-export-clean-up.processor.ts b/packages/nocodb/src/modules/jobs/jobs/data-export-clean-up/data-export-clean-up.processor.ts new file mode 100644 index 000000000000..149ecd6253f6 --- /dev/null +++ b/packages/nocodb/src/modules/jobs/jobs/data-export-clean-up/data-export-clean-up.processor.ts @@ -0,0 +1,110 @@ +import debug from 'debug'; +import PQueue from 'p-queue'; +import { Injectable } from '@nestjs/common'; +import moment from 'moment'; +import type { Job } from 'bull'; +import NcPluginMgrv2 from '~/helpers/NcPluginMgrv2'; + +@Injectable() +export class DataExportCleanUpProcessor { + private readonly debugLog = debug('nc:jobs:data-export-clean-up'); + + async job(job: Job) { + this.debugLog(`Job started for ${job.id}`); + + const queue = new PQueue({ concurrency: 1 }); + + try { + const storageAdapter = await NcPluginMgrv2.storageAdapter(); + const cutoffDate = moment().subtract(4, 'hours'); + + // Convert cutoff date to YYYY-MM-DD format to match folder structure + const cutoffDateFormatted = cutoffDate.format('YYYY-MM-DD'); + + // The pattern will match all files in data-export folders that are older than the cutoff date + const globPattern = `nc/uploads/data-export/**`; + + const fileStream = await storageAdapter.scanFiles(globPattern); + + // Track statistics for logging + let scannedCount = 0; + let deletedCount = 0; + let errorCount = 0; + + return new Promise((resolve, reject) => { + fileStream.on('error', (error) => { + this.debugLog(`Error scanning files: ${error.message}`); + reject(error); + }); + + fileStream.on('data', (filePath) => { + scannedCount++; + + // Add file processing task to the queue + queue + .add(async () => { + try { + // Skip if not a data export file + if ( + !filePath || + typeof filePath !== 'string' || + !filePath.startsWith('nc/uploads/data-export') + ) { + return; + } + + // Extract date from file path (format: nc/uploads/data-export/YYYY-MM-DD/HH/...) + const pathParts = filePath.split('/'); + const dateIndex = pathParts.indexOf('data-export') + 1; + + if (dateIndex >= pathParts.length || !pathParts[dateIndex]) { + return; + } + + const folderDate = pathParts[dateIndex]; + + // Check if folder date is before cutoff date + if (folderDate && folderDate < cutoffDateFormatted) { + await storageAdapter.fileDelete(filePath); + deletedCount++; + this.debugLog(`Deleted old export file: ${filePath}`); + } + } catch (e) { + errorCount++; + this.debugLog( + `Error processing file ${filePath}: ${e.message}`, + ); + // Don't rethrow to prevent queue from stopping + } + }) + .catch((err) => { + errorCount++; + this.debugLog( + `Queue error processing file ${filePath}: ${err.message}`, + ); + }); + }); + + fileStream.on('end', async () => { + try { + // Wait for all queued tasks to complete + await queue.onIdle(); + this.debugLog( + `Data export cleanup completed: Scanned ${scannedCount} files, deleted ${deletedCount} files, encountered ${errorCount} errors`, + ); + this.debugLog(`Job completed for ${job.id}`); + resolve(true); + } catch (err) { + this.debugLog( + `Error waiting for queue to complete: ${err.message}`, + ); + reject(err); + } + }); + }); + } catch (e) { + this.debugLog(`Job failed: ${e.message}`); + throw e; + } + } +} diff --git a/packages/nocodb/src/modules/jobs/jobs/data-export/public-data-export.controller.ts b/packages/nocodb/src/modules/jobs/jobs/data-export/public-data-export.controller.ts new file mode 100644 index 000000000000..0ee53be584c7 --- /dev/null +++ b/packages/nocodb/src/modules/jobs/jobs/data-export/public-data-export.controller.ts @@ -0,0 +1,76 @@ +import { + Body, + Controller, + HttpCode, + Inject, + Param, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { ViewTypes } from 'nocodb-sdk'; +import type { DataExportJobData } from '~/interface/Jobs'; +import { BasesService } from '~/services/bases.service'; +import { View } from '~/models'; +import { JobTypes } from '~/interface/Jobs'; +import { IJobsService } from '~/modules/jobs/jobs-service.interface'; +import { TenantContext } from '~/decorators/tenant-context.decorator'; +import { NcContext, NcRequest } from '~/interface/config'; +import { NcError } from '~/helpers/catchError'; +import { PublicApiLimiterGuard } from '~/guards/public-api-limiter.guard'; + +@Controller() +@UseGuards(PublicApiLimiterGuard) +export class PublicDataExportController { + constructor( + @Inject('JobsService') protected readonly jobsService: IJobsService, + protected readonly basesService: BasesService, + ) {} + + @Post(['/api/v2/public/export/:publicDataUuid/:exportAs']) + @HttpCode(200) + async exportModelData( + @TenantContext() context: NcContext, + @Req() req: NcRequest, + @Param('publicDataUuid') publicDataUuid: string, + @Param('exportAs') exportAs: 'csv' | 'json' | 'xlsx', + @Body() options: DataExportJobData['options'], + ) { + const view = await View.getByUUID(context, publicDataUuid); + + if (!view) NcError.viewNotFound(publicDataUuid); + if ( + view.type !== ViewTypes.GRID && + view.type !== ViewTypes.KANBAN && + view.type !== ViewTypes.GALLERY && + view.type !== ViewTypes.CALENDAR && + view.type !== ViewTypes.MAP + ) + NcError.notFound('Not found'); + + if (view.password && view.password !== req.headers?.['xc-password']) { + NcError.invalidSharedViewPassword(); + } + + // check if download is allowed + if (!view.meta?.allowCSVDownload) { + NcError.forbidden('Download is not allowed for this view'); + } + + if (!view) NcError.viewNotFound(publicDataUuid); + + const job = await this.jobsService.add(JobTypes.DataExport, { + context, + options, + modelId: view.fk_model_id, + viewId: view.id, + user: req.user, + exportAs, + ncSiteUrl: req.ncSiteUrl, + }); + + return { + id: job.id, + }; + } +} diff --git a/packages/nocodb/src/modules/jobs/redis/jobs.service.ts b/packages/nocodb/src/modules/jobs/redis/jobs.service.ts index 6c889454f165..6b17f2c7062e 100644 --- a/packages/nocodb/src/modules/jobs/redis/jobs.service.ts +++ b/packages/nocodb/src/modules/jobs/redis/jobs.service.ts @@ -28,6 +28,18 @@ export class JobsService implements OnModuleInit { await this.jobsQueue.pause(true); } + await this.jobsQueue.add( + { + jobName: JobTypes.DataExportCleanUp, + context: {}, + }, + { + jobId: JobTypes.DataExportCleanUp, + // run every 5 hours + repeat: { cron: '0 */5 * * *' }, + }, + ); + await this.toggleQueue(); JobsRedis.workerCallbacks[InstanceCommands.RESUME_LOCAL] = async () => { diff --git a/packages/nocodb/src/modules/noco.module.ts b/packages/nocodb/src/modules/noco.module.ts index 6242fc769ac7..6ad3c8502753 100644 --- a/packages/nocodb/src/modules/noco.module.ts +++ b/packages/nocodb/src/modules/noco.module.ts @@ -116,7 +116,6 @@ import { InternalController } from '~/controllers/internal.controller'; /* Datas */ import { BulkDataAliasController } from '~/controllers/bulk-data-alias.controller'; import { CalendarDatasController } from '~/controllers/calendars-datas.controller'; -import { DataAliasExportController } from '~/controllers/data-alias-export.controller'; import { DataAliasNestedController } from '~/controllers/data-alias-nested.controller'; import { DataAliasController } from '~/controllers/data-alias.controller'; import { DataTableController } from '~/controllers/data-table.controller'; @@ -124,7 +123,6 @@ import { DatasController } from '~/controllers/datas.controller'; import { IntegrationsController } from '~/controllers/integrations.controller'; import { OldDatasController } from '~/controllers/old-datas/old-datas.controller'; import { OldDatasService } from '~/controllers/old-datas/old-datas.service'; -import { PublicDatasExportController } from '~/controllers/public-datas-export.controller'; import { PublicDatasController } from '~/controllers/public-datas.controller'; import { BaseUsersV3Controller } from '~/controllers/v3/base-users-v3.controller'; import { BasesV3Controller } from '~/controllers/v3/bases-v3.controller'; @@ -241,10 +239,8 @@ export const nocoModuleMetadata = { BulkDataAliasController, DataAliasController, DataAliasNestedController, - DataAliasExportController, OldDatasController, PublicDatasController, - PublicDatasExportController, Datav3Controller, ] : []), diff --git a/packages/nocodb/src/schema/swagger-v2.json b/packages/nocodb/src/schema/swagger-v2.json index 02c9204329a4..2d7f46adc843 100644 --- a/packages/nocodb/src/schema/swagger-v2.json +++ b/packages/nocodb/src/schema/swagger-v2.json @@ -9104,64 +9104,6 @@ "description": "List all nested list data in a given shared view" } }, - "/api/v2/public/shared-view/{sharedViewUuid}/rows/export/{type}": { - "parameters": [ - { - "schema": { - "type": "string", - "example": "76d44b86-bc65-4500-8956-ab512c80ab25" - }, - "name": "sharedViewUuid", - "in": "path", - "required": true, - "description": "Shared View UUID" - }, - { - "schema": { - "type": "string", - "enum": [ - "csv", - "excel" - ], - "example": "csv" - }, - "name": "type", - "in": "path", - "required": true, - "description": "Export Type" - } - ], - "get": { - "summary": "Export Rows in Share View", - "operationId": "public-csv-export", - "description": "Export all rows in Share View in a CSV / Excel Format", - "wrapped": true, - "responses": { - "200": { - "description": "OK", - "content": { - "application/octet-stream": { - "schema": {} - } - }, - "headers": { - "nc-export-offset": { - "schema": { - "type": "integer" - }, - "description": "The starting offset of the next set of rows" - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - } - }, - "tags": [ - "Public", "Internal" - ] - } - }, "/api/v2/public/shared-view/{sharedViewUuid}/nested/{columnName}": { "parameters": [ { @@ -12926,6 +12868,50 @@ "$ref": "#/components/parameters/xc-token" } ] + }, + "/api/v2/public/export/{publicDataUuid}/{exportAs}": { + "post": { + "summary": "Trigger export as job", + "operationId": "public-export-data", + "description": "Trigger export as job", + "tags": [ + "Public" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "parameters": [ + { + "schema": { + "$ref": "#/components/schemas/Id", + "example": "vw124dflkcvasewh", + "type": "string" + }, + "name": "publicDataUuid", + "in": "path", + "required": true, + "description": "Unique View ID" + }, + { + "schema": { + "type": "string", + "enum": [ + "csv" + ] + }, + "name": "exportAs", + "in": "path", + "required": true, + "description": "Export as format" + } + ] } }, "components": { diff --git a/packages/nocodb/src/schema/swagger.json b/packages/nocodb/src/schema/swagger.json index 82f018eacda7..0764b6475c7a 100644 --- a/packages/nocodb/src/schema/swagger.json +++ b/packages/nocodb/src/schema/swagger.json @@ -12411,170 +12411,6 @@ ] } }, - "/api/v1/db/data/{orgs}/{baseName}/{tableName}/views/{viewName}/export/{type}": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "name": "orgs", - "in": "path", - "required": true, - "description": "Organisation Name. Currently `noco` will be used." - }, - { - "schema": { - "type": "string" - }, - "name": "baseName", - "in": "path", - "required": true, - "description": "Base Name" - }, - { - "schema": { - "type": "string" - }, - "name": "tableName", - "in": "path", - "required": true, - "description": "Table Name" - }, - { - "schema": { - "type": "string" - }, - "name": "viewName", - "in": "path", - "required": true - }, - { - "schema": { - "type": "string", - "enum": [ - "csv", - "excel" - ] - }, - "name": "type", - "in": "path", - "required": true - } - ], - "get": { - "summary": "Export Table View Rows", - "operationId": "db-view-row-export", - "description": "Export Table View Rows by CSV or Excel", - "tags": [ - "DB View Row" - ], - "wrapped": true, - "responses": { - "200": { - "description": "OK", - "content": { - "application/octet-stream": { - "schema": {} - } - }, - "headers": { - "nc-export-offset": { - "schema": { - "type": "integer" - }, - "description": "The starting offset of the next set of rows" - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - } - }, - "parameters": [ - { - "$ref": "#/components/parameters/xc-auth" - } - ] - } - }, - "/api/v1/db/data/{orgs}/{baseName}/{tableName}/export/{type}": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "name": "orgs", - "in": "path", - "required": true, - "description": "Organisation Name. Currently `noco` will be used." - }, - { - "schema": { - "type": "string" - }, - "name": "baseName", - "in": "path", - "required": true, - "description": "Base Name" - }, - { - "schema": { - "type": "string" - }, - "name": "tableName", - "in": "path", - "required": true, - "description": "Table Name" - }, - { - "schema": { - "type": "string", - "enum": [ - "csv", - "excel" - ] - }, - "name": "type", - "in": "path", - "required": true - } - ], - "get": { - "summary": "Export Table View Rows", - "operationId": "db-table-row-csv-export", - "description": "Export Table View Rows by CSV or Excel", - "tags": [ - "DB Table Row" - ], - "wrapped": true, - "responses": { - "200": { - "description": "OK", - "content": { - "application/octet-stream": { - "schema": {} - } - }, - "headers": { - "nc-export-offset": { - "schema": { - "type": "integer" - }, - "description": "The starting offset of the next set of rows" - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - } - }, - "parameters": [ - { - "$ref": "#/components/parameters/xc-auth" - } - ] - } - }, "/api/v1/db/data/{orgs}/{baseName}/{tableName}/{rowId}/{relationType}/{columnName}": { "parameters": [ { @@ -14301,64 +14137,6 @@ "description": "List all nested list data in a given shared view" } }, - "/api/v1/db/public/shared-view/{sharedViewUuid}/rows/export/{type}": { - "parameters": [ - { - "schema": { - "type": "string", - "example": "76d44b86-bc65-4500-8956-ab512c80ab25" - }, - "name": "sharedViewUuid", - "in": "path", - "required": true, - "description": "Shared View UUID" - }, - { - "schema": { - "type": "string", - "enum": [ - "csv", - "excel" - ], - "example": "csv" - }, - "name": "type", - "in": "path", - "required": true, - "description": "Export Type" - } - ], - "get": { - "summary": "Export Rows in Share View", - "operationId": "public-csv-export", - "description": "Export all rows in Share View in a CSV / Excel Format", - "wrapped": true, - "responses": { - "200": { - "description": "OK", - "content": { - "application/octet-stream": { - "schema": {} - } - }, - "headers": { - "nc-export-offset": { - "schema": { - "type": "integer" - }, - "description": "The starting offset of the next set of rows" - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - } - }, - "tags": [ - "Public" - ] - } - }, "/api/v1/db/public/shared-view/{sharedViewUuid}/nested/{columnName}": { "parameters": [ { @@ -19419,6 +19197,50 @@ } ] }, + "/api/v2/public/export/{publicDataUuid}/{exportAs}": { + "post": { + "summary": "Trigger export as job", + "operationId": "public-export-data", + "description": "Trigger export as job", + "tags": [ + "Public" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "parameters": [ + { + "schema": { + "$ref": "#/components/schemas/Id", + "example": "vw124dflkcvasewh", + "type": "string" + }, + "name": "publicDataUuid", + "in": "path", + "required": true, + "description": "Unique View ID" + }, + { + "schema": { + "type": "string", + "enum": [ + "csv" + ] + }, + "name": "exportAs", + "in": "path", + "required": true, + "description": "Export as format" + } + ] + }, "/api/v2/meta/hooks/{hookId}/trigger/{rowId}": { "parameters": [ { diff --git a/packages/nocodb/src/services/api-docs/swagger/templates/paths.ts b/packages/nocodb/src/services/api-docs/swagger/templates/paths.ts index a52d8807aeb9..b2b1e0a67303 100644 --- a/packages/nocodb/src/services/api-docs/swagger/templates/paths.ts +++ b/packages/nocodb/src/services/api-docs/swagger/templates/paths.ts @@ -450,29 +450,6 @@ export const getModelPaths = async ( : {}), } : {}), - [`/api/v1/db/data/${ctx.orgs}/${ctx.baseName}/${ctx.tableName}/export/{type}`]: - { - parameters: [exportTypeParam], - get: { - summary: 'Rows export', - operationId: `${ctx.tableName.toLowerCase()}-csv-export`, - description: - 'Export all the records from a table.Currently we are only supports `csv` export.', - tags: [ctx.tableName], - responses: { - '200': { - description: 'OK', - content: { - 'application/octet-stream': { - schema: {}, - }, - }, - headers: csvExportResponseHeader, - }, - }, - parameters: [csvExportOffsetParam], - }, - }, }); export const getViewPaths = async ( @@ -633,29 +610,6 @@ export const getViewPaths = async ( }, } : {}), - [`/api/v1/db/data/${ctx.orgs}/${ctx.baseName}/${ctx.tableName}/views/${ctx.viewName}/export/{type}`]: - { - parameters: [exportTypeParam], - get: { - summary: `${ctx.viewName} export`, - operationId: `${ctx.tableName}-${ctx.viewName}-row-export`, - description: - 'Export all the records from a table view. Currently we are only supports `csv` export.', - tags: [`${ctx.viewName} ( ${ctx.tableName} grid )`], - responses: { - '200': { - description: 'OK', - content: { - 'application/octet-stream': { - schema: {}, - }, - }, - headers: csvExportResponseHeader, - }, - }, - parameters: [], - }, - }, }); function getPaginatedResponseType(type: string) {