From 7be686e6415a86da0ac1b870cfc6c22af2949853 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 15:13:08 +0100 Subject: [PATCH 01/29] feat: Umami importer --- backend/apps/cloud/src/app.module.ts | 11 + .../src/data-import/data-import.controller.ts | 205 +++++++ .../src/data-import/data-import.module.ts | 21 + .../src/data-import/data-import.processor.ts | 153 +++++ .../src/data-import/data-import.service.ts | 184 ++++++ .../apps/cloud/src/data-import/dto/index.ts | 1 + .../src/data-import/dto/upload-import.dto.ts | 15 + .../data-import/entity/data-import.entity.ts | 69 +++ .../cloud/src/data-import/mappers/index.ts | 12 + .../data-import/mappers/mapper.interface.ts | 15 + .../src/data-import/mappers/umami.mapper.ts | 263 +++++++++ backend/apps/community/src/app.module.ts | 11 + .../src/data-import/data-import.controller.ts | 196 +++++++ .../src/data-import/data-import.module.ts | 18 + .../src/data-import/data-import.processor.ts | 161 +++++ .../src/data-import/data-import.service.ts | 269 +++++++++ .../community/src/data-import/dto/index.ts | 1 + .../src/data-import/dto/upload-import.dto.ts | 15 + .../data-import/entity/data-import.entity.ts | 24 + .../src/data-import/mappers/index.ts | 12 + .../data-import/mappers/mapper.interface.ts | 15 + .../src/data-import/mappers/umami.mapper.ts | 263 +++++++++ .../clickhouse/2026_03_31_import_id.js | 8 + .../clickhouse/initialise_database.js | 2 + .../clickhouse/initialise_selfhosted.js | 19 + .../selfhosted_2026_03_31_data_import.js | 24 + .../mysql/2026_03_31_data_import.sql | 19 + backend/package-lock.json | 221 ++++++- backend/package.json | 4 + docs/content/docs/data-import.mdx | 116 ++++ docs/content/docs/meta.json | 1 + web/app/api/api.server.ts | 16 +- web/app/lib/models/Project.ts | 15 + .../Project/Settings/ProjectSettings.tsx | 22 + .../Settings/components/DataImportTab.tsx | 548 ++++++++++++++++++ web/app/pages/Project/View/ViewProject.tsx | 68 +++ .../Project/tabs/Traffic/TrafficView.tsx | 14 +- web/app/routes/api.data-import.ts | 66 +++ web/app/ui/icons/Umami.tsx | 20 + web/public/locales/en.json | 38 +- 40 files changed, 3142 insertions(+), 13 deletions(-) create mode 100644 backend/apps/cloud/src/data-import/data-import.controller.ts create mode 100644 backend/apps/cloud/src/data-import/data-import.module.ts create mode 100644 backend/apps/cloud/src/data-import/data-import.processor.ts create mode 100644 backend/apps/cloud/src/data-import/data-import.service.ts create mode 100644 backend/apps/cloud/src/data-import/dto/index.ts create mode 100644 backend/apps/cloud/src/data-import/dto/upload-import.dto.ts create mode 100644 backend/apps/cloud/src/data-import/entity/data-import.entity.ts create mode 100644 backend/apps/cloud/src/data-import/mappers/index.ts create mode 100644 backend/apps/cloud/src/data-import/mappers/mapper.interface.ts create mode 100644 backend/apps/cloud/src/data-import/mappers/umami.mapper.ts create mode 100644 backend/apps/community/src/data-import/data-import.controller.ts create mode 100644 backend/apps/community/src/data-import/data-import.module.ts create mode 100644 backend/apps/community/src/data-import/data-import.processor.ts create mode 100644 backend/apps/community/src/data-import/data-import.service.ts create mode 100644 backend/apps/community/src/data-import/dto/index.ts create mode 100644 backend/apps/community/src/data-import/dto/upload-import.dto.ts create mode 100644 backend/apps/community/src/data-import/entity/data-import.entity.ts create mode 100644 backend/apps/community/src/data-import/mappers/index.ts create mode 100644 backend/apps/community/src/data-import/mappers/mapper.interface.ts create mode 100644 backend/apps/community/src/data-import/mappers/umami.mapper.ts create mode 100644 backend/migrations/clickhouse/2026_03_31_import_id.js create mode 100644 backend/migrations/clickhouse/selfhosted_2026_03_31_data_import.js create mode 100644 backend/migrations/mysql/2026_03_31_data_import.sql create mode 100644 docs/content/docs/data-import.mdx create mode 100644 web/app/pages/Project/Settings/components/DataImportTab.tsx create mode 100644 web/app/routes/api.data-import.ts create mode 100644 web/app/ui/icons/Umami.tsx diff --git a/backend/apps/cloud/src/app.module.ts b/backend/apps/cloud/src/app.module.ts index 6068b9f0d..fa8ac2128 100644 --- a/backend/apps/cloud/src/app.module.ts +++ b/backend/apps/cloud/src/app.module.ts @@ -32,8 +32,10 @@ import { AppController } from './app.controller' import { isPrimaryNode, isPrimaryClusterNode } from './common/utils' import { OrganisationModule } from './organisation/organisation.module' import { RevenueModule } from './revenue/revenue.module' +import { BullModule } from '@nestjs/bullmq' import { ToolsModule } from './tools/tools.module' import { PendingInvitationModule } from './pending-invitation/pending-invitation.module' +import { DataImportModule } from './data-import/data-import.module' const modules = [ SentryModule.forRoot(), @@ -71,6 +73,14 @@ const modules = [ I18nModule.forRootAsync(getI18nConfig()), ScheduleModule.forRoot(), NestjsFormDataModule.config({ isGlobal: true }), + BullModule.forRoot({ + connection: { + host: process.env.REDIS_HOST, + port: Number(process.env.REDIS_PORT), + password: process.env.REDIS_PASSWORD, + username: process.env.REDIS_USER, + }, + }), BlogModule, UserModule, MailerModule, @@ -92,6 +102,7 @@ const modules = [ RevenueModule, ToolsModule, PendingInvitationModule, + DataImportModule, ] @Module({ diff --git a/backend/apps/cloud/src/data-import/data-import.controller.ts b/backend/apps/cloud/src/data-import/data-import.controller.ts new file mode 100644 index 000000000..b1179d52b --- /dev/null +++ b/backend/apps/cloud/src/data-import/data-import.controller.ts @@ -0,0 +1,205 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { + Controller, + Post, + Get, + Delete, + Param, + Body, + UseGuards, + UseInterceptors, + UploadedFile, + BadRequestException, + Query, + Logger, +} from '@nestjs/common' +import { FileInterceptor } from '@nestjs/platform-express' +import { ApiBearerAuth, ApiTags, ApiConsumes, ApiBody } from '@nestjs/swagger' +import { InjectQueue } from '@nestjs/bullmq' +import { Queue } from 'bullmq' + +import { Auth } from '../auth/decorators' +import { CurrentUserId } from '../auth/decorators/current-user-id.decorator' +import { AuthenticationGuard } from '../auth/guards/authentication.guard' +import { ProjectService } from '../project/project.service' +import { DataImportService } from './data-import.service' +import { UploadImportDto } from './dto' +import { getMapper, SUPPORTED_PROVIDERS } from './mappers' +import { DataImportJobData } from './data-import.processor' + +const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100 MB + +@ApiTags('Data Import') +@UseGuards(AuthenticationGuard) +@Controller('data-import') +export class DataImportController { + private readonly logger = new Logger(DataImportController.name) + + constructor( + private readonly dataImportService: DataImportService, + private readonly projectService: ProjectService, + @InjectQueue('data-import') + private readonly importQueue: Queue, + ) {} + + @Post(':projectId/upload') + @Auth() + @ApiConsumes('multipart/form-data') + @ApiBearerAuth() + @ApiBody({ + schema: { + type: 'object', + properties: { + provider: { type: 'string', enum: [...SUPPORTED_PROVIDERS] }, + file: { type: 'string', format: 'binary' }, + }, + }, + }) + @UseInterceptors( + FileInterceptor('file', { + limits: { fileSize: MAX_FILE_SIZE }, + }), + ) + async upload( + @Param('projectId') projectId: string, + @CurrentUserId() uid: string, + @UploadedFile() file: Express.Multer.File, + @Body() dto: UploadImportDto, + ) { + const project = await this.projectService.getFullProject(projectId) + this.projectService.allowedToManage(project, uid) + + if (!file || !file.buffer) { + throw new BadRequestException('No file uploaded') + } + + const mapper = getMapper(dto.provider) + if (!mapper) { + throw new BadRequestException( + `Unsupported provider: ${dto.provider}. Supported: ${SUPPORTED_PROVIDERS.join(', ')}`, + ) + } + + const ext = path.extname(file.originalname).toLowerCase() + if (ext !== mapper.expectedFileExtension) { + throw new BadRequestException( + `Invalid file type. Expected a ${mapper.expectedFileExtension} file for ${dto.provider} import.`, + ) + } + + const tempDir = path.join(os.tmpdir(), 'swetrix-imports') + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }) + } + const tempFile = path.join(tempDir, `${projectId}-${Date.now()}${ext}`) + fs.writeFileSync(tempFile, file.buffer) + + const dataImport = await this.dataImportService.create( + projectId, + dto.provider, + ) + + try { + await this.importQueue.add( + 'process-import', + { + id: dataImport.id, + importId: dataImport.importId, + projectId, + provider: dto.provider, + filePath: tempFile, + }, + { + attempts: 1, + removeOnComplete: true, + removeOnFail: false, + }, + ) + } catch (error) { + this.logger.error(`Failed to enqueue import job: ${error.message}`) + await this.dataImportService.deleteImportRecord(dataImport.id) + this.cleanupFile(tempFile) + throw new BadRequestException( + 'Failed to start import. Please try again later.', + ) + } + + return dataImport + } + + @Get(':projectId') + @Auth() + @ApiBearerAuth() + async list( + @Param('projectId') projectId: string, + @CurrentUserId() uid: string, + ) { + const project = await this.projectService.getFullProject(projectId) + this.projectService.allowedToManage(project, uid) + return this.dataImportService.findAllByProject(projectId) + } + + @Get(':projectId/has-imported-data') + @Auth() + @ApiBearerAuth() + async hasImportedData( + @Param('projectId') projectId: string, + @CurrentUserId() uid: string, + @Query('from') from: string, + @Query('to') to: string, + ) { + const project = await this.projectService.getFullProject(projectId) + this.projectService.allowedToView(project, uid) + + if (!from || !to) { + throw new BadRequestException('"from" and "to" query params are required') + } + + const hasImported = await this.dataImportService.hasImportedDataInRange( + projectId, + from, + to, + ) + + return { hasImportedData: hasImported } + } + + @Get(':projectId/:importId') + @Auth() + @ApiBearerAuth() + async getOne( + @Param('projectId') projectId: string, + @Param('importId') importId: number, + @CurrentUserId() uid: string, + ) { + const project = await this.projectService.getFullProject(projectId) + this.projectService.allowedToManage(project, uid) + return this.dataImportService.findOne(projectId, importId) + } + + @Delete(':projectId/:importId') + @Auth() + @ApiBearerAuth() + async deleteImport( + @Param('projectId') projectId: string, + @Param('importId') importId: number, + @CurrentUserId() uid: string, + ) { + const project = await this.projectService.getFullProject(projectId) + this.projectService.allowedToManage(project, uid) + await this.dataImportService.deleteImport(projectId, importId) + return { success: true } + } + + private cleanupFile(filePath: string): void { + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath) + } + } catch { + // + } + } +} diff --git a/backend/apps/cloud/src/data-import/data-import.module.ts b/backend/apps/cloud/src/data-import/data-import.module.ts new file mode 100644 index 000000000..67e037f96 --- /dev/null +++ b/backend/apps/cloud/src/data-import/data-import.module.ts @@ -0,0 +1,21 @@ +import { Module, forwardRef } from '@nestjs/common' +import { TypeOrmModule } from '@nestjs/typeorm' +import { BullModule } from '@nestjs/bullmq' + +import { ProjectModule } from '../project/project.module' +import { DataImport } from './entity/data-import.entity' +import { DataImportService } from './data-import.service' +import { DataImportController } from './data-import.controller' +import { DataImportProcessor } from './data-import.processor' + +@Module({ + imports: [ + TypeOrmModule.forFeature([DataImport]), + BullModule.registerQueue({ name: 'data-import' }), + forwardRef(() => ProjectModule), + ], + providers: [DataImportService, DataImportProcessor], + controllers: [DataImportController], + exports: [DataImportService], +}) +export class DataImportModule {} diff --git a/backend/apps/cloud/src/data-import/data-import.processor.ts b/backend/apps/cloud/src/data-import/data-import.processor.ts new file mode 100644 index 000000000..8555dfbe5 --- /dev/null +++ b/backend/apps/cloud/src/data-import/data-import.processor.ts @@ -0,0 +1,153 @@ +import * as fs from 'fs' +import { Processor, WorkerHost } from '@nestjs/bullmq' +import { Logger } from '@nestjs/common' +import { Job } from 'bullmq' + +import { DataImportService } from './data-import.service' +import { getMapper } from './mappers' +import { clickhouse } from '../common/integrations/clickhouse' + +const CLICKHOUSE_DB = process.env.CLICKHOUSE_DATABASE || 'analytics' +const BATCH_SIZE = 5000 + +export interface DataImportJobData { + id: number + importId: number + projectId: string + provider: string + filePath: string +} + +@Processor('data-import', { concurrency: 1 }) +export class DataImportProcessor extends WorkerHost { + private readonly logger = new Logger(DataImportProcessor.name) + + constructor(private readonly dataImportService: DataImportService) { + super() + } + + async process(job: Job): Promise { + const { id, importId, projectId, provider, filePath } = job.data + + this.logger.log( + `Processing import ${id} (importId=${importId}) for project ${projectId} (provider: ${provider})`, + ) + + await this.dataImportService.markProcessing(id) + + const mapper = getMapper(provider) + if (!mapper) { + await this.dataImportService.markFailed( + id, + `Unsupported provider: ${provider}`, + ) + return + } + + let importedRows = 0 + let totalRows = 0 + let minDate: string | null = null + let maxDate: string | null = null + + const analyticsBatch: Record[] = [] + const customEVBatch: Record[] = [] + + try { + for await (const row of mapper.createRowStream( + filePath, + projectId, + importId, + )) { + totalRows++ + + const created = row.data.created as string + if (created) { + if (!minDate || created < minDate) minDate = created + if (!maxDate || created > maxDate) maxDate = created + } + + if (row.table === 'analytics') { + analyticsBatch.push(row.data) + } else { + customEVBatch.push(row.data) + } + + if (analyticsBatch.length >= BATCH_SIZE) { + await this.flushBatch('analytics', analyticsBatch) + importedRows += analyticsBatch.length + analyticsBatch.length = 0 + } + + if (customEVBatch.length >= BATCH_SIZE) { + await this.flushBatch('customEV', customEVBatch) + importedRows += customEVBatch.length + customEVBatch.length = 0 + } + + if (totalRows % 10000 === 0) { + await job.updateProgress({ + importedRows: + importedRows + analyticsBatch.length + customEVBatch.length, + totalRows, + }) + } + } + + if (analyticsBatch.length > 0) { + await this.flushBatch('analytics', analyticsBatch) + importedRows += analyticsBatch.length + } + + if (customEVBatch.length > 0) { + await this.flushBatch('customEV', customEVBatch) + importedRows += customEVBatch.length + } + + await this.dataImportService.markCompleted(id, { + importedRows, + invalidRows: 0, + totalRows: importedRows, + dateFrom: minDate ? new Date(minDate) : null, + dateTo: maxDate ? new Date(maxDate) : null, + }) + + this.logger.log(`Import ${id} completed: ${importedRows} rows imported`) + } catch (error) { + this.logger.error(`Import ${id} failed: ${error.message}`, error.stack) + + // Rollback any partially inserted rows + try { + await this.dataImportService.cleanupImportedRows(projectId, importId) + } catch (cleanupError) { + this.logger.error( + `Failed to rollback partial import ${id}: ${cleanupError.message}`, + ) + } + + await this.dataImportService.markFailed(id, error.message) + } finally { + this.cleanupFile(filePath) + } + } + + private async flushBatch( + table: 'analytics' | 'customEV', + batch: Record[], + ): Promise { + await clickhouse.insert({ + table: `${CLICKHOUSE_DB}.${table}`, + values: batch, + format: 'JSONEachRow', + }) + } + + private cleanupFile(filePath: string): void { + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath) + } + } catch { + this.logger.warn(`Failed to clean up temp file: ${filePath}`) + } + } +} diff --git a/backend/apps/cloud/src/data-import/data-import.service.ts b/backend/apps/cloud/src/data-import/data-import.service.ts new file mode 100644 index 000000000..d6721bed8 --- /dev/null +++ b/backend/apps/cloud/src/data-import/data-import.service.ts @@ -0,0 +1,184 @@ +import { + Injectable, + BadRequestException, + NotFoundException, + Logger, +} from '@nestjs/common' +import { InjectRepository } from '@nestjs/typeorm' +import { Repository } from 'typeorm' + +import { + DataImport, + DataImportStatus, + MAX_IMPORT_ID, +} from './entity/data-import.entity' +import { clickhouse } from '../common/integrations/clickhouse' + +const CLICKHOUSE_DB = process.env.CLICKHOUSE_DATABASE || 'analytics' + +@Injectable() +export class DataImportService { + private readonly logger = new Logger(DataImportService.name) + + constructor( + @InjectRepository(DataImport) + private readonly dataImportRepository: Repository, + ) {} + + private async getNextImportId(projectId: string): Promise { + const result = await this.dataImportRepository + .createQueryBuilder('di') + .select('MAX(di.importId)', 'maxId') + .where('di.projectId = :projectId', { projectId }) + .getRawOne<{ maxId: number | null }>() + + const next = (result?.maxId ?? 0) + 1 + + if (next > MAX_IMPORT_ID) { + throw new BadRequestException( + `This project has reached the maximum number of imports (${MAX_IMPORT_ID}). Please delete an old import before creating a new one.`, + ) + } + + return next + } + + async create(projectId: string, provider: string): Promise { + const active = await this.dataImportRepository.findOne({ + where: [ + { projectId, status: DataImportStatus.PENDING }, + { projectId, status: DataImportStatus.PROCESSING }, + ], + }) + + if (active) { + throw new BadRequestException( + 'An import is already in progress for this project. Please wait for it to complete before starting a new one.', + ) + } + + const importId = await this.getNextImportId(projectId) + + const dataImport = this.dataImportRepository.create({ + projectId, + importId, + provider, + status: DataImportStatus.PENDING, + }) + + return this.dataImportRepository.save(dataImport) + } + + async findAllByProject(projectId: string): Promise { + return this.dataImportRepository.find({ + where: { projectId }, + order: { createdAt: 'DESC' }, + }) + } + + async findOne(projectId: string, id: number): Promise { + const dataImport = await this.dataImportRepository.findOne({ + where: { id, projectId }, + }) + + if (!dataImport) { + throw new NotFoundException('Import not found') + } + + return dataImport + } + + async updateStatus( + id: number, + status: DataImportStatus, + extra?: Partial, + ): Promise { + await this.dataImportRepository.update(id, { status, ...extra }) + } + + async markProcessing(id: number): Promise { + await this.updateStatus(id, DataImportStatus.PROCESSING) + } + + async markCompleted( + id: number, + stats: { + importedRows: number + invalidRows: number + totalRows: number + dateFrom: Date | null + dateTo: Date | null + }, + ): Promise { + await this.updateStatus(id, DataImportStatus.COMPLETED, { + ...stats, + finishedAt: new Date(), + }) + } + + async markFailed(id: number, errorMessage: string): Promise { + await this.updateStatus(id, DataImportStatus.FAILED, { + errorMessage, + finishedAt: new Date(), + }) + } + + async cleanupImportedRows( + projectId: string, + importId: number, + ): Promise { + await clickhouse.command({ + query: `ALTER TABLE ${CLICKHOUSE_DB}.analytics DELETE WHERE pid = {pid:FixedString(12)} AND importID = {importID:UInt8}`, + query_params: { pid: projectId, importID: importId }, + }) + await clickhouse.command({ + query: `ALTER TABLE ${CLICKHOUSE_DB}.customEV DELETE WHERE pid = {pid:FixedString(12)} AND importID = {importID:UInt8}`, + query_params: { pid: projectId, importID: importId }, + }) + } + + async deleteImport(projectId: string, id: number): Promise { + const dataImport = await this.findOne(projectId, id) + + if ( + dataImport.status === DataImportStatus.PENDING || + dataImport.status === DataImportStatus.PROCESSING + ) { + throw new BadRequestException( + 'Cannot delete an import that is still in progress.', + ) + } + + try { + await this.cleanupImportedRows(projectId, dataImport.importId) + } catch (error) { + this.logger.error( + `Failed to delete imported data from ClickHouse for import ${dataImport.importId}: ${error.message}`, + ) + throw new BadRequestException( + 'Failed to remove imported data. Please try again later.', + ) + } + + await this.dataImportRepository.delete(id) + } + + async deleteImportRecord(id: number): Promise { + await this.dataImportRepository.delete(id) + } + + async hasImportedDataInRange( + projectId: string, + from: string, + to: string, + ): Promise { + const result = await clickhouse + .query({ + query: `SELECT count() as cnt FROM ${CLICKHOUSE_DB}.analytics WHERE pid = {pid:FixedString(12)} AND importID IS NOT NULL AND created >= {from:DateTime} AND created <= {to:DateTime} LIMIT 1`, + query_params: { pid: projectId, from, to }, + }) + .then((resultSet) => resultSet.json<{ cnt: string }>()) + + return Number(result.data?.[0]?.cnt) > 0 + } +} diff --git a/backend/apps/cloud/src/data-import/dto/index.ts b/backend/apps/cloud/src/data-import/dto/index.ts new file mode 100644 index 000000000..0221d0e71 --- /dev/null +++ b/backend/apps/cloud/src/data-import/dto/index.ts @@ -0,0 +1 @@ +export { UploadImportDto } from './upload-import.dto' diff --git a/backend/apps/cloud/src/data-import/dto/upload-import.dto.ts b/backend/apps/cloud/src/data-import/dto/upload-import.dto.ts new file mode 100644 index 000000000..26ce83163 --- /dev/null +++ b/backend/apps/cloud/src/data-import/dto/upload-import.dto.ts @@ -0,0 +1,15 @@ +import { IsString, IsIn } from 'class-validator' +import { ApiProperty } from '@nestjs/swagger' + +import { SUPPORTED_PROVIDERS } from '../mappers' + +export class UploadImportDto { + @ApiProperty({ + description: 'Analytics provider to import from', + enum: SUPPORTED_PROVIDERS, + example: 'umami', + }) + @IsString() + @IsIn(SUPPORTED_PROVIDERS) + provider: string +} diff --git a/backend/apps/cloud/src/data-import/entity/data-import.entity.ts b/backend/apps/cloud/src/data-import/entity/data-import.entity.ts new file mode 100644 index 000000000..a6c362fb7 --- /dev/null +++ b/backend/apps/cloud/src/data-import/entity/data-import.entity.ts @@ -0,0 +1,69 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToOne, + JoinColumn, + CreateDateColumn, +} from 'typeorm' + +import { Project } from '../../project/entity/project.entity' + +export enum DataImportStatus { + PENDING = 'pending', + PROCESSING = 'processing', + COMPLETED = 'completed', + FAILED = 'failed', +} + +export const MAX_IMPORT_ID = 255 + +@Entity('data_import') +export class DataImport { + @PrimaryGeneratedColumn() + id: number + + @Column('tinyint', { unsigned: true }) + importId: number + + @Column('varchar', { length: 12 }) + projectId: string + + @ManyToOne(() => Project, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'projectId' }) + project: Project + + @Column('varchar', { length: 30 }) + provider: string + + @Column({ + type: 'enum', + enum: DataImportStatus, + default: DataImportStatus.PENDING, + }) + status: DataImportStatus + + @Column({ type: 'date', nullable: true }) + dateFrom: Date | null + + @Column({ type: 'date', nullable: true }) + dateTo: Date | null + + @Column({ default: 0 }) + totalRows: number + + @Column({ default: 0 }) + importedRows: number + + @Column({ default: 0 }) + invalidRows: number + + @Column('text', { nullable: true }) + errorMessage: string | null + + @CreateDateColumn() + createdAt: Date + + @Column({ type: 'datetime', nullable: true }) + finishedAt: Date | null +} diff --git a/backend/apps/cloud/src/data-import/mappers/index.ts b/backend/apps/cloud/src/data-import/mappers/index.ts new file mode 100644 index 000000000..5302f3e7c --- /dev/null +++ b/backend/apps/cloud/src/data-import/mappers/index.ts @@ -0,0 +1,12 @@ +import { ImportMapper } from './mapper.interface' +import { UmamiMapper } from './umami.mapper' + +export const SUPPORTED_PROVIDERS = ['umami'] as const + +const mappers: Record = { + umami: new UmamiMapper(), +} + +export function getMapper(provider: string): ImportMapper | undefined { + return mappers[provider] +} diff --git a/backend/apps/cloud/src/data-import/mappers/mapper.interface.ts b/backend/apps/cloud/src/data-import/mappers/mapper.interface.ts new file mode 100644 index 000000000..68cc5c3e8 --- /dev/null +++ b/backend/apps/cloud/src/data-import/mappers/mapper.interface.ts @@ -0,0 +1,15 @@ +export interface AnalyticsImportRow { + table: 'analytics' | 'customEV' + data: Record +} + +export interface ImportMapper { + readonly provider: string + readonly expectedFileExtension: string + + createRowStream( + filePath: string, + pid: string, + importID: number, + ): AsyncIterable +} diff --git a/backend/apps/cloud/src/data-import/mappers/umami.mapper.ts b/backend/apps/cloud/src/data-import/mappers/umami.mapper.ts new file mode 100644 index 000000000..783ebb734 --- /dev/null +++ b/backend/apps/cloud/src/data-import/mappers/umami.mapper.ts @@ -0,0 +1,263 @@ +import * as fs from 'fs' +import * as path from 'path' +import * as os from 'os' +import * as crypto from 'crypto' +import { unzipSync } from 'fflate' +import { parse } from 'csv-parse' + +import { ImportMapper, AnalyticsImportRow } from './mapper.interface' + +const WEBSITE_EVENT_CSV = 'website_event.csv' + +const BROWSER_MAP: Record = { + chrome: 'Chrome', + opera: 'Opera', + crios: 'Mobile Chrome', + firefox: 'Firefox', + facebook: 'Facebook', + safari: 'Safari', + ios: 'Mobile Safari', + 'ios-webview': 'Mobile Safari', + 'edge-chromium': 'Edge', + samsung: 'Samsung Internet', + yandexbrowser: 'Yandex', + 'edge-ios': 'Edge', + 'chromium-webview': 'Chrome WebView', + fxios: 'Mobile Firefox', + edge: 'Edge', +} + +const MOBILE_BROWSER_VARIANTS: Record = { + Chrome: 'Mobile Chrome', + Firefox: 'Mobile Firefox', + Safari: 'Mobile Safari', +} + +const OS_MAP: Record = { + 'mac os': 'macOS', + 'windows 10': 'Windows', + 'windows 7': 'Windows', + 'windows 8': 'Windows', + 'windows 8.1': 'Windows', + 'windows vista': 'Windows', + 'windows xp': 'Windows', + 'windows server 2003': 'Windows', + 'android os': 'Android', + 'chrome os': 'Chrome OS', + 'chromium os': 'Chrome OS', +} + +const DEVICE_MAP: Record = { + laptop: 'desktop', + desktop: 'desktop', + mobile: 'mobile', + tablet: 'tablet', +} + +function normalizeNull(value: string | undefined): string | null { + if (!value || value === '' || value === '\\N') return null + return value +} + +function truncate(value: string | null, maxLen: number): string | null { + if (!value) return null + return value.length > maxLen ? value.slice(0, maxLen) : value +} + +function mapBrowser(raw: string | null, device: string | null): string | null { + if (!raw) return null + + const key = raw.toLowerCase() + let mapped = BROWSER_MAP[key] + + if (!mapped) { + mapped = raw.charAt(0).toUpperCase() + raw.slice(1) + } + + const isMobile = device === 'mobile' || device === 'tablet' + if (isMobile && MOBILE_BROWSER_VARIANTS[mapped]) { + return MOBILE_BROWSER_VARIANTS[mapped] + } + + return mapped +} + +function mapOS(raw: string | null): string | null { + if (!raw) return null + const key = raw.toLowerCase() + return OS_MAP[key] ?? raw +} + +function mapDevice(raw: string | null): string { + if (!raw) return 'desktop' + const key = raw.toLowerCase() + return DEVICE_MAP[key] ?? 'desktop' +} + +function buildReferrer( + domain: string | null, + path: string | null, + query: string | null, + hostname: string | null, +): string | null { + if (!domain) return null + + let ref = `https://${domain}` + if (path) ref += path + if (query) ref += query.startsWith('?') ? query : `?${query}` + + if (hostname) { + const cleanHost = hostname.replace(/^www\./, '') + const cleanDomain = domain.replace(/^www\./, '') + if (cleanDomain === cleanHost) return null + } + + return ref +} + +const DATE_REGEX = + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]) ([01]\d|2[0-3]):([0-5]\d):([0-5]\d)$/ + +function isValidTimestamp(ts: string): boolean { + return DATE_REGEX.test(ts) +} + +function sessionIdToPsid(sessionId: string): string { + return crypto + .createHash('sha256') + .update(sessionId) + .digest() + .readBigUInt64BE(0) + .toString() +} + +export class UmamiMapper implements ImportMapper { + readonly provider = 'umami' + readonly expectedFileExtension = '.zip' + + private extractCsvFromZip(zipPath: string): string { + const zipBuffer = fs.readFileSync(zipPath) + const files = unzipSync(new Uint8Array(zipBuffer)) + + const matchKey = Object.keys(files).find((name) => { + const basename = name.split('/').pop() || name + return basename === WEBSITE_EVENT_CSV + }) + + if (!matchKey) { + throw new Error( + `ZIP does not contain ${WEBSITE_EVENT_CSV}. Please upload the export ZIP from Umami.`, + ) + } + + const tempCsv = path.join(os.tmpdir(), `swetrix-umami-${Date.now()}.csv`) + fs.writeFileSync(tempCsv, Buffer.from(files[matchKey])) + return tempCsv + } + + async *createRowStream( + filePath: string, + pid: string, + importID: number, + ): AsyncIterable { + const csvPath = this.extractCsvFromZip(filePath) + + let cleanedUp = false + const cleanup = () => { + if (!cleanedUp) { + cleanedUp = true + try { + fs.unlinkSync(csvPath) + } catch { + // + } + } + } + + try { + const parser = fs.createReadStream(csvPath).pipe( + parse({ + columns: true, + skip_empty_lines: true, + trim: true, + relax_quotes: true, + relax_column_count: true, + }), + ) + + for await (const row of parser) { + const sessionId = normalizeNull(row.session_id) + if (!sessionId) continue + + const createdAt = normalizeNull(row.created_at) + if (!createdAt || !isValidTimestamp(createdAt)) continue + + const eventType = normalizeNull(row.event_type) + if (!eventType || (eventType !== '1' && eventType !== '2')) continue + + const psid = sessionIdToPsid(sessionId) + + const rawDevice = normalizeNull(row.device) + const device = mapDevice(rawDevice) + const browser = mapBrowser(normalizeNull(row.browser), rawDevice) + const os = mapOS(normalizeNull(row.os)) + + const hostname = truncate(normalizeNull(row.hostname), 253) + const referrerDomain = normalizeNull(row.referrer_domain) + const referrerPath = normalizeNull(row.referrer_path) + const referrerQuery = normalizeNull(row.referrer_query) + const ref = buildReferrer( + referrerDomain, + referrerPath, + referrerQuery, + hostname, + ) + + const country = normalizeNull(row.country) + const cc = country && /^[A-Z]{2}$/.test(country) ? country : null + + const region = normalizeNull(row.region) + + const baseData: Record = { + psid, + profileId: null, + pid, + host: hostname, + pg: truncate(normalizeNull(row.url_path), 2048), + dv: device, + br: truncate(browser, 30), + brv: null, + os: truncate(os, 25), + osv: null, + lc: truncate(normalizeNull(row.language), 35), + ref: truncate(ref, 2048), + so: truncate(normalizeNull(row.utm_source), 256), + me: truncate(normalizeNull(row.utm_medium), 256), + ca: truncate(normalizeNull(row.utm_campaign), 256), + te: truncate(normalizeNull(row.utm_term), 256), + co: truncate(normalizeNull(row.utm_content), 256), + cc, + rg: null, + rgc: region && region.length <= 10 ? region : null, + ct: truncate(normalizeNull(row.city), 60), + 'meta.key': [], + 'meta.value': [], + importID, + created: createdAt, + } + + if (eventType === '1') { + yield { table: 'analytics', data: baseData } + } else { + const eventName = truncate(normalizeNull(row.event_name), 256) || '' + yield { + table: 'customEV', + data: { ...baseData, ev: eventName }, + } + } + } + } finally { + cleanup() + } + } +} diff --git a/backend/apps/community/src/app.module.ts b/backend/apps/community/src/app.module.ts index 73481f249..15b07ab64 100644 --- a/backend/apps/community/src/app.module.ts +++ b/backend/apps/community/src/app.module.ts @@ -16,9 +16,11 @@ import { CaptchaModule } from './captcha/captcha.module' import { getI18nConfig } from './configs' import { AuthModule } from './auth/auth.module' import { TwoFactorAuthModule } from './twoFactorAuth/twoFactorAuth.module' +import { BullModule } from '@nestjs/bullmq' import { AppController } from './app.controller' import { isPrimaryNode } from './common/utils' import { MailerModule } from './mailer/mailer.module' +import { DataImportModule } from './data-import/data-import.module' const modules = [ ConfigModule.forRoot({ @@ -63,6 +65,14 @@ const modules = [ I18nModule.forRootAsync(getI18nConfig()), ScheduleModule.forRoot(), NestjsFormDataModule.config({ isGlobal: true }), + BullModule.forRoot({ + connection: { + host: process.env.REDIS_HOST, + port: Number(process.env.REDIS_PORT), + password: process.env.REDIS_PASSWORD, + username: process.env.REDIS_USER, + }, + }), UserModule, MailerModule, ProjectModule, @@ -73,6 +83,7 @@ const modules = [ CaptchaModule, AuthModule, TwoFactorAuthModule, + DataImportModule, ] @Module({ diff --git a/backend/apps/community/src/data-import/data-import.controller.ts b/backend/apps/community/src/data-import/data-import.controller.ts new file mode 100644 index 000000000..952f434f9 --- /dev/null +++ b/backend/apps/community/src/data-import/data-import.controller.ts @@ -0,0 +1,196 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { + Controller, + Post, + Get, + Delete, + Param, + Body, + UseInterceptors, + UploadedFile, + BadRequestException, + Query, + Logger, +} from '@nestjs/common' +import { FileInterceptor } from '@nestjs/platform-express' +import { ApiTags, ApiConsumes, ApiBody } from '@nestjs/swagger' +import { InjectQueue } from '@nestjs/bullmq' +import { Queue } from 'bullmq' + +import { CurrentUserId } from '../auth/decorators/current-user-id.decorator' +import { Auth } from '../auth/decorators' +import { ProjectService } from '../project/project.service' +import { DataImportService } from './data-import.service' +import { UploadImportDto } from './dto' +import { getMapper, SUPPORTED_PROVIDERS } from './mappers' +import { DataImportJobData } from './data-import.processor' + +const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100 MB + +@ApiTags('Data Import') +@Controller('data-import') +export class DataImportController { + private readonly logger = new Logger(DataImportController.name) + + constructor( + private readonly dataImportService: DataImportService, + private readonly projectService: ProjectService, + @InjectQueue('data-import') + private readonly importQueue: Queue, + ) {} + + @Post(':projectId/upload') + @Auth() + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + provider: { type: 'string', enum: [...SUPPORTED_PROVIDERS] }, + file: { type: 'string', format: 'binary' }, + }, + }, + }) + @UseInterceptors( + FileInterceptor('file', { + limits: { fileSize: MAX_FILE_SIZE }, + }), + ) + async upload( + @Param('projectId') projectId: string, + @CurrentUserId() uid: string, + @UploadedFile() file: Express.Multer.File, + @Body() dto: UploadImportDto, + ) { + const project = await this.projectService.getFullProject(projectId) + this.projectService.allowedToManage(project, uid) + + if (!file || !file.buffer) { + throw new BadRequestException('No file uploaded') + } + + const mapper = getMapper(dto.provider) + if (!mapper) { + throw new BadRequestException( + `Unsupported provider: ${dto.provider}. Supported: ${SUPPORTED_PROVIDERS.join(', ')}`, + ) + } + + const ext = path.extname(file.originalname).toLowerCase() + if (ext !== mapper.expectedFileExtension) { + throw new BadRequestException( + `Invalid file type. Expected a ${mapper.expectedFileExtension} file for ${dto.provider} import.`, + ) + } + + const tempDir = path.join(os.tmpdir(), 'swetrix-imports') + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }) + } + const tempFile = path.join(tempDir, `${projectId}-${Date.now()}${ext}`) + fs.writeFileSync(tempFile, file.buffer) + + const dataImport = await this.dataImportService.create( + projectId, + dto.provider, + ) + + try { + await this.importQueue.add( + 'process-import', + { + importId: dataImport.id, + projectId, + provider: dto.provider, + filePath: tempFile, + }, + { + attempts: 1, + removeOnComplete: true, + removeOnFail: false, + }, + ) + } catch (error) { + this.logger.error(`Failed to enqueue import job: ${error.message}`) + await this.dataImportService.deleteImportRecord(projectId, dataImport.id) + this.cleanupFile(tempFile) + throw new BadRequestException( + 'Failed to start import. Please try again later.', + ) + } + + return dataImport + } + + @Get(':projectId') + @Auth() + async list( + @Param('projectId') projectId: string, + @CurrentUserId() uid: string, + ) { + const project = await this.projectService.getFullProject(projectId) + this.projectService.allowedToManage(project, uid) + return this.dataImportService.findAllByProject(projectId) + } + + @Get(':projectId/has-imported-data') + @Auth() + async hasImportedData( + @Param('projectId') projectId: string, + @CurrentUserId() uid: string, + @Query('from') from: string, + @Query('to') to: string, + ) { + const project = await this.projectService.getFullProject(projectId) + this.projectService.allowedToView(project, uid) + + if (!from || !to) { + throw new BadRequestException('"from" and "to" query params are required') + } + + const hasImported = await this.dataImportService.hasImportedDataInRange( + projectId, + from, + to, + ) + + return { hasImportedData: hasImported } + } + + @Get(':projectId/:importId') + @Auth() + async getOne( + @Param('projectId') projectId: string, + @Param('importId') importId: number, + @CurrentUserId() uid: string, + ) { + const project = await this.projectService.getFullProject(projectId) + this.projectService.allowedToManage(project, uid) + return this.dataImportService.findOne(projectId, importId) + } + + @Delete(':projectId/:importId') + @Auth() + async deleteImport( + @Param('projectId') projectId: string, + @Param('importId') importId: number, + @CurrentUserId() uid: string, + ) { + const project = await this.projectService.getFullProject(projectId) + this.projectService.allowedToManage(project, uid) + await this.dataImportService.deleteImport(projectId, importId) + return { success: true } + } + + private cleanupFile(filePath: string): void { + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath) + } + } catch { + // + } + } +} diff --git a/backend/apps/community/src/data-import/data-import.module.ts b/backend/apps/community/src/data-import/data-import.module.ts new file mode 100644 index 000000000..a863560d5 --- /dev/null +++ b/backend/apps/community/src/data-import/data-import.module.ts @@ -0,0 +1,18 @@ +import { Module, forwardRef } from '@nestjs/common' +import { BullModule } from '@nestjs/bullmq' + +import { ProjectModule } from '../project/project.module' +import { DataImportService } from './data-import.service' +import { DataImportController } from './data-import.controller' +import { DataImportProcessor } from './data-import.processor' + +@Module({ + imports: [ + BullModule.registerQueue({ name: 'data-import' }), + forwardRef(() => ProjectModule), + ], + providers: [DataImportService, DataImportProcessor], + controllers: [DataImportController], + exports: [DataImportService], +}) +export class DataImportModule {} diff --git a/backend/apps/community/src/data-import/data-import.processor.ts b/backend/apps/community/src/data-import/data-import.processor.ts new file mode 100644 index 000000000..63cdc97fd --- /dev/null +++ b/backend/apps/community/src/data-import/data-import.processor.ts @@ -0,0 +1,161 @@ +import * as fs from 'fs' +import { Processor, WorkerHost } from '@nestjs/bullmq' +import { Logger } from '@nestjs/common' +import { Job } from 'bullmq' + +import { DataImportService } from './data-import.service' +import { getMapper } from './mappers' +import { clickhouse } from '../common/integrations/clickhouse' + +const CLICKHOUSE_DB = process.env.CLICKHOUSE_DATABASE || 'analytics' +const BATCH_SIZE = 5000 + +export interface DataImportJobData { + importId: number + projectId: string + provider: string + filePath: string +} + +@Processor('data-import', { concurrency: 1 }) +export class DataImportProcessor extends WorkerHost { + private readonly logger = new Logger(DataImportProcessor.name) + + constructor(private readonly dataImportService: DataImportService) { + super() + } + + async process(job: Job): Promise { + const { importId, projectId, provider, filePath } = job.data + + this.logger.log( + `Processing import ${importId} for project ${projectId} (provider: ${provider})`, + ) + + await this.dataImportService.markProcessing(importId, projectId) + + const mapper = getMapper(provider) + if (!mapper) { + await this.dataImportService.markFailed( + importId, + projectId, + `Unsupported provider: ${provider}`, + ) + return + } + + let importedRows = 0 + let totalRows = 0 + let minDate: string | null = null + let maxDate: string | null = null + + const analyticsBatch: Record[] = [] + const customEVBatch: Record[] = [] + + try { + for await (const row of mapper.createRowStream( + filePath, + projectId, + importId, + )) { + totalRows++ + + const created = row.data.created as string + if (created) { + if (!minDate || created < minDate) minDate = created + if (!maxDate || created > maxDate) maxDate = created + } + + if (row.table === 'analytics') { + analyticsBatch.push(row.data) + } else { + customEVBatch.push(row.data) + } + + if (analyticsBatch.length >= BATCH_SIZE) { + await this.flushBatch('analytics', analyticsBatch) + importedRows += analyticsBatch.length + analyticsBatch.length = 0 + } + + if (customEVBatch.length >= BATCH_SIZE) { + await this.flushBatch('customEV', customEVBatch) + importedRows += customEVBatch.length + customEVBatch.length = 0 + } + + if (totalRows % 10000 === 0) { + await job.updateProgress({ + importedRows: + importedRows + analyticsBatch.length + customEVBatch.length, + totalRows, + }) + } + } + + if (analyticsBatch.length > 0) { + await this.flushBatch('analytics', analyticsBatch) + importedRows += analyticsBatch.length + } + + if (customEVBatch.length > 0) { + await this.flushBatch('customEV', customEVBatch) + importedRows += customEVBatch.length + } + + await this.dataImportService.markCompleted(importId, projectId, { + importedRows, + invalidRows: 0, + totalRows: importedRows, + dateFrom: minDate ? minDate.split(' ')[0] : null, + dateTo: maxDate ? maxDate.split(' ')[0] : null, + }) + + this.logger.log( + `Import ${importId} completed: ${importedRows} rows imported`, + ) + } catch (error) { + this.logger.error( + `Import ${importId} failed: ${error.message}`, + error.stack, + ) + + try { + await this.dataImportService.cleanupImportedRows(projectId, importId) + } catch (cleanupError) { + this.logger.error( + `Failed to rollback partial import ${importId}: ${cleanupError.message}`, + ) + } + + await this.dataImportService.markFailed( + importId, + projectId, + error.message, + ) + } finally { + this.cleanupFile(filePath) + } + } + + private async flushBatch( + table: 'analytics' | 'customEV', + batch: Record[], + ): Promise { + await clickhouse.insert({ + table: `${CLICKHOUSE_DB}.${table}`, + values: batch, + format: 'JSONEachRow', + }) + } + + private cleanupFile(filePath: string): void { + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath) + } + } catch { + this.logger.warn(`Failed to clean up temp file: ${filePath}`) + } + } +} diff --git a/backend/apps/community/src/data-import/data-import.service.ts b/backend/apps/community/src/data-import/data-import.service.ts new file mode 100644 index 000000000..b1fd9b7c7 --- /dev/null +++ b/backend/apps/community/src/data-import/data-import.service.ts @@ -0,0 +1,269 @@ +import { + Injectable, + BadRequestException, + NotFoundException, + Logger, +} from '@nestjs/common' + +import { + DataImport, + DataImportStatus, + MAX_IMPORT_ID, +} from './entity/data-import.entity' +import { clickhouse } from '../common/integrations/clickhouse' + +const CLICKHOUSE_DB = process.env.CLICKHOUSE_DATABASE || 'analytics' + +@Injectable() +export class DataImportService { + private readonly logger = new Logger(DataImportService.name) + + private async getNextId(projectId: string): Promise { + const result = await clickhouse + .query({ + query: `SELECT max(id) as maxId FROM ${CLICKHOUSE_DB}.data_import FINAL WHERE projectId = {projectId:FixedString(12)}`, + query_params: { projectId }, + }) + .then((rs) => rs.json<{ maxId: number }>()) + + const next = (result.data?.[0]?.maxId || 0) + 1 + + if (next > MAX_IMPORT_ID) { + throw new BadRequestException( + `This project has reached the maximum number of imports (${MAX_IMPORT_ID}). Please delete an old import before creating a new one.`, + ) + } + + return next + } + + async create(projectId: string, provider: string): Promise { + const active = await this.findActiveByProject(projectId) + if (active) { + throw new BadRequestException( + 'An import is already in progress for this project. Please wait for it to complete before starting a new one.', + ) + } + + const id = await this.getNextId(projectId) + + const now = new Date() + .toISOString() + .replace('T', ' ') + .replace('Z', '') + .slice(0, 19) + + await clickhouse.insert({ + table: `${CLICKHOUSE_DB}.data_import`, + values: [ + { + id, + projectId, + provider, + status: 'pending', + dateFrom: null, + dateTo: null, + totalRows: 0, + importedRows: 0, + invalidRows: 0, + errorMessage: null, + createdAt: now, + finishedAt: null, + version: 1, + }, + ], + format: 'JSONEachRow', + }) + + return { + id, + projectId, + provider, + status: DataImportStatus.PENDING, + dateFrom: null, + dateTo: null, + totalRows: 0, + importedRows: 0, + invalidRows: 0, + errorMessage: null, + createdAt: now, + finishedAt: null, + version: 1, + } + } + + private async findActiveByProject( + projectId: string, + ): Promise { + const result = await clickhouse + .query({ + query: `SELECT * FROM ${CLICKHOUSE_DB}.data_import FINAL WHERE projectId = {projectId:FixedString(12)} AND status IN ('pending', 'processing') LIMIT 1`, + query_params: { projectId }, + }) + .then((rs) => rs.json()) + + return result.data?.[0] || null + } + + async findAllByProject(projectId: string): Promise { + const result = await clickhouse + .query({ + query: `SELECT * FROM ${CLICKHOUSE_DB}.data_import FINAL WHERE projectId = {projectId:FixedString(12)} ORDER BY createdAt DESC`, + query_params: { projectId }, + }) + .then((rs) => rs.json()) + + return result.data || [] + } + + async findOne(projectId: string, importId: number): Promise { + const result = await clickhouse + .query({ + query: `SELECT * FROM ${CLICKHOUSE_DB}.data_import FINAL WHERE projectId = {projectId:FixedString(12)} AND id = {id:UInt8} LIMIT 1`, + query_params: { projectId, id: importId }, + }) + .then((rs) => rs.json()) + + const dataImport = result.data?.[0] + if (!dataImport) { + throw new NotFoundException('Import not found') + } + + return dataImport + } + + async updateStatus( + id: number, + projectId: string, + updates: Partial, + ): Promise { + const existing = await this.findOne(projectId, id) + const nextVersion = (existing.version || 0) + 1 + + await clickhouse.insert({ + table: `${CLICKHOUSE_DB}.data_import`, + values: [ + { + ...existing, + ...updates, + id, + projectId, + version: nextVersion, + }, + ], + format: 'JSONEachRow', + }) + } + + async markProcessing(id: number, projectId: string): Promise { + await this.updateStatus(id, projectId, { + status: DataImportStatus.PROCESSING, + }) + } + + async markCompleted( + id: number, + projectId: string, + stats: { + importedRows: number + invalidRows: number + totalRows: number + dateFrom: string | null + dateTo: string | null + }, + ): Promise { + const now = new Date() + .toISOString() + .replace('T', ' ') + .replace('Z', '') + .slice(0, 19) + await this.updateStatus(id, projectId, { + status: DataImportStatus.COMPLETED, + importedRows: stats.importedRows, + invalidRows: stats.invalidRows, + totalRows: stats.totalRows, + dateFrom: stats.dateFrom, + dateTo: stats.dateTo, + finishedAt: now, + }) + } + + async markFailed( + id: number, + projectId: string, + errorMessage: string, + ): Promise { + const now = new Date() + .toISOString() + .replace('T', ' ') + .replace('Z', '') + .slice(0, 19) + await this.updateStatus(id, projectId, { + status: DataImportStatus.FAILED, + errorMessage, + finishedAt: now, + }) + } + + async cleanupImportedRows( + projectId: string, + importId: number, + ): Promise { + await clickhouse.command({ + query: `ALTER TABLE ${CLICKHOUSE_DB}.analytics DELETE WHERE pid = {pid:FixedString(12)} AND importID = {importID:UInt8}`, + query_params: { pid: projectId, importID: importId }, + }) + await clickhouse.command({ + query: `ALTER TABLE ${CLICKHOUSE_DB}.customEV DELETE WHERE pid = {pid:FixedString(12)} AND importID = {importID:UInt8}`, + query_params: { pid: projectId, importID: importId }, + }) + } + + async deleteImport(projectId: string, importId: number): Promise { + const dataImport = await this.findOne(projectId, importId) + + if ( + dataImport.status === DataImportStatus.PENDING || + dataImport.status === DataImportStatus.PROCESSING + ) { + throw new BadRequestException( + 'Cannot delete an import that is still in progress.', + ) + } + + try { + await this.cleanupImportedRows(projectId, importId) + } catch (error) { + this.logger.error( + `Failed to delete imported data from ClickHouse for import ${importId}: ${error.message}`, + ) + throw new BadRequestException( + 'Failed to remove imported data. Please try again later.', + ) + } + + await this.deleteImportRecord(projectId, importId) + } + + async deleteImportRecord(projectId: string, importId: number): Promise { + await clickhouse.command({ + query: `ALTER TABLE ${CLICKHOUSE_DB}.data_import DELETE WHERE projectId = {projectId:FixedString(12)} AND id = {id:UInt8}`, + query_params: { projectId, id: importId }, + }) + } + + async hasImportedDataInRange( + projectId: string, + from: string, + to: string, + ): Promise { + const result = await clickhouse + .query({ + query: `SELECT count() as cnt FROM ${CLICKHOUSE_DB}.analytics WHERE pid = {pid:FixedString(12)} AND importID IS NOT NULL AND created >= {from:DateTime} AND created <= {to:DateTime} LIMIT 1`, + query_params: { pid: projectId, from, to }, + }) + .then((rs) => rs.json<{ cnt: string }>()) + + return Number(result.data?.[0]?.cnt) > 0 + } +} diff --git a/backend/apps/community/src/data-import/dto/index.ts b/backend/apps/community/src/data-import/dto/index.ts new file mode 100644 index 000000000..0221d0e71 --- /dev/null +++ b/backend/apps/community/src/data-import/dto/index.ts @@ -0,0 +1 @@ +export { UploadImportDto } from './upload-import.dto' diff --git a/backend/apps/community/src/data-import/dto/upload-import.dto.ts b/backend/apps/community/src/data-import/dto/upload-import.dto.ts new file mode 100644 index 000000000..26ce83163 --- /dev/null +++ b/backend/apps/community/src/data-import/dto/upload-import.dto.ts @@ -0,0 +1,15 @@ +import { IsString, IsIn } from 'class-validator' +import { ApiProperty } from '@nestjs/swagger' + +import { SUPPORTED_PROVIDERS } from '../mappers' + +export class UploadImportDto { + @ApiProperty({ + description: 'Analytics provider to import from', + enum: SUPPORTED_PROVIDERS, + example: 'umami', + }) + @IsString() + @IsIn(SUPPORTED_PROVIDERS) + provider: string +} diff --git a/backend/apps/community/src/data-import/entity/data-import.entity.ts b/backend/apps/community/src/data-import/entity/data-import.entity.ts new file mode 100644 index 000000000..2c1ba7817 --- /dev/null +++ b/backend/apps/community/src/data-import/entity/data-import.entity.ts @@ -0,0 +1,24 @@ +export enum DataImportStatus { + PENDING = 'pending', + PROCESSING = 'processing', + COMPLETED = 'completed', + FAILED = 'failed', +} + +export const MAX_IMPORT_ID = 255 + +export class DataImport { + id: number + projectId: string + provider: string + status: DataImportStatus + dateFrom: string | null + dateTo: string | null + totalRows: number + importedRows: number + invalidRows: number + errorMessage: string | null + createdAt: string + finishedAt: string | null + version: number +} diff --git a/backend/apps/community/src/data-import/mappers/index.ts b/backend/apps/community/src/data-import/mappers/index.ts new file mode 100644 index 000000000..5302f3e7c --- /dev/null +++ b/backend/apps/community/src/data-import/mappers/index.ts @@ -0,0 +1,12 @@ +import { ImportMapper } from './mapper.interface' +import { UmamiMapper } from './umami.mapper' + +export const SUPPORTED_PROVIDERS = ['umami'] as const + +const mappers: Record = { + umami: new UmamiMapper(), +} + +export function getMapper(provider: string): ImportMapper | undefined { + return mappers[provider] +} diff --git a/backend/apps/community/src/data-import/mappers/mapper.interface.ts b/backend/apps/community/src/data-import/mappers/mapper.interface.ts new file mode 100644 index 000000000..68cc5c3e8 --- /dev/null +++ b/backend/apps/community/src/data-import/mappers/mapper.interface.ts @@ -0,0 +1,15 @@ +export interface AnalyticsImportRow { + table: 'analytics' | 'customEV' + data: Record +} + +export interface ImportMapper { + readonly provider: string + readonly expectedFileExtension: string + + createRowStream( + filePath: string, + pid: string, + importID: number, + ): AsyncIterable +} diff --git a/backend/apps/community/src/data-import/mappers/umami.mapper.ts b/backend/apps/community/src/data-import/mappers/umami.mapper.ts new file mode 100644 index 000000000..e7bfb9b93 --- /dev/null +++ b/backend/apps/community/src/data-import/mappers/umami.mapper.ts @@ -0,0 +1,263 @@ +import * as fs from 'fs' +import * as path from 'path' +import * as os from 'os' +import * as crypto from 'crypto' +import { unzipSync } from 'fflate' +import { parse } from 'csv-parse' + +import { ImportMapper, AnalyticsImportRow } from './mapper.interface' + +const WEBSITE_EVENT_CSV = 'website_event.csv' + +const BROWSER_MAP: Record = { + chrome: 'Chrome', + opera: 'Opera', + crios: 'Mobile Chrome', + firefox: 'Firefox', + facebook: 'Facebook', + safari: 'Safari', + ios: 'Mobile Safari', + 'ios-webview': 'Mobile Safari', + 'edge-chromium': 'Edge', + samsung: 'Samsung Internet', + yandexbrowser: 'Yandex', + 'edge-ios': 'Edge', + 'chromium-webview': 'Chrome WebView', + fxios: 'Mobile Firefox', + edge: 'Edge', +} + +const MOBILE_BROWSER_VARIANTS: Record = { + Chrome: 'Mobile Chrome', + Firefox: 'Mobile Firefox', + Safari: 'Mobile Safari', +} + +const OS_MAP: Record = { + 'mac os': 'macOS', + 'windows 10': 'Windows', + 'windows 7': 'Windows', + 'windows 8': 'Windows', + 'windows 8.1': 'Windows', + 'windows vista': 'Windows', + 'windows xp': 'Windows', + 'windows server 2003': 'Windows', + 'android os': 'Android', + 'chrome os': 'Chrome OS', + 'chromium os': 'Chrome OS', +} + +const DEVICE_MAP: Record = { + laptop: 'desktop', + desktop: 'desktop', + mobile: 'mobile', + tablet: 'tablet', +} + +function normalizeNull(value: string | undefined): string | null { + if (!value || value === '' || value === '\\N') return null + return value +} + +function truncate(value: string | null, maxLen: number): string | null { + if (!value) return null + return value.length > maxLen ? value.slice(0, maxLen) : value +} + +function mapBrowser(raw: string | null, device: string | null): string | null { + if (!raw) return null + + const key = raw.toLowerCase() + let mapped = BROWSER_MAP[key] + + if (!mapped) { + mapped = raw.charAt(0).toUpperCase() + raw.slice(1) + } + + const isMobile = device === 'mobile' || device === 'tablet' + if (isMobile && MOBILE_BROWSER_VARIANTS[mapped]) { + return MOBILE_BROWSER_VARIANTS[mapped] + } + + return mapped +} + +function mapOS(raw: string | null): string | null { + if (!raw) return null + const key = raw.toLowerCase() + return OS_MAP[key] ?? raw +} + +function mapDevice(raw: string | null): string { + if (!raw) return 'desktop' + const key = raw.toLowerCase() + return DEVICE_MAP[key] ?? 'desktop' +} + +function buildReferrer( + domain: string | null, + path: string | null, + query: string | null, + hostname: string | null, +): string | null { + if (!domain) return null + + let ref = `https://${domain}` + if (path) ref += path + if (query) ref += query.startsWith('?') ? query : `?${query}` + + if (hostname) { + const cleanHost = hostname.replace(/^www\./, '') + const cleanDomain = domain.replace(/^www\./, '') + if (cleanDomain === cleanHost) return null + } + + return ref +} + +const DATE_REGEX = + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]) ([01]\d|2[0-3]):([0-5]\d):([0-5]\d)$/ + +function isValidTimestamp(ts: string): boolean { + return DATE_REGEX.test(ts) +} + +function sessionIdToPsid(sessionId: string): string { + return crypto + .createHash('sha256') + .update(sessionId) + .digest() + .readBigUInt64BE(0) + .toString() +} + +export class UmamiMapper implements ImportMapper { + readonly provider = 'umami' + readonly expectedFileExtension = '.zip' + + private extractCsvFromZip(zipPath: string): string { + const zipBuffer = fs.readFileSync(zipPath) + const files = unzipSync(new Uint8Array(zipBuffer)) + + const matchKey = Object.keys(files).find((name) => { + const basename = name.split('/').pop() || name + return basename === WEBSITE_EVENT_CSV + }) + + if (!matchKey) { + throw new Error( + `ZIP does not contain ${WEBSITE_EVENT_CSV}. Please upload the export ZIP from Umami.`, + ) + } + + const tempCsv = path.join(os.tmpdir(), `swetrix-umami-${Date.now()}.csv`) + fs.writeFileSync(tempCsv, Buffer.from(files[matchKey])) + return tempCsv + } + + async *createRowStream( + filePath: string, + pid: string, + importID: number, + ): AsyncIterable { + const csvPath = this.extractCsvFromZip(filePath) + + let cleanedUp = false + const cleanup = () => { + if (!cleanedUp) { + cleanedUp = true + try { + fs.unlinkSync(csvPath) + } catch { + // ignore + } + } + } + + try { + const parser = fs.createReadStream(csvPath).pipe( + parse({ + columns: true, + skip_empty_lines: true, + trim: true, + relax_quotes: true, + relax_column_count: true, + }), + ) + + for await (const row of parser) { + const sessionId = normalizeNull(row.session_id) + if (!sessionId) continue + + const createdAt = normalizeNull(row.created_at) + if (!createdAt || !isValidTimestamp(createdAt)) continue + + const eventType = normalizeNull(row.event_type) + if (!eventType || (eventType !== '1' && eventType !== '2')) continue + + const psid = sessionIdToPsid(sessionId) + + const rawDevice = normalizeNull(row.device) + const device = mapDevice(rawDevice) + const browser = mapBrowser(normalizeNull(row.browser), rawDevice) + const os = mapOS(normalizeNull(row.os)) + + const hostname = truncate(normalizeNull(row.hostname), 253) + const referrerDomain = normalizeNull(row.referrer_domain) + const referrerPath = normalizeNull(row.referrer_path) + const referrerQuery = normalizeNull(row.referrer_query) + const ref = buildReferrer( + referrerDomain, + referrerPath, + referrerQuery, + hostname, + ) + + const country = normalizeNull(row.country) + const cc = country && /^[A-Z]{2}$/.test(country) ? country : null + + const region = normalizeNull(row.region) + + const baseData: Record = { + psid, + profileId: null, + pid, + host: hostname, + pg: truncate(normalizeNull(row.url_path), 2048), + dv: device, + br: truncate(browser, 30), + brv: null, + os: truncate(os, 25), + osv: null, + lc: truncate(normalizeNull(row.language), 35), + ref: truncate(ref, 2048), + so: truncate(normalizeNull(row.utm_source), 256), + me: truncate(normalizeNull(row.utm_medium), 256), + ca: truncate(normalizeNull(row.utm_campaign), 256), + te: truncate(normalizeNull(row.utm_term), 256), + co: truncate(normalizeNull(row.utm_content), 256), + cc, + rg: null, + rgc: region && region.length <= 10 ? region : null, + ct: truncate(normalizeNull(row.city), 60), + 'meta.key': [], + 'meta.value': [], + importID, + created: createdAt, + } + + if (eventType === '1') { + yield { table: 'analytics', data: baseData } + } else { + const eventName = truncate(normalizeNull(row.event_name), 256) || '' + yield { + table: 'customEV', + data: { ...baseData, ev: eventName }, + } + } + } + } finally { + cleanup() + } + } +} diff --git a/backend/migrations/clickhouse/2026_03_31_import_id.js b/backend/migrations/clickhouse/2026_03_31_import_id.js new file mode 100644 index 000000000..b22295395 --- /dev/null +++ b/backend/migrations/clickhouse/2026_03_31_import_id.js @@ -0,0 +1,8 @@ +const { queriesRunner, dbName } = require('./setup') + +const queries = [ + `ALTER TABLE ${dbName}.analytics ADD COLUMN IF NOT EXISTS importID Nullable(UInt8) AFTER \`meta.value\``, + `ALTER TABLE ${dbName}.customEV ADD COLUMN IF NOT EXISTS importID Nullable(UInt8) AFTER \`meta.value\``, +] + +queriesRunner(queries) diff --git a/backend/migrations/clickhouse/initialise_database.js b/backend/migrations/clickhouse/initialise_database.js index 655f4a21a..c93de76d9 100644 --- a/backend/migrations/clickhouse/initialise_database.js +++ b/backend/migrations/clickhouse/initialise_database.js @@ -30,6 +30,7 @@ const CLICKHOUSE_INIT_QUERIES = [ ct Nullable(String) CODEC(ZSTD(3)), \`meta.key\` Array(String) CODEC(ZSTD(3)), \`meta.value\` Array(String) CODEC(ZSTD(3)), + importID Nullable(UInt8), created DateTime('UTC') CODEC(Delta(4), LZ4) ) ENGINE = MergeTree() @@ -63,6 +64,7 @@ const CLICKHOUSE_INIT_QUERIES = [ ct Nullable(String) CODEC(ZSTD(3)), \`meta.key\` Array(String) CODEC(ZSTD(3)), \`meta.value\` Array(String) CODEC(ZSTD(3)), + importID Nullable(UInt8), created DateTime('UTC') CODEC(Delta(4), LZ4) ) ENGINE = MergeTree() diff --git a/backend/migrations/clickhouse/initialise_selfhosted.js b/backend/migrations/clickhouse/initialise_selfhosted.js index f0b6eff39..c2d840a46 100644 --- a/backend/migrations/clickhouse/initialise_selfhosted.js +++ b/backend/migrations/clickhouse/initialise_selfhosted.js @@ -178,6 +178,25 @@ const CLICKHOUSE_INIT_QUERIES = [ ) ENGINE = MergeTree() PRIMARY KEY rotation;`, + + `CREATE TABLE IF NOT EXISTS ${dbName}.data_import + ( + id UInt8, + projectId FixedString(12), + provider LowCardinality(String), + status Enum8('pending' = 1, 'processing' = 2, 'completed' = 3, 'failed' = 4) DEFAULT 'pending', + dateFrom Nullable(Date), + dateTo Nullable(Date), + totalRows UInt32 DEFAULT 0, + importedRows UInt32 DEFAULT 0, + invalidRows UInt32 DEFAULT 0, + errorMessage Nullable(String) CODEC(ZSTD(3)), + createdAt DateTime('UTC') DEFAULT now(), + finishedAt Nullable(DateTime('UTC')), + version UInt32 DEFAULT 1 + ) + ENGINE = ReplacingMergeTree(version) + ORDER BY (projectId, id);`, ] const initialiseSelfhosted = async () => { diff --git a/backend/migrations/clickhouse/selfhosted_2026_03_31_data_import.js b/backend/migrations/clickhouse/selfhosted_2026_03_31_data_import.js new file mode 100644 index 000000000..4334445fc --- /dev/null +++ b/backend/migrations/clickhouse/selfhosted_2026_03_31_data_import.js @@ -0,0 +1,24 @@ +const { queriesRunner, dbName } = require('./setup') + +const queries = [ + `CREATE TABLE IF NOT EXISTS ${dbName}.data_import + ( + id UInt8, + projectId FixedString(12), + provider LowCardinality(String), + status Enum8('pending' = 1, 'processing' = 2, 'completed' = 3, 'failed' = 4) DEFAULT 'pending', + dateFrom Nullable(Date), + dateTo Nullable(Date), + totalRows UInt32 DEFAULT 0, + importedRows UInt32 DEFAULT 0, + invalidRows UInt32 DEFAULT 0, + errorMessage Nullable(String) CODEC(ZSTD(3)), + createdAt DateTime('UTC') DEFAULT now(), + finishedAt Nullable(DateTime('UTC')), + version UInt32 DEFAULT 1 + ) + ENGINE = ReplacingMergeTree(version) + ORDER BY (projectId, id);`, +] + +queriesRunner(queries) diff --git a/backend/migrations/mysql/2026_03_31_data_import.sql b/backend/migrations/mysql/2026_03_31_data_import.sql new file mode 100644 index 000000000..1a10c69be --- /dev/null +++ b/backend/migrations/mysql/2026_03_31_data_import.sql @@ -0,0 +1,19 @@ +-- Data import table for tracking imports from third-party analytics providers +CREATE TABLE IF NOT EXISTS `data_import` ( + `id` int NOT NULL AUTO_INCREMENT, + `importId` tinyint UNSIGNED NOT NULL, + `projectId` varchar(12) NOT NULL, + `provider` varchar(30) NOT NULL, + `status` enum('pending', 'processing', 'completed', 'failed') NOT NULL DEFAULT 'pending', + `dateFrom` date DEFAULT NULL, + `dateTo` date DEFAULT NULL, + `totalRows` int NOT NULL DEFAULT 0, + `importedRows` int NOT NULL DEFAULT 0, + `invalidRows` int NOT NULL DEFAULT 0, + `errorMessage` text DEFAULT NULL, + `createdAt` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `finishedAt` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `FK_data_import_project` (`projectId`), + CONSTRAINT `FK_data_import_project` FOREIGN KEY (`projectId`) REFERENCES `project` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/package-lock.json b/backend/package-lock.json index fedbaef1f..678548a9a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,6 +12,7 @@ "@googleapis/searchconsole": "^6.0.1", "@nestjs-modules/mailer": "^2.0.2", "@nestjs/axios": "^4.0.1", + "@nestjs/bullmq": "^11.0.4", "@nestjs/common": "^11.1.15", "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.1.15", @@ -35,15 +36,18 @@ "axios": "^1.13.6", "bcrypt": "^6.0.0", "body-parser": "^2.2.2", + "bullmq": "^5.71.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", "cookie-parser": "^1.4.7", "countries-and-timezones": "^3.8.0", "cross-env": "^10.1.0", "crypto-js": "^4.2.0", + "csv-parse": "^6.2.1", "dayjs": "^1.11.19", "dotenv": "^17.3.1", "express": "^5.2.1", + "fflate": "^0.8.2", "front-matter": "^4.0.2", "google-auth-library": "^10.6.1", "handlebars": "^4.7.8", @@ -1224,6 +1228,84 @@ "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", "license": "MIT" }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.0.tgz", @@ -1283,6 +1365,34 @@ "rxjs": "^7.0.0" } }, + "node_modules/@nestjs/bull-shared": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-11.0.4.tgz", + "integrity": "sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, + "node_modules/@nestjs/bullmq": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-11.0.4.tgz", + "integrity": "sha512-wBzK9raAVG0/6NTMdvLGM4/FQ1lsB35/pYS8L6a0SDgkTiLpd7mAjQ8R692oMx5s7IjvgntaZOuTUrKYLNfIkA==", + "license": "MIT", + "dependencies": { + "@nestjs/bull-shared": "^11.0.4", + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0", + "bullmq": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, "node_modules/@nestjs/cli": { "version": "11.0.16", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.16.tgz", @@ -5215,6 +5325,34 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/bullmq": { + "version": "5.71.1", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.71.1.tgz", + "integrity": "sha512-kOBfdcsHmO6wwmIjpersoVdYQ7jkjTgky4Yop0loc7QwSdgxliSzD69U9ijZuRrkyCJwz5p5eqxeGeQkJ0YGZQ==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.10.1", + "msgpackr": "1.11.5", + "node-abort-controller": "3.1.1", + "semver": "7.7.4", + "tslib": "2.8.1", + "uuid": "11.1.0" + } + }, + "node_modules/bullmq/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -5884,6 +6022,18 @@ "url": "https://ko-fi.com/intcreator" } }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-env": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", @@ -5951,6 +6101,12 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/csv-parse": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-6.2.1.tgz", + "integrity": "sha512-LRLMV+UCyfMokp8Wb411duBf1gaBKJfOfBWU9eHMJ+b+cJYZsNu3AFmjJf3+yPGd59Exz1TsMjaSFyxnYB9+IQ==", + "license": "MIT" + }, "node_modules/dayjs": { "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", @@ -6907,6 +7063,12 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-type": { "version": "21.3.0", "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.0.tgz", @@ -7797,9 +7959,9 @@ "optional": true }, "node_modules/ioredis": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.0.tgz", - "integrity": "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==", + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", "license": "MIT", "dependencies": { "@ioredis/commands": "1.5.1", @@ -9567,6 +9729,37 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msgpackr": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", + "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, "node_modules/multer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.0.tgz", @@ -9951,7 +10144,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", - "dev": true, "license": "MIT" }, "node_modules/node-addon-api": { @@ -10024,6 +10216,21 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -11622,9 +11829,9 @@ } }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" diff --git a/backend/package.json b/backend/package.json index a6494b71f..b99406990 100644 --- a/backend/package.json +++ b/backend/package.json @@ -33,6 +33,7 @@ "@googleapis/searchconsole": "^6.0.1", "@nestjs-modules/mailer": "^2.0.2", "@nestjs/axios": "^4.0.1", + "@nestjs/bullmq": "^11.0.4", "@nestjs/common": "^11.1.15", "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.1.15", @@ -56,15 +57,18 @@ "axios": "^1.13.6", "bcrypt": "^6.0.0", "body-parser": "^2.2.2", + "bullmq": "^5.71.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", "cookie-parser": "^1.4.7", "countries-and-timezones": "^3.8.0", "cross-env": "^10.1.0", "crypto-js": "^4.2.0", + "csv-parse": "^6.2.1", "dayjs": "^1.11.19", "dotenv": "^17.3.1", "express": "^5.2.1", + "fflate": "^0.8.2", "front-matter": "^4.0.2", "google-auth-library": "^10.6.1", "handlebars": "^4.7.8", diff --git a/docs/content/docs/data-import.mdx b/docs/content/docs/data-import.mdx new file mode 100644 index 000000000..c6381e060 --- /dev/null +++ b/docs/content/docs/data-import.mdx @@ -0,0 +1,116 @@ +--- +title: Data Import +description: Import historical analytics data from third-party platforms into Swetrix +--- + +# Data Import + +Swetrix allows you to import historical analytics data from other platforms so you can switch without losing your history. Imported data appears alongside your live-tracked data in dashboards, charts, and reports. + +## Supported Providers + +| Provider | File Format | Status | +|----------|------------|--------| +| Umami | ZIP | Available | +| Plausible | ZIP (CSV) | Coming soon | +| Fathom | CSV | Coming soon | +| Simple Analytics | CSV | Coming soon | + +## How It Works + +1. **Export** your data from the source platform +2. **Upload** the exported file to Swetrix +3. **Swetrix processes** the data in the background +4. **Imported data** appears in your analytics dashboards + +Imported data is tagged internally so it can be identified and, if needed, removed later without affecting your live-tracked data. + +## Importing from Umami + +### Step 1: Export Your Data from Umami + +1. Log in to your Umami instance +2. Go to **Settings** → **Websites** +3. Click on the website you want to export +4. Navigate to the **Data** section +5. Click **Export** to download a CSV file + +[SCREENSHOT: Umami export data page] + +### Step 2: Import into Swetrix + +1. Go to your project's **Settings** page in Swetrix +2. Click the **Import data** tab +3. Click the **Umami** provider card +4. In the upload modal, either drag and drop your CSV file or click to browse +5. Wait for the upload and processing to complete + +[SCREENSHOT: Swetrix import data tab with provider cards] + +[SCREENSHOT: Swetrix upload modal] + +### Step 3: Monitor Progress + +After uploading, the import will appear in the **Import history** table below the provider cards. You can see: + +- **Status** — Pending, Processing, Completed, or Failed +- **Date range** — The time span of the imported data +- **Rows** — How many analytics events were imported + +The page auto-refreshes while an import is processing. You can navigate away and come back later. + +[SCREENSHOT: Import history table showing a completed import] + +## Viewing Imported Data + +Once an import completes, the data is immediately available in your analytics dashboard. When you select a date range that includes imported data, a small **"Imported"** badge appears near the period selector to let you know. + +[SCREENSHOT: Dashboard with imported data indicator badge] + +## Deleting an Import + +If you want to remove imported data: + +1. Go to **Settings** → **Import data** +2. Find the import in the history table +3. Click the trash icon +4. Confirm the deletion + +This permanently removes all analytics rows associated with that import. Your live-tracked data is not affected. + +## Data Mapping + +When importing from Umami, Swetrix maps field names and values to match its internal format: + +- **Browsers** — Umami's lowercase identifiers (e.g., `chrome`, `firefox`, `fxios`) are mapped to Swetrix display names (e.g., `Chrome`, `Firefox`, `Mobile Firefox`) +- **Operating systems** — Names are normalized (e.g., `Mac OS` → `macOS`, `Android OS` → `Android`) +- **Devices** — Categories are mapped (e.g., `laptop` → `desktop`) +- **UTM parameters** — Imported directly (`utm_source`, `utm_medium`, `utm_campaign`, etc.) +- **Geolocation** — Country codes, region codes, and city names are imported as-is +- **Custom events** — Umami event type `2` rows are imported as custom events + +## Limitations + +- **File size**: Maximum 100 MB per upload +- **One at a time**: Only one import can be active (pending or processing) per project at a time +- **ZIP format**: Upload the `.zip` file from Umami directly — Swetrix extracts `website_event.csv` from it automatically (session and event metadata files are not used) +- **No performance data**: Web Vitals from Umami (LCP, FCP, etc.) are not imported as they use a different measurement schema +- **Timestamps**: Events with invalid timestamps are skipped during import + +## Troubleshooting + +### Import stuck in "Processing" + +Large files may take several minutes to process. If an import seems stuck, wait up to 30 minutes. If it still hasn't completed, the import may have failed — check for error messages in the import history. + +### "An import is already in progress" + +Only one import can run at a time per project. Wait for the current import to complete or fail before starting a new one. + +### Invalid file type error + +Make sure you're uploading the correct file format for your provider. For Umami, upload the `.zip` file from the export directly — do not extract it first. + +### Data not appearing in charts + +After a successful import, select a date range that covers the imported data's time period. The imported data indicator badge should appear to confirm it's included. diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index 29e1e77e7..039242a12 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -13,6 +13,7 @@ "---Settings & Billing---", "analytics-dashboard", "sitesettings", + "data-import", "accountsettings", "billing", "---Developers---", diff --git a/web/app/api/api.server.ts b/web/app/api/api.server.ts index e621f725f..0b3452878 100644 --- a/web/app/api/api.server.ts +++ b/web/app/api/api.server.ts @@ -46,7 +46,7 @@ async function fetchWithTimeout( interface ServerFetchOptions { method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' - body?: Record + body?: Record | FormData headers?: Record /** Skip authentication - for public endpoints */ skipAuth?: boolean @@ -75,8 +75,10 @@ export async function serverFetch( const cleanEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint const url = `${API_URL}${cleanEndpoint}` + const isFormData = body instanceof FormData + const fetchHeaders: Record = { - 'Content-Type': 'application/json', + ...(isFormData ? {} : { 'Content-Type': 'application/json' }), ...customHeaders, } @@ -97,11 +99,17 @@ export async function serverFetch( } } + const serializedBody = body + ? isFormData + ? body + : JSON.stringify(body) + : undefined + try { const response = await fetchWithTimeout(url, { method, headers: fetchHeaders, - body: body ? JSON.stringify(body) : undefined, + body: serializedBody, }) if ((response.status === 401 || response.status === 403) && !skipAuth) { @@ -114,7 +122,7 @@ export async function serverFetch( const retryResponse = await fetchWithTimeout(url, { method, headers: fetchHeaders, - body: body ? JSON.stringify(body) : undefined, + body: serializedBody, }) if (retryResponse.ok) { diff --git a/web/app/lib/models/Project.ts b/web/app/lib/models/Project.ts index 2eb44f2d4..448bf00bb 100644 --- a/web/app/lib/models/Project.ts +++ b/web/app/lib/models/Project.ts @@ -172,6 +172,21 @@ export interface AnalyticsFunnel { dropoffPercStep: number } +export interface DataImport { + id: number + projectId: string + provider: string + status: 'pending' | 'processing' | 'completed' | 'failed' + dateFrom: string | null + dateTo: string | null + totalRows: number + importedRows: number + invalidRows: number + errorMessage: string | null + createdAt: string + finishedAt: string | null +} + export interface Project { id: string name: string diff --git a/web/app/pages/Project/Settings/ProjectSettings.tsx b/web/app/pages/Project/Settings/ProjectSettings.tsx index 4af2725eb..6977b7585 100644 --- a/web/app/pages/Project/Settings/ProjectSettings.tsx +++ b/web/app/pages/Project/Settings/ProjectSettings.tsx @@ -21,6 +21,7 @@ import { NoteIcon, ShieldCheckIcon, CurrencyDollarIcon, + DownloadIcon, } from '@phosphor-icons/react' import React, { useState, useEffect, useMemo, useRef } from 'react' import { Trans, useTranslation } from 'react-i18next' @@ -64,6 +65,7 @@ import DangerZone from './tabs/DangerZone' import General from './tabs/General' import Revenue from './tabs/Revenue' import Shields from './tabs/Shields' +import DataImportTab from './components/DataImportTab' const MAX_NAME_LENGTH = 50 const MAX_ORIGINS_LENGTH = 300 @@ -355,6 +357,7 @@ const ProjectSettings = () => { | 'emails' | 'people' | 'annotations' + | 'import' | 'danger' const tabs = useMemo( @@ -424,6 +427,14 @@ const ProjectSettings = () => { iconColor: 'text-indigo-500', visible: true, }, + { + id: 'import', + label: t('project.settings.tabs.import'), + description: t('project.settings.tabs.importDesc'), + icon: DownloadIcon, + iconColor: 'text-cyan-500', + visible: true, + }, { id: 'annotations', label: t('project.settings.tabs.annotations'), @@ -1045,6 +1056,17 @@ const ProjectSettings = () => { /> ) : null} + {activeTab === 'import' && activeTabConfig ? ( + <> + + + + ) : null} {activeTab === 'integrations' && activeTabConfig ? (
diff --git a/web/app/pages/Project/Settings/components/DataImportTab.tsx b/web/app/pages/Project/Settings/components/DataImportTab.tsx new file mode 100644 index 000000000..517164fd3 --- /dev/null +++ b/web/app/pages/Project/Settings/components/DataImportTab.tsx @@ -0,0 +1,548 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { + UploadIcon, + TrashIcon, + CheckCircleIcon, + XCircleIcon, + ClockIcon, + SpinnerIcon, + FileTextIcon, +} from '@phosphor-icons/react' +import cx from 'clsx' +import { toast } from 'sonner' +import dayjs from 'dayjs' +import { useTranslation } from 'react-i18next' + +import { DataImport } from '~/lib/models/Project' +import Modal from '~/ui/Modal' +import Loader from '~/ui/Loader' +import { Text } from '~/ui/Text' +import UmamiSVG from '~/ui/icons/Umami' + +const PROVIDERS = ['umami'] as const + +const STATUS_ICONS = { + pending: ClockIcon, + processing: SpinnerIcon, + completed: CheckCircleIcon, + failed: XCircleIcon, +} as const + +const STATUS_CLASSES = { + pending: + 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-500/10 dark:text-amber-400 dark:ring-amber-500/20', + processing: + 'bg-blue-50 text-blue-700 ring-blue-600/20 dark:bg-blue-500/10 dark:text-blue-400 dark:ring-blue-500/20', + completed: + 'bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-400 dark:ring-emerald-500/20', + failed: + 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-500/10 dark:text-red-400 dark:ring-red-500/20', +} as const + +function StatusBadge({ + status, + label, +}: { + status: DataImport['status'] + label: string +}) { + const Icon = STATUS_ICONS[status] + + return ( + + + {label} + + ) +} + +interface DataImportTabProps { + projectId: string +} + +export default function DataImportTab({ projectId }: DataImportTabProps) { + const { t } = useTranslation('common') + const [imports, setImports] = useState([]) + const [loading, setLoading] = useState(true) + const [selectedProvider, setSelectedProvider] = useState(null) + const [uploading, setUploading] = useState(false) + const [deleteTarget, setDeleteTarget] = useState(null) + const [deleting, setDeleting] = useState(false) + const fileInputRef = useRef(null) + const pollRef = useRef | null>(null) + + const statusLabels: Record = { + pending: t('project.settings.dataImport.statusPending'), + processing: t('project.settings.dataImport.statusProcessing'), + completed: t('project.settings.dataImport.statusCompleted'), + failed: t('project.settings.dataImport.statusFailed'), + } + + const fetchImports = useCallback(async () => { + try { + const res = await fetch( + `/api/data-import?endpoint=${encodeURIComponent(`data-import/${projectId}`)}`, + ) + if (res.ok) { + const data = await res.json() + setImports(Array.isArray(data) ? data : []) + } + } catch { + // silently fail on poll + } finally { + setLoading(false) + } + }, [projectId]) + + useEffect(() => { + fetchImports() + }, [fetchImports]) + + useEffect(() => { + const hasActive = imports.some( + (i) => i.status === 'pending' || i.status === 'processing', + ) + + if (hasActive) { + pollRef.current = setInterval(fetchImports, 3000) + } else if (pollRef.current) { + clearInterval(pollRef.current) + pollRef.current = null + } + + return () => { + if (pollRef.current) { + clearInterval(pollRef.current) + } + } + }, [imports, fetchImports]) + + const handleUpload = async (file: File) => { + if (!selectedProvider) return + + setUploading(true) + + try { + const formData = new FormData() + formData.append('file', file) + formData.append('provider', selectedProvider) + + const res = await fetch( + `/api/data-import?endpoint=${encodeURIComponent(`data-import/${projectId}/upload`)}`, + { + method: 'POST', + body: formData, + }, + ) + + const result = await res.json() + + if (!res.ok) { + toast.error( + result.error || t('project.settings.dataImport.uploadFailed'), + ) + return + } + + toast.success(t('project.settings.dataImport.importStarted')) + setSelectedProvider(null) + fetchImports() + } catch { + toast.error(t('project.settings.dataImport.uploadFailed')) + } finally { + setUploading(false) + } + } + + const handleDelete = async () => { + if (!deleteTarget) return + + setDeleting(true) + + try { + const res = await fetch( + `/api/data-import?endpoint=${encodeURIComponent(`data-import/${projectId}/${deleteTarget.id}`)}`, + { method: 'DELETE' }, + ) + + const result = await res.json() + + if (!res.ok) { + toast.error( + result.error || t('project.settings.dataImport.deleteFailed'), + ) + return + } + + toast.success(t('project.settings.dataImport.importDeleted')) + setDeleteTarget(null) + fetchImports() + } catch { + toast.error(t('project.settings.dataImport.deleteFailed')) + } finally { + setDeleting(false) + } + } + + const providerFileType = selectedProvider + ? t(`project.settings.dataImport.${selectedProvider}.fileType`) + : '' + const providerExportGuide = selectedProvider + ? t(`project.settings.dataImport.${selectedProvider}.exportGuide`) + : '' + + return ( +
+
+ + {t('project.settings.dataImport.importFrom')} + + + {t('project.settings.dataImport.importFromDesc')} + + +
+ {PROVIDERS.map((providerId) => { + const Icon = providerId === 'umami' ? UmamiSVG : FileTextIcon + const hasActive = imports.some( + (i) => i.status === 'pending' || i.status === 'processing', + ) + const name = + providerId.charAt(0).toUpperCase() + providerId.slice(1) + const description = t( + `project.settings.dataImport.${providerId}.description`, + ) + const fileType = t( + `project.settings.dataImport.${providerId}.fileType`, + ) + + return ( + + ) + })} +
+
+ +
+ + {t('project.settings.dataImport.importHistory')} + + + {loading ? ( +
+ +
+ ) : imports.length === 0 ? ( +
+ + + {t('project.settings.dataImport.noImports')} + +
+ ) : ( +
+ + + + + + + + + + + + {imports.map((imp) => ( + + + + + + + + + ))} + +
+ {t('project.settings.dataImport.provider')} + + {t('project.settings.dataImport.status')} + + {t('project.settings.dataImport.dateRange')} + + {t('project.settings.dataImport.rows')} + + {t('project.settings.dataImport.created')} + +
+ {imp.provider} + + + {imp.status === 'failed' && imp.errorMessage && ( + + {imp.errorMessage} + + )} + + {imp.dateFrom && imp.dateTo ? ( + <> + {dayjs(imp.dateFrom).format('MMM D, YYYY')} + {' — '} + {dayjs(imp.dateTo).format('MMM D, YYYY')} + + ) : ( + '—' + )} + + {imp.importedRows > 0 + ? imp.importedRows.toLocaleString() + : '—'} + + {dayjs(imp.createdAt).format('MMM D, YYYY HH:mm')} + + {(imp.status === 'completed' || + imp.status === 'failed') && ( + + )} +
+
+ )} +
+ + { + if (!uploading) setSelectedProvider(null) + }} + title={t('project.settings.dataImport.importFromProvider', { + provider: selectedProvider + ? selectedProvider.charAt(0).toUpperCase() + + selectedProvider.slice(1) + : '', + })} + size='medium' + message={ +
+
+ + {t('project.settings.dataImport.howToExport')} + + + {providerExportGuide} + +
+ + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
{ + if (!uploading) fileInputRef.current?.click() + }} + onDragOver={(e) => { + e.preventDefault() + e.stopPropagation() + }} + onDrop={(e) => { + e.preventDefault() + e.stopPropagation() + if (uploading) return + const file = e.dataTransfer.files?.[0] + if (file) handleUpload(file) + }} + > + { + const file = e.target.files?.[0] + if (file) handleUpload(file) + e.target.value = '' + }} + disabled={uploading} + /> + + {uploading ? ( +
+ + + {t('project.settings.dataImport.uploadingProcessing')} + +
+ ) : ( +
+ + + + {t('project.settings.dataImport.clickToUpload')} + {' '} + {t('project.settings.dataImport.orDragDrop')} + + + {t('project.settings.dataImport.maxFileSize', { + fileType: providerFileType.toUpperCase(), + })} + +
+ )} +
+
+ } + closeText={t('common.cancel')} + /> + + { + if (!deleting) setDeleteTarget(null) + }} + type='warning' + title={t('project.settings.dataImport.deleteImport')} + message={ + + {t('project.settings.dataImport.deleteImportConfirm', { + count: deleteTarget?.importedRows || 0, + })} + + } + submitText={t('common.delete')} + closeText={t('common.cancel')} + submitType='danger' + onSubmit={handleDelete} + isLoading={deleting} + /> +
+ ) +} diff --git a/web/app/pages/Project/View/ViewProject.tsx b/web/app/pages/Project/View/ViewProject.tsx index cb81011d9..4be98a69f 100644 --- a/web/app/pages/Project/View/ViewProject.tsx +++ b/web/app/pages/Project/View/ViewProject.tsx @@ -164,6 +164,8 @@ interface ViewProjectContextType { resetDateRange: () => void refreshStats: (isManual?: boolean) => Promise + hasImportedData: boolean + refCalendar: React.RefObject refCalendarCompare: React.RefObject @@ -225,6 +227,8 @@ const defaultViewProjectContext: ViewProjectContextType = { setShowFiltersSearch: () => {}, resetDateRange: () => {}, refreshStats: async () => {}, + hasImportedData: false, + refCalendar: { current: null } as any, refCalendarCompare: { current: null } as any, @@ -419,6 +423,67 @@ const ViewProjectContent = () => { return null }, [period, searchParams]) + const [hasImportedData, setHasImportedData] = useState(false) + + useEffect(() => { + if (!id) return + + let from: string + let to: string + + if (dateRange && dateRange.length >= 2) { + from = dayjs(dateRange[0]).format('YYYY-MM-DD HH:mm:ss') + to = dayjs(dateRange[1]).format('YYYY-MM-DD HH:mm:ss') + } else { + const now = dayjs() + to = now.format('YYYY-MM-DD HH:mm:ss') + + const periodMap: Record = { + '1h': [1, 'hour'], + today: [0, 'day'], + yesterday: [1, 'day'], + '1d': [1, 'day'], + '7d': [7, 'day'], + '4w': [4, 'week'], + '3M': [3, 'month'], + '12M': [12, 'month'], + '24M': [24, 'month'], + } + + const mapping = periodMap[period] + if (mapping) { + from = now + .subtract(mapping[0], mapping[1]) + .format('YYYY-MM-DD HH:mm:ss') + if (period === 'today') { + from = now.startOf('day').format('YYYY-MM-DD HH:mm:ss') + } else if (period === 'yesterday') { + from = now + .subtract(1, 'day') + .startOf('day') + .format('YYYY-MM-DD HH:mm:ss') + to = now.subtract(1, 'day').endOf('day').format('YYYY-MM-DD HH:mm:ss') + } + } else if (period === 'all') { + from = '2020-01-01 00:00:00' + } else { + setHasImportedData(false) + return + } + } + + const endpoint = `data-import/${id}/has-imported-data?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}` + + fetch(`/api/data-import?endpoint=${encodeURIComponent(endpoint)}`) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + setHasImportedData(data?.hasImportedData === true) + }) + .catch(() => { + setHasImportedData(false) + }) + }, [id, dateRange, period]) + const periodPairs = useMemo(() => { let tbs = null @@ -1191,6 +1256,8 @@ const ViewProjectContent = () => { resetDateRange, refreshStats, + hasImportedData, + refCalendar, refCalendarCompare, fullscreenMapRef, @@ -1232,6 +1299,7 @@ const ViewProjectContent = () => { setShowFiltersSearch, resetDateRange, refreshStats, + hasImportedData, refCalendar, refCalendarCompare, fullscreenMapRef, diff --git a/web/app/pages/Project/tabs/Traffic/TrafficView.tsx b/web/app/pages/Project/tabs/Traffic/TrafficView.tsx index c7cb9a1ea..af949c75e 100644 --- a/web/app/pages/Project/tabs/Traffic/TrafficView.tsx +++ b/web/app/pages/Project/tabs/Traffic/TrafficView.tsx @@ -6,7 +6,7 @@ import _isEmpty from 'lodash/isEmpty' import _keys from 'lodash/keys' import _map from 'lodash/map' import _some from 'lodash/some' -import { ProhibitIcon, EyeIcon } from '@phosphor-icons/react' +import { ProhibitIcon, EyeIcon, DownloadIcon } from '@phosphor-icons/react' import React, { useState, useEffect, @@ -96,6 +96,7 @@ import Checkbox from '~/ui/Checkbox' import Dropdown from '~/ui/Dropdown' import Loader from '~/ui/Loader' import LoadingBar from '~/ui/LoadingBar' +import Tooltip from '~/ui/Tooltip' import { getLocaleDisplayName, nLocaleFormatter } from '~/utils/generic' import { groupRefEntries } from '~/utils/referrers' import routes from '~/utils/routes' @@ -285,6 +286,7 @@ const TrafficViewInner = ({ isMapFullscreen, setIsMapFullscreen, fullscreenMapRef, + hasImportedData, } = useViewProjectContext() const { t, @@ -1055,6 +1057,16 @@ const TrafficViewInner = ({ onSwitch={setChartTypeOnClick} type={chartType} /> + {hasImportedData ? ( + + + + } + /> + ) : null}
{!_isEmpty(overall) ? (
diff --git a/web/app/routes/api.data-import.ts b/web/app/routes/api.data-import.ts new file mode 100644 index 000000000..9da7f237a --- /dev/null +++ b/web/app/routes/api.data-import.ts @@ -0,0 +1,66 @@ +import { + type ActionFunctionArgs, + type LoaderFunctionArgs, + data, +} from 'react-router' + +import { serverFetch } from '~/api/api.server' + +function toResponse(result: Awaited>) { + if (result.error) { + return data({ error: result.error }, { status: result.status }) + } + + return data(result.data, { + headers: result.cookies.length + ? { 'Set-Cookie': result.cookies.join(', ') } + : undefined, + }) +} + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url) + const endpoint = url.searchParams.get('endpoint') + + if (!endpoint) { + return data({ error: 'endpoint param is required' }, { status: 400 }) + } + + return toResponse(await serverFetch(request, endpoint)) +} + +export async function action({ request }: ActionFunctionArgs) { + const url = new URL(request.url) + const endpoint = url.searchParams.get('endpoint') + + if (!endpoint) { + return data({ error: 'endpoint param is required' }, { status: 400 }) + } + + if (request.method === 'DELETE') { + return toResponse( + await serverFetch(request, endpoint, { method: 'DELETE' }), + ) + } + + const contentType = request.headers.get('content-type') || '' + + if (contentType.includes('multipart/form-data')) { + const formData = await request.formData() + + const proxyForm = new FormData() + for (const [key, value] of formData.entries()) { + if (value instanceof File) { + proxyForm.append(key, value, value.name) + } else { + proxyForm.append(key, value) + } + } + + return toResponse( + await serverFetch(request, endpoint, { method: 'POST', body: proxyForm }), + ) + } + + return data({ error: 'Unsupported content type' }, { status: 400 }) +} diff --git a/web/app/ui/icons/Umami.tsx b/web/app/ui/icons/Umami.tsx new file mode 100644 index 000000000..18d017436 --- /dev/null +++ b/web/app/ui/icons/Umami.tsx @@ -0,0 +1,20 @@ +const UmamiSVG = ({ className }: { className?: string }) => ( + + + + + + + + +) + +export default UmamiSVG diff --git a/web/public/locales/en.json b/web/public/locales/en.json index 96bd490c2..195f4a86f 100644 --- a/web/public/locales/en.json +++ b/web/public/locales/en.json @@ -1702,7 +1702,9 @@ "integrations": "Integrations", "integrationsDesc": "Connect external services to enrich your analytics.", "revenue": "Revenue", - "revenueDesc": "Track revenue and attribute sales to campaigns." + "revenueDesc": "Track revenue and attribute sales to campaigns.", + "import": "Import data", + "importDesc": "Import analytics data from other platforms" }, "revenue": { "title": "Revenue Tracking", @@ -1734,6 +1736,40 @@ "paddleExample": "Paddle Checkout Integration:" } }, + "dataImport": { + "importFrom": "Import from", + "importFromDesc": "Select an analytics provider to import historical data from.", + "importHistory": "Import history", + "noImports": "No imports yet. Select a provider above to get started.", + "provider": "Provider", + "status": "Status", + "dateRange": "Date range", + "rows": "Rows", + "created": "Created", + "deleteImport": "Delete import", + "deleteImportConfirm": "This will permanently remove all imported data ({{count}} rows) from your analytics. This action cannot be undone.", + "importStarted": "Import started. Your data is being processed.", + "importDeleted": "Import deleted successfully", + "uploadFailed": "Failed to upload file. Please try again.", + "deleteFailed": "Failed to delete import", + "clickToUpload": "Click to upload", + "orDragDrop": "or drag and drop", + "maxFileSize": "{{fileType}} file, max 100 MB", + "uploadingProcessing": "Uploading and processing...", + "howToExport": "How to export your data", + "importFromProvider": "Import from {{provider}}", + "statsIncludeImported": "Stats include imported data", + "imported": "Imported", + "statusPending": "Pending", + "statusProcessing": "Processing", + "statusCompleted": "Completed", + "statusFailed": "Failed", + "umami": { + "description": "Import pageviews and custom events from Umami export", + "fileType": ".zip", + "exportGuide": "In Umami, go to Settings → Websites → click your site → Data → Export. Upload the resulting .zip file directly." + } + }, "captcha": { "title": "CAPTCHA Integration", "description": "Enable CAPTCHA protection for your forms and actions. Generate a secret key to integrate Swetrix CAPTCHA with your application.", From c8969633e30f9e477b4ad92857bc91a07abbf9bd Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 17:01:50 +0100 Subject: [PATCH 02/29] Prevent Uncontrolled data used in path expression --- backend/apps/cloud/src/data-import/data-import.controller.ts | 3 ++- .../apps/community/src/data-import/data-import.controller.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/apps/cloud/src/data-import/data-import.controller.ts b/backend/apps/cloud/src/data-import/data-import.controller.ts index b1179d52b..a68569add 100644 --- a/backend/apps/cloud/src/data-import/data-import.controller.ts +++ b/backend/apps/cloud/src/data-import/data-import.controller.ts @@ -1,6 +1,7 @@ import * as fs from 'fs' import * as os from 'os' import * as path from 'path' +import { randomUUID } from 'crypto' import { Controller, Post, @@ -93,7 +94,7 @@ export class DataImportController { if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }) } - const tempFile = path.join(tempDir, `${projectId}-${Date.now()}${ext}`) + const tempFile = path.join(tempDir, `${Date.now()}-${randomUUID()}${ext}`) fs.writeFileSync(tempFile, file.buffer) const dataImport = await this.dataImportService.create( diff --git a/backend/apps/community/src/data-import/data-import.controller.ts b/backend/apps/community/src/data-import/data-import.controller.ts index 952f434f9..e8ce62670 100644 --- a/backend/apps/community/src/data-import/data-import.controller.ts +++ b/backend/apps/community/src/data-import/data-import.controller.ts @@ -1,6 +1,7 @@ import * as fs from 'fs' import * as os from 'os' import * as path from 'path' +import { randomUUID } from 'crypto' import { Controller, Post, @@ -89,7 +90,7 @@ export class DataImportController { if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }) } - const tempFile = path.join(tempDir, `${projectId}-${Date.now()}${ext}`) + const tempFile = path.join(tempDir, `${Date.now()}-${randomUUID()}${ext}`) fs.writeFileSync(tempFile, file.buffer) const dataImport = await this.dataImportService.create( From 465cb3c55947927fa4906629be5fa29de234c94f Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 17:12:01 +0100 Subject: [PATCH 03/29] fix: Upload file streaming --- .../src/data-import/data-import.controller.ts | 27 ++++++++++++------- .../src/data-import/data-import.controller.ts | 27 ++++++++++++------- 2 files changed, 34 insertions(+), 20 deletions(-) diff --git a/backend/apps/cloud/src/data-import/data-import.controller.ts b/backend/apps/cloud/src/data-import/data-import.controller.ts index a68569add..519b636ad 100644 --- a/backend/apps/cloud/src/data-import/data-import.controller.ts +++ b/backend/apps/cloud/src/data-import/data-import.controller.ts @@ -17,6 +17,7 @@ import { Logger, } from '@nestjs/common' import { FileInterceptor } from '@nestjs/platform-express' +import { diskStorage } from 'multer' import { ApiBearerAuth, ApiTags, ApiConsumes, ApiBody } from '@nestjs/swagger' import { InjectQueue } from '@nestjs/bullmq' import { Queue } from 'bullmq' @@ -61,6 +62,17 @@ export class DataImportController { @UseInterceptors( FileInterceptor('file', { limits: { fileSize: MAX_FILE_SIZE }, + storage: diskStorage({ + destination: (_req, _file, cb) => { + const dir = path.join(os.tmpdir(), 'swetrix-imports') + fs.mkdirSync(dir, { recursive: true }) + cb(null, dir) + }, + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase() + cb(null, `${Date.now()}-${randomUUID()}${ext}`) + }, + }), }), ) async upload( @@ -72,12 +84,13 @@ export class DataImportController { const project = await this.projectService.getFullProject(projectId) this.projectService.allowedToManage(project, uid) - if (!file || !file.buffer) { + if (!file?.path) { throw new BadRequestException('No file uploaded') } const mapper = getMapper(dto.provider) if (!mapper) { + this.cleanupFile(file.path) throw new BadRequestException( `Unsupported provider: ${dto.provider}. Supported: ${SUPPORTED_PROVIDERS.join(', ')}`, ) @@ -85,18 +98,12 @@ export class DataImportController { const ext = path.extname(file.originalname).toLowerCase() if (ext !== mapper.expectedFileExtension) { + this.cleanupFile(file.path) throw new BadRequestException( `Invalid file type. Expected a ${mapper.expectedFileExtension} file for ${dto.provider} import.`, ) } - const tempDir = path.join(os.tmpdir(), 'swetrix-imports') - if (!fs.existsSync(tempDir)) { - fs.mkdirSync(tempDir, { recursive: true }) - } - const tempFile = path.join(tempDir, `${Date.now()}-${randomUUID()}${ext}`) - fs.writeFileSync(tempFile, file.buffer) - const dataImport = await this.dataImportService.create( projectId, dto.provider, @@ -110,7 +117,7 @@ export class DataImportController { importId: dataImport.importId, projectId, provider: dto.provider, - filePath: tempFile, + filePath: file.path, }, { attempts: 1, @@ -121,7 +128,7 @@ export class DataImportController { } catch (error) { this.logger.error(`Failed to enqueue import job: ${error.message}`) await this.dataImportService.deleteImportRecord(dataImport.id) - this.cleanupFile(tempFile) + this.cleanupFile(file.path) throw new BadRequestException( 'Failed to start import. Please try again later.', ) diff --git a/backend/apps/community/src/data-import/data-import.controller.ts b/backend/apps/community/src/data-import/data-import.controller.ts index e8ce62670..8482a735f 100644 --- a/backend/apps/community/src/data-import/data-import.controller.ts +++ b/backend/apps/community/src/data-import/data-import.controller.ts @@ -16,6 +16,7 @@ import { Logger, } from '@nestjs/common' import { FileInterceptor } from '@nestjs/platform-express' +import { diskStorage } from 'multer' import { ApiTags, ApiConsumes, ApiBody } from '@nestjs/swagger' import { InjectQueue } from '@nestjs/bullmq' import { Queue } from 'bullmq' @@ -57,6 +58,17 @@ export class DataImportController { @UseInterceptors( FileInterceptor('file', { limits: { fileSize: MAX_FILE_SIZE }, + storage: diskStorage({ + destination: (_req, _file, cb) => { + const dir = path.join(os.tmpdir(), 'swetrix-imports') + fs.mkdirSync(dir, { recursive: true }) + cb(null, dir) + }, + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase() + cb(null, `${Date.now()}-${randomUUID()}${ext}`) + }, + }), }), ) async upload( @@ -68,12 +80,13 @@ export class DataImportController { const project = await this.projectService.getFullProject(projectId) this.projectService.allowedToManage(project, uid) - if (!file || !file.buffer) { + if (!file?.path) { throw new BadRequestException('No file uploaded') } const mapper = getMapper(dto.provider) if (!mapper) { + this.cleanupFile(file.path) throw new BadRequestException( `Unsupported provider: ${dto.provider}. Supported: ${SUPPORTED_PROVIDERS.join(', ')}`, ) @@ -81,18 +94,12 @@ export class DataImportController { const ext = path.extname(file.originalname).toLowerCase() if (ext !== mapper.expectedFileExtension) { + this.cleanupFile(file.path) throw new BadRequestException( `Invalid file type. Expected a ${mapper.expectedFileExtension} file for ${dto.provider} import.`, ) } - const tempDir = path.join(os.tmpdir(), 'swetrix-imports') - if (!fs.existsSync(tempDir)) { - fs.mkdirSync(tempDir, { recursive: true }) - } - const tempFile = path.join(tempDir, `${Date.now()}-${randomUUID()}${ext}`) - fs.writeFileSync(tempFile, file.buffer) - const dataImport = await this.dataImportService.create( projectId, dto.provider, @@ -105,7 +112,7 @@ export class DataImportController { importId: dataImport.id, projectId, provider: dto.provider, - filePath: tempFile, + filePath: file.path, }, { attempts: 1, @@ -116,7 +123,7 @@ export class DataImportController { } catch (error) { this.logger.error(`Failed to enqueue import job: ${error.message}`) await this.dataImportService.deleteImportRecord(projectId, dataImport.id) - this.cleanupFile(tempFile) + this.cleanupFile(file.path) throw new BadRequestException( 'Failed to start import. Please try again later.', ) From 53459d594f6d8ebb9644200fbf86ed15a080a5b8 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 17:17:08 +0100 Subject: [PATCH 04/29] fix: Files clean-up handling --- .../src/data-import/data-import.controller.ts | 11 +- .../src/data-import/data-import.processor.ts | 162 ++++++++-------- .../src/data-import/data-import.controller.ts | 11 +- .../src/data-import/data-import.processor.ts | 175 +++++++++--------- 4 files changed, 188 insertions(+), 171 deletions(-) diff --git a/backend/apps/cloud/src/data-import/data-import.controller.ts b/backend/apps/cloud/src/data-import/data-import.controller.ts index 519b636ad..8f4cd8d8d 100644 --- a/backend/apps/cloud/src/data-import/data-import.controller.ts +++ b/backend/apps/cloud/src/data-import/data-import.controller.ts @@ -104,10 +104,13 @@ export class DataImportController { ) } - const dataImport = await this.dataImportService.create( - projectId, - dto.provider, - ) + let dataImport + try { + dataImport = await this.dataImportService.create(projectId, dto.provider) + } catch (error) { + this.cleanupFile(file.path) + throw error + } try { await this.importQueue.add( diff --git a/backend/apps/cloud/src/data-import/data-import.processor.ts b/backend/apps/cloud/src/data-import/data-import.processor.ts index 8555dfbe5..c45fb3693 100644 --- a/backend/apps/cloud/src/data-import/data-import.processor.ts +++ b/backend/apps/cloud/src/data-import/data-import.processor.ts @@ -29,102 +29,108 @@ export class DataImportProcessor extends WorkerHost { async process(job: Job): Promise { const { id, importId, projectId, provider, filePath } = job.data - this.logger.log( - `Processing import ${id} (importId=${importId}) for project ${projectId} (provider: ${provider})`, - ) - - await this.dataImportService.markProcessing(id) - - const mapper = getMapper(provider) - if (!mapper) { - await this.dataImportService.markFailed( - id, - `Unsupported provider: ${provider}`, + try { + this.logger.log( + `Processing import ${id} (importId=${importId}) for project ${projectId} (provider: ${provider})`, ) - return - } - let importedRows = 0 - let totalRows = 0 - let minDate: string | null = null - let maxDate: string | null = null + await this.dataImportService.markProcessing(id) - const analyticsBatch: Record[] = [] - const customEVBatch: Record[] = [] + const mapper = getMapper(provider) + if (!mapper) { + await this.dataImportService.markFailed( + id, + `Unsupported provider: ${provider}`, + ) + return + } - try { - for await (const row of mapper.createRowStream( - filePath, - projectId, - importId, - )) { - totalRows++ - - const created = row.data.created as string - if (created) { - if (!minDate || created < minDate) minDate = created - if (!maxDate || created > maxDate) maxDate = created - } + let importedRows = 0 + let totalRows = 0 + let minDate: string | null = null + let maxDate: string | null = null + + const analyticsBatch: Record[] = [] + const customEVBatch: Record[] = [] - if (row.table === 'analytics') { - analyticsBatch.push(row.data) - } else { - customEVBatch.push(row.data) + try { + for await (const row of mapper.createRowStream( + filePath, + projectId, + importId, + )) { + totalRows++ + + const created = row.data.created as string + if (created) { + if (!minDate || created < minDate) minDate = created + if (!maxDate || created > maxDate) maxDate = created + } + + if (row.table === 'analytics') { + analyticsBatch.push(row.data) + } else { + customEVBatch.push(row.data) + } + + if (analyticsBatch.length >= BATCH_SIZE) { + await this.flushBatch('analytics', analyticsBatch) + importedRows += analyticsBatch.length + analyticsBatch.length = 0 + } + + if (customEVBatch.length >= BATCH_SIZE) { + await this.flushBatch('customEV', customEVBatch) + importedRows += customEVBatch.length + customEVBatch.length = 0 + } + + if (totalRows % 10000 === 0) { + await job.updateProgress({ + importedRows: + importedRows + analyticsBatch.length + customEVBatch.length, + totalRows, + }) + } } - if (analyticsBatch.length >= BATCH_SIZE) { + if (analyticsBatch.length > 0) { await this.flushBatch('analytics', analyticsBatch) importedRows += analyticsBatch.length - analyticsBatch.length = 0 } - if (customEVBatch.length >= BATCH_SIZE) { + if (customEVBatch.length > 0) { await this.flushBatch('customEV', customEVBatch) importedRows += customEVBatch.length - customEVBatch.length = 0 - } - - if (totalRows % 10000 === 0) { - await job.updateProgress({ - importedRows: - importedRows + analyticsBatch.length + customEVBatch.length, - totalRows, - }) } - } - - if (analyticsBatch.length > 0) { - await this.flushBatch('analytics', analyticsBatch) - importedRows += analyticsBatch.length - } - - if (customEVBatch.length > 0) { - await this.flushBatch('customEV', customEVBatch) - importedRows += customEVBatch.length - } - await this.dataImportService.markCompleted(id, { - importedRows, - invalidRows: 0, - totalRows: importedRows, - dateFrom: minDate ? new Date(minDate) : null, - dateTo: maxDate ? new Date(maxDate) : null, - }) + await this.dataImportService.markCompleted(id, { + importedRows, + invalidRows: 0, + totalRows: importedRows, + dateFrom: minDate ? new Date(minDate) : null, + dateTo: maxDate ? new Date(maxDate) : null, + }) - this.logger.log(`Import ${id} completed: ${importedRows} rows imported`) - } catch (error) { - this.logger.error(`Import ${id} failed: ${error.message}`, error.stack) - - // Rollback any partially inserted rows - try { - await this.dataImportService.cleanupImportedRows(projectId, importId) - } catch (cleanupError) { - this.logger.error( - `Failed to rollback partial import ${id}: ${cleanupError.message}`, + this.logger.log( + `Import ${id} completed: ${importedRows} rows imported`, ) - } + } catch (error) { + this.logger.error(`Import ${id} failed: ${error.message}`, error.stack) + + try { + await this.dataImportService.cleanupImportedRows( + projectId, + importId, + ) + } catch (cleanupError) { + this.logger.error( + `Failed to rollback partial import ${id}: ${cleanupError.message}`, + ) + } - await this.dataImportService.markFailed(id, error.message) + await this.dataImportService.markFailed(id, error.message) + } } finally { this.cleanupFile(filePath) } diff --git a/backend/apps/community/src/data-import/data-import.controller.ts b/backend/apps/community/src/data-import/data-import.controller.ts index 8482a735f..4ba84c384 100644 --- a/backend/apps/community/src/data-import/data-import.controller.ts +++ b/backend/apps/community/src/data-import/data-import.controller.ts @@ -100,10 +100,13 @@ export class DataImportController { ) } - const dataImport = await this.dataImportService.create( - projectId, - dto.provider, - ) + let dataImport + try { + dataImport = await this.dataImportService.create(projectId, dto.provider) + } catch (error) { + this.cleanupFile(file.path) + throw error + } try { await this.importQueue.add( diff --git a/backend/apps/community/src/data-import/data-import.processor.ts b/backend/apps/community/src/data-import/data-import.processor.ts index 63cdc97fd..d877dc031 100644 --- a/backend/apps/community/src/data-import/data-import.processor.ts +++ b/backend/apps/community/src/data-import/data-import.processor.ts @@ -28,111 +28,116 @@ export class DataImportProcessor extends WorkerHost { async process(job: Job): Promise { const { importId, projectId, provider, filePath } = job.data - this.logger.log( - `Processing import ${importId} for project ${projectId} (provider: ${provider})`, - ) - - await this.dataImportService.markProcessing(importId, projectId) - - const mapper = getMapper(provider) - if (!mapper) { - await this.dataImportService.markFailed( - importId, - projectId, - `Unsupported provider: ${provider}`, + try { + this.logger.log( + `Processing import ${importId} for project ${projectId} (provider: ${provider})`, ) - return - } - let importedRows = 0 - let totalRows = 0 - let minDate: string | null = null - let maxDate: string | null = null + await this.dataImportService.markProcessing(importId, projectId) - const analyticsBatch: Record[] = [] - const customEVBatch: Record[] = [] + const mapper = getMapper(provider) + if (!mapper) { + await this.dataImportService.markFailed( + importId, + projectId, + `Unsupported provider: ${provider}`, + ) + return + } - try { - for await (const row of mapper.createRowStream( - filePath, - projectId, - importId, - )) { - totalRows++ - - const created = row.data.created as string - if (created) { - if (!minDate || created < minDate) minDate = created - if (!maxDate || created > maxDate) maxDate = created - } + let importedRows = 0 + let totalRows = 0 + let minDate: string | null = null + let maxDate: string | null = null - if (row.table === 'analytics') { - analyticsBatch.push(row.data) - } else { - customEVBatch.push(row.data) + const analyticsBatch: Record[] = [] + const customEVBatch: Record[] = [] + + try { + for await (const row of mapper.createRowStream( + filePath, + projectId, + importId, + )) { + totalRows++ + + const created = row.data.created as string + if (created) { + if (!minDate || created < minDate) minDate = created + if (!maxDate || created > maxDate) maxDate = created + } + + if (row.table === 'analytics') { + analyticsBatch.push(row.data) + } else { + customEVBatch.push(row.data) + } + + if (analyticsBatch.length >= BATCH_SIZE) { + await this.flushBatch('analytics', analyticsBatch) + importedRows += analyticsBatch.length + analyticsBatch.length = 0 + } + + if (customEVBatch.length >= BATCH_SIZE) { + await this.flushBatch('customEV', customEVBatch) + importedRows += customEVBatch.length + customEVBatch.length = 0 + } + + if (totalRows % 10000 === 0) { + await job.updateProgress({ + importedRows: + importedRows + analyticsBatch.length + customEVBatch.length, + totalRows, + }) + } } - if (analyticsBatch.length >= BATCH_SIZE) { + if (analyticsBatch.length > 0) { await this.flushBatch('analytics', analyticsBatch) importedRows += analyticsBatch.length - analyticsBatch.length = 0 } - if (customEVBatch.length >= BATCH_SIZE) { + if (customEVBatch.length > 0) { await this.flushBatch('customEV', customEVBatch) importedRows += customEVBatch.length - customEVBatch.length = 0 - } - - if (totalRows % 10000 === 0) { - await job.updateProgress({ - importedRows: - importedRows + analyticsBatch.length + customEVBatch.length, - totalRows, - }) } - } - - if (analyticsBatch.length > 0) { - await this.flushBatch('analytics', analyticsBatch) - importedRows += analyticsBatch.length - } - if (customEVBatch.length > 0) { - await this.flushBatch('customEV', customEVBatch) - importedRows += customEVBatch.length - } + await this.dataImportService.markCompleted(importId, projectId, { + importedRows, + invalidRows: 0, + totalRows: importedRows, + dateFrom: minDate ? minDate.split(' ')[0] : null, + dateTo: maxDate ? maxDate.split(' ')[0] : null, + }) - await this.dataImportService.markCompleted(importId, projectId, { - importedRows, - invalidRows: 0, - totalRows: importedRows, - dateFrom: minDate ? minDate.split(' ')[0] : null, - dateTo: maxDate ? maxDate.split(' ')[0] : null, - }) + this.logger.log( + `Import ${importId} completed: ${importedRows} rows imported`, + ) + } catch (error) { + this.logger.error( + `Import ${importId} failed: ${error.message}`, + error.stack, + ) - this.logger.log( - `Import ${importId} completed: ${importedRows} rows imported`, - ) - } catch (error) { - this.logger.error( - `Import ${importId} failed: ${error.message}`, - error.stack, - ) + try { + await this.dataImportService.cleanupImportedRows( + projectId, + importId, + ) + } catch (cleanupError) { + this.logger.error( + `Failed to rollback partial import ${importId}: ${cleanupError.message}`, + ) + } - try { - await this.dataImportService.cleanupImportedRows(projectId, importId) - } catch (cleanupError) { - this.logger.error( - `Failed to rollback partial import ${importId}: ${cleanupError.message}`, + await this.dataImportService.markFailed( + importId, + projectId, + error.message, ) } - - await this.dataImportService.markFailed( - importId, - projectId, - error.message, - ) } finally { this.cleanupFile(filePath) } From ea20fbbd5e921658e7934ba1648549700095f0cf Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 17:18:39 +0100 Subject: [PATCH 05/29] fix: Optimise import ID calculation --- .../src/data-import/data-import.service.ts | 24 +++++++++++++++---- .../src/data-import/data-import.service.ts | 21 +++++++++++++--- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/backend/apps/cloud/src/data-import/data-import.service.ts b/backend/apps/cloud/src/data-import/data-import.service.ts index d6721bed8..65da68a73 100644 --- a/backend/apps/cloud/src/data-import/data-import.service.ts +++ b/backend/apps/cloud/src/data-import/data-import.service.ts @@ -26,13 +26,29 @@ export class DataImportService { ) {} private async getNextImportId(projectId: string): Promise { - const result = await this.dataImportRepository + const existingImportIds = await this.dataImportRepository .createQueryBuilder('di') - .select('MAX(di.importId)', 'maxId') + .select('di.importId', 'importId') .where('di.projectId = :projectId', { projectId }) - .getRawOne<{ maxId: number | null }>() + .orderBy('di.importId', 'ASC') + .getRawMany<{ importId: number | string }>() - const next = (result?.maxId ?? 0) + 1 + let next = 1 + + for (const { importId } of existingImportIds) { + const currentImportId = Number(importId) + + if (currentImportId < next) { + continue + } + + if (currentImportId === next) { + next += 1 + continue + } + + break + } if (next > MAX_IMPORT_ID) { throw new BadRequestException( diff --git a/backend/apps/community/src/data-import/data-import.service.ts b/backend/apps/community/src/data-import/data-import.service.ts index b1fd9b7c7..f7f4bd12d 100644 --- a/backend/apps/community/src/data-import/data-import.service.ts +++ b/backend/apps/community/src/data-import/data-import.service.ts @@ -21,12 +21,27 @@ export class DataImportService { private async getNextId(projectId: string): Promise { const result = await clickhouse .query({ - query: `SELECT max(id) as maxId FROM ${CLICKHOUSE_DB}.data_import FINAL WHERE projectId = {projectId:FixedString(12)}`, + query: `SELECT id FROM ${CLICKHOUSE_DB}.data_import FINAL WHERE projectId = {projectId:FixedString(12)} ORDER BY id ASC`, query_params: { projectId }, }) - .then((rs) => rs.json<{ maxId: number }>()) + .then((rs) => rs.json<{ id: number | string }>()) - const next = (result.data?.[0]?.maxId || 0) + 1 + let next = 1 + + for (const row of result.data || []) { + const currentId = Number(row.id) + + if (currentId < next) { + continue + } + + if (currentId === next) { + next += 1 + continue + } + + break + } if (next > MAX_IMPORT_ID) { throw new BadRequestException( From 6356e4152a8105867f37e8d370ddc744f3369276 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 17:26:21 +0100 Subject: [PATCH 06/29] fix: one active import guard is not atomic --- .../src/data-import/data-import.processor.ts | 9 +- .../src/data-import/data-import.service.ts | 57 ++++--- .../src/data-import/data-import.processor.ts | 5 +- .../src/data-import/data-import.service.ts | 152 ++++++++++++------ 4 files changed, 142 insertions(+), 81 deletions(-) diff --git a/backend/apps/cloud/src/data-import/data-import.processor.ts b/backend/apps/cloud/src/data-import/data-import.processor.ts index c45fb3693..bce677e2a 100644 --- a/backend/apps/cloud/src/data-import/data-import.processor.ts +++ b/backend/apps/cloud/src/data-import/data-import.processor.ts @@ -112,17 +112,12 @@ export class DataImportProcessor extends WorkerHost { dateTo: maxDate ? new Date(maxDate) : null, }) - this.logger.log( - `Import ${id} completed: ${importedRows} rows imported`, - ) + this.logger.log(`Import ${id} completed: ${importedRows} rows imported`) } catch (error) { this.logger.error(`Import ${id} failed: ${error.message}`, error.stack) try { - await this.dataImportService.cleanupImportedRows( - projectId, - importId, - ) + await this.dataImportService.cleanupImportedRows(projectId, importId) } catch (cleanupError) { this.logger.error( `Failed to rollback partial import ${id}: ${cleanupError.message}`, diff --git a/backend/apps/cloud/src/data-import/data-import.service.ts b/backend/apps/cloud/src/data-import/data-import.service.ts index 65da68a73..37f0355e9 100644 --- a/backend/apps/cloud/src/data-import/data-import.service.ts +++ b/backend/apps/cloud/src/data-import/data-import.service.ts @@ -5,7 +5,7 @@ import { Logger, } from '@nestjs/common' import { InjectRepository } from '@nestjs/typeorm' -import { Repository } from 'typeorm' +import { DataSource, Repository } from 'typeorm' import { DataImport, @@ -13,6 +13,7 @@ import { MAX_IMPORT_ID, } from './entity/data-import.entity' import { clickhouse } from '../common/integrations/clickhouse' +import { Project } from '../project/entity/project.entity' const CLICKHOUSE_DB = process.env.CLICKHOUSE_DATABASE || 'analytics' @@ -23,10 +24,14 @@ export class DataImportService { constructor( @InjectRepository(DataImport) private readonly dataImportRepository: Repository, + private readonly dataSource: DataSource, ) {} - private async getNextImportId(projectId: string): Promise { - const existingImportIds = await this.dataImportRepository + private async getNextImportId( + repository: Repository, + projectId: string, + ): Promise { + const existingImportIds = await repository .createQueryBuilder('di') .select('di.importId', 'importId') .where('di.projectId = :projectId', { projectId }) @@ -60,29 +65,37 @@ export class DataImportService { } async create(projectId: string, provider: string): Promise { - const active = await this.dataImportRepository.findOne({ - where: [ - { projectId, status: DataImportStatus.PENDING }, - { projectId, status: DataImportStatus.PROCESSING }, - ], - }) + return this.dataSource.transaction(async (manager) => { + await manager.getRepository(Project).findOneOrFail({ + where: { id: projectId }, + lock: { mode: 'pessimistic_write' }, + }) - if (active) { - throw new BadRequestException( - 'An import is already in progress for this project. Please wait for it to complete before starting a new one.', - ) - } + const repository = manager.getRepository(DataImport) + const active = await repository.findOne({ + where: [ + { projectId, status: DataImportStatus.PENDING }, + { projectId, status: DataImportStatus.PROCESSING }, + ], + }) - const importId = await this.getNextImportId(projectId) + if (active) { + throw new BadRequestException( + 'An import is already in progress for this project. Please wait for it to complete before starting a new one.', + ) + } - const dataImport = this.dataImportRepository.create({ - projectId, - importId, - provider, - status: DataImportStatus.PENDING, - }) + const importId = await this.getNextImportId(repository, projectId) - return this.dataImportRepository.save(dataImport) + const dataImport = repository.create({ + projectId, + importId, + provider, + status: DataImportStatus.PENDING, + }) + + return repository.save(dataImport) + }) } async findAllByProject(projectId: string): Promise { diff --git a/backend/apps/community/src/data-import/data-import.processor.ts b/backend/apps/community/src/data-import/data-import.processor.ts index d877dc031..9055442f6 100644 --- a/backend/apps/community/src/data-import/data-import.processor.ts +++ b/backend/apps/community/src/data-import/data-import.processor.ts @@ -122,10 +122,7 @@ export class DataImportProcessor extends WorkerHost { ) try { - await this.dataImportService.cleanupImportedRows( - projectId, - importId, - ) + await this.dataImportService.cleanupImportedRows(projectId, importId) } catch (cleanupError) { this.logger.error( `Failed to rollback partial import ${importId}: ${cleanupError.message}`, diff --git a/backend/apps/community/src/data-import/data-import.service.ts b/backend/apps/community/src/data-import/data-import.service.ts index f7f4bd12d..ac9be0c50 100644 --- a/backend/apps/community/src/data-import/data-import.service.ts +++ b/backend/apps/community/src/data-import/data-import.service.ts @@ -4,6 +4,7 @@ import { NotFoundException, Logger, } from '@nestjs/common' +import { randomUUID } from 'crypto' import { DataImport, @@ -11,13 +12,64 @@ import { MAX_IMPORT_ID, } from './entity/data-import.entity' import { clickhouse } from '../common/integrations/clickhouse' +import { redis } from '../common/constants' const CLICKHOUSE_DB = process.env.CLICKHOUSE_DATABASE || 'analytics' +const IMPORT_IN_PROGRESS_ERROR = + 'An import is already in progress for this project. Please wait for it to complete before starting a new one.' +const IMPORT_CREATE_LOCK_TTL_MS = 10_000 +const IMPORT_CREATE_LOCK_WAIT_MS = 5_000 +const IMPORT_CREATE_LOCK_RETRY_MS = 100 @Injectable() export class DataImportService { private readonly logger = new Logger(DataImportService.name) + private getCreateLockKey(projectId: string): string { + return `data-import:create:${projectId}` + } + + private async acquireCreateLock(projectId: string): Promise { + const token = randomUUID() + const lockKey = this.getCreateLockKey(projectId) + const deadline = Date.now() + IMPORT_CREATE_LOCK_WAIT_MS + + while (Date.now() < deadline) { + const acquired = await redis.set( + lockKey, + token, + 'PX', + IMPORT_CREATE_LOCK_TTL_MS, + 'NX', + ) + + if (acquired === 'OK') { + return token + } + + await new Promise((resolve) => + setTimeout(resolve, IMPORT_CREATE_LOCK_RETRY_MS), + ) + } + + throw new BadRequestException(IMPORT_IN_PROGRESS_ERROR) + } + + private async releaseCreateLock( + projectId: string, + token: string, + ): Promise { + await redis.eval( + `if redis.call("GET", KEYS[1]) == ARGV[1] then + return redis.call("DEL", KEYS[1]) + end + return 0`, + 1, + this.getCreateLockKey(projectId), + token, + ) + } + private async getNextId(projectId: string): Promise { const result = await clickhouse .query({ @@ -53,57 +105,61 @@ export class DataImportService { } async create(projectId: string, provider: string): Promise { - const active = await this.findActiveByProject(projectId) - if (active) { - throw new BadRequestException( - 'An import is already in progress for this project. Please wait for it to complete before starting a new one.', - ) - } - - const id = await this.getNextId(projectId) + const lockToken = await this.acquireCreateLock(projectId) - const now = new Date() - .toISOString() - .replace('T', ' ') - .replace('Z', '') - .slice(0, 19) + try { + const active = await this.findActiveByProject(projectId) + if (active) { + throw new BadRequestException(IMPORT_IN_PROGRESS_ERROR) + } - await clickhouse.insert({ - table: `${CLICKHOUSE_DB}.data_import`, - values: [ - { - id, - projectId, - provider, - status: 'pending', - dateFrom: null, - dateTo: null, - totalRows: 0, - importedRows: 0, - invalidRows: 0, - errorMessage: null, - createdAt: now, - finishedAt: null, - version: 1, - }, - ], - format: 'JSONEachRow', - }) + const id = await this.getNextId(projectId) + + const now = new Date() + .toISOString() + .replace('T', ' ') + .replace('Z', '') + .slice(0, 19) + + await clickhouse.insert({ + table: `${CLICKHOUSE_DB}.data_import`, + values: [ + { + id, + projectId, + provider, + status: 'pending', + dateFrom: null, + dateTo: null, + totalRows: 0, + importedRows: 0, + invalidRows: 0, + errorMessage: null, + createdAt: now, + finishedAt: null, + version: 1, + }, + ], + format: 'JSONEachRow', + }) - return { - id, - projectId, - provider, - status: DataImportStatus.PENDING, - dateFrom: null, - dateTo: null, - totalRows: 0, - importedRows: 0, - invalidRows: 0, - errorMessage: null, - createdAt: now, - finishedAt: null, - version: 1, + return { + id, + projectId, + provider, + status: DataImportStatus.PENDING, + dateFrom: null, + dateTo: null, + totalRows: 0, + importedRows: 0, + invalidRows: 0, + errorMessage: null, + createdAt: now, + finishedAt: null, + version: 1, + } + } finally { + await this.releaseCreateLock(projectId, lockToken) } } From c6193c872b4fa1ae5d7ccce110225cb9ec9d19a7 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 17:32:56 +0100 Subject: [PATCH 07/29] fix: Umami mapper reads whole ZIP in memory --- .../src/data-import/mappers/umami.mapper.ts | 183 ++++++++++++++++-- .../src/data-import/mappers/umami.mapper.ts | 183 ++++++++++++++++-- 2 files changed, 332 insertions(+), 34 deletions(-) diff --git a/backend/apps/cloud/src/data-import/mappers/umami.mapper.ts b/backend/apps/cloud/src/data-import/mappers/umami.mapper.ts index 783ebb734..a1158230b 100644 --- a/backend/apps/cloud/src/data-import/mappers/umami.mapper.ts +++ b/backend/apps/cloud/src/data-import/mappers/umami.mapper.ts @@ -2,12 +2,13 @@ import * as fs from 'fs' import * as path from 'path' import * as os from 'os' import * as crypto from 'crypto' -import { unzipSync } from 'fflate' +import { Unzip, UnzipInflate } from 'fflate' import { parse } from 'csv-parse' import { ImportMapper, AnalyticsImportRow } from './mapper.interface' const WEBSITE_EVENT_CSV = 'website_event.csv' +const MAX_UMAMI_CSV_BYTES = 512 * 1024 * 1024 const BROWSER_MAP: Record = { chrome: 'Chrome', @@ -135,24 +136,172 @@ export class UmamiMapper implements ImportMapper { readonly provider = 'umami' readonly expectedFileExtension = '.zip' - private extractCsvFromZip(zipPath: string): string { - const zipBuffer = fs.readFileSync(zipPath) - const files = unzipSync(new Uint8Array(zipBuffer)) + private async extractCsvFromZip(zipPath: string): Promise { + const tempCsv = path.join(os.tmpdir(), `swetrix-umami-${Date.now()}.csv`) - const matchKey = Object.keys(files).find((name) => { - const basename = name.split('/').pop() || name - return basename === WEBSITE_EVENT_CSV - }) + return new Promise((resolve, reject) => { + const zipStream = fs.createReadStream(zipPath) + const unzipper = new Unzip() + + unzipper.register(UnzipInflate) + + let settled = false + let foundEntry = false + let fileDescriptor: number | null = null + let tempFileCreated = false + let extractedBytes = 0 + let terminateEntry: (() => void) | null = null + + const cleanupTempCsv = () => { + if (fileDescriptor !== null) { + try { + fs.closeSync(fileDescriptor) + } catch { + // + } + fileDescriptor = null + } - if (!matchKey) { - throw new Error( - `ZIP does not contain ${WEBSITE_EVENT_CSV}. Please upload the export ZIP from Umami.`, - ) - } + if (tempFileCreated) { + try { + fs.unlinkSync(tempCsv) + } catch { + // + } + } + } - const tempCsv = path.join(os.tmpdir(), `swetrix-umami-${Date.now()}.csv`) - fs.writeFileSync(tempCsv, Buffer.from(files[matchKey])) - return tempCsv + const fail = (error: Error) => { + if (settled) return + settled = true + terminateEntry?.() + zipStream.destroy() + cleanupTempCsv() + reject(error) + } + + const succeed = () => { + if (settled) return + settled = true + if (fileDescriptor !== null) { + try { + fs.closeSync(fileDescriptor) + } catch (error) { + cleanupTempCsv() + reject(error) + return + } + fileDescriptor = null + } + zipStream.destroy() + resolve(tempCsv) + } + + unzipper.onfile = (file) => { + if (path.basename(file.name) !== WEBSITE_EVENT_CSV || foundEntry) return + + foundEntry = true + + if ( + typeof file.originalSize !== 'number' || + !Number.isFinite(file.originalSize) + ) { + fail( + new Error( + `Unable to validate ${WEBSITE_EVENT_CSV} size in the ZIP archive.`, + ), + ) + return + } + + if (file.originalSize > MAX_UMAMI_CSV_BYTES) { + fail( + new Error( + `${WEBSITE_EVENT_CSV} exceeds the ${MAX_UMAMI_CSV_BYTES} byte limit.`, + ), + ) + return + } + + try { + fileDescriptor = fs.openSync(tempCsv, 'wx') + tempFileCreated = true + } catch (error) { + fail(error as Error) + return + } + + terminateEntry = file.terminate + file.ondata = (error, chunk, final) => { + if (error) { + fail(error) + return + } + + extractedBytes += chunk.length + if (extractedBytes > MAX_UMAMI_CSV_BYTES) { + fail( + new Error( + `${WEBSITE_EVENT_CSV} exceeds the ${MAX_UMAMI_CSV_BYTES} byte limit.`, + ), + ) + return + } + + try { + fs.writeSync(fileDescriptor!, chunk) + } catch (writeError) { + fail(writeError as Error) + return + } + + if (final) { + succeed() + } + } + + try { + file.start() + } catch (error) { + fail(error as Error) + } + } + + zipStream.on('data', (chunk: Buffer) => { + if (settled) return + + try { + unzipper.push( + new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength), + ) + } catch (error) { + fail(error as Error) + } + }) + + zipStream.on('end', () => { + if (settled) return + + try { + unzipper.push(new Uint8Array(0), true) + } catch (error) { + fail(error as Error) + return + } + + if (!foundEntry) { + fail( + new Error( + `ZIP does not contain ${WEBSITE_EVENT_CSV}. Please upload the export ZIP from Umami.`, + ), + ) + } + }) + + zipStream.on('error', (error) => { + fail(error) + }) + }) } async *createRowStream( @@ -160,7 +309,7 @@ export class UmamiMapper implements ImportMapper { pid: string, importID: number, ): AsyncIterable { - const csvPath = this.extractCsvFromZip(filePath) + const csvPath = await this.extractCsvFromZip(filePath) let cleanedUp = false const cleanup = () => { diff --git a/backend/apps/community/src/data-import/mappers/umami.mapper.ts b/backend/apps/community/src/data-import/mappers/umami.mapper.ts index e7bfb9b93..8e2ea25d4 100644 --- a/backend/apps/community/src/data-import/mappers/umami.mapper.ts +++ b/backend/apps/community/src/data-import/mappers/umami.mapper.ts @@ -2,12 +2,13 @@ import * as fs from 'fs' import * as path from 'path' import * as os from 'os' import * as crypto from 'crypto' -import { unzipSync } from 'fflate' +import { Unzip, UnzipInflate } from 'fflate' import { parse } from 'csv-parse' import { ImportMapper, AnalyticsImportRow } from './mapper.interface' const WEBSITE_EVENT_CSV = 'website_event.csv' +const MAX_UMAMI_CSV_BYTES = 512 * 1024 * 1024 const BROWSER_MAP: Record = { chrome: 'Chrome', @@ -135,24 +136,172 @@ export class UmamiMapper implements ImportMapper { readonly provider = 'umami' readonly expectedFileExtension = '.zip' - private extractCsvFromZip(zipPath: string): string { - const zipBuffer = fs.readFileSync(zipPath) - const files = unzipSync(new Uint8Array(zipBuffer)) + private async extractCsvFromZip(zipPath: string): Promise { + const tempCsv = path.join(os.tmpdir(), `swetrix-umami-${Date.now()}.csv`) - const matchKey = Object.keys(files).find((name) => { - const basename = name.split('/').pop() || name - return basename === WEBSITE_EVENT_CSV - }) + return new Promise((resolve, reject) => { + const zipStream = fs.createReadStream(zipPath) + const unzipper = new Unzip() + + unzipper.register(UnzipInflate) + + let settled = false + let foundEntry = false + let fileDescriptor: number | null = null + let tempFileCreated = false + let extractedBytes = 0 + let terminateEntry: (() => void) | null = null + + const cleanupTempCsv = () => { + if (fileDescriptor !== null) { + try { + fs.closeSync(fileDescriptor) + } catch { + // + } + fileDescriptor = null + } - if (!matchKey) { - throw new Error( - `ZIP does not contain ${WEBSITE_EVENT_CSV}. Please upload the export ZIP from Umami.`, - ) - } + if (tempFileCreated) { + try { + fs.unlinkSync(tempCsv) + } catch { + // + } + } + } - const tempCsv = path.join(os.tmpdir(), `swetrix-umami-${Date.now()}.csv`) - fs.writeFileSync(tempCsv, Buffer.from(files[matchKey])) - return tempCsv + const fail = (error: Error) => { + if (settled) return + settled = true + terminateEntry?.() + zipStream.destroy() + cleanupTempCsv() + reject(error) + } + + const succeed = () => { + if (settled) return + settled = true + if (fileDescriptor !== null) { + try { + fs.closeSync(fileDescriptor) + } catch (error) { + cleanupTempCsv() + reject(error) + return + } + fileDescriptor = null + } + zipStream.destroy() + resolve(tempCsv) + } + + unzipper.onfile = (file) => { + if (path.basename(file.name) !== WEBSITE_EVENT_CSV || foundEntry) return + + foundEntry = true + + if ( + typeof file.originalSize !== 'number' || + !Number.isFinite(file.originalSize) + ) { + fail( + new Error( + `Unable to validate ${WEBSITE_EVENT_CSV} size in the ZIP archive.`, + ), + ) + return + } + + if (file.originalSize > MAX_UMAMI_CSV_BYTES) { + fail( + new Error( + `${WEBSITE_EVENT_CSV} exceeds the ${MAX_UMAMI_CSV_BYTES} byte limit.`, + ), + ) + return + } + + try { + fileDescriptor = fs.openSync(tempCsv, 'wx') + tempFileCreated = true + } catch (error) { + fail(error as Error) + return + } + + terminateEntry = file.terminate + file.ondata = (error, chunk, final) => { + if (error) { + fail(error) + return + } + + extractedBytes += chunk.length + if (extractedBytes > MAX_UMAMI_CSV_BYTES) { + fail( + new Error( + `${WEBSITE_EVENT_CSV} exceeds the ${MAX_UMAMI_CSV_BYTES} byte limit.`, + ), + ) + return + } + + try { + fs.writeSync(fileDescriptor!, chunk) + } catch (writeError) { + fail(writeError as Error) + return + } + + if (final) { + succeed() + } + } + + try { + file.start() + } catch (error) { + fail(error as Error) + } + } + + zipStream.on('data', (chunk: Buffer) => { + if (settled) return + + try { + unzipper.push( + new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength), + ) + } catch (error) { + fail(error as Error) + } + }) + + zipStream.on('end', () => { + if (settled) return + + try { + unzipper.push(new Uint8Array(0), true) + } catch (error) { + fail(error as Error) + return + } + + if (!foundEntry) { + fail( + new Error( + `ZIP does not contain ${WEBSITE_EVENT_CSV}. Please upload the export ZIP from Umami.`, + ), + ) + } + }) + + zipStream.on('error', (error) => { + fail(error) + }) + }) } async *createRowStream( @@ -160,7 +309,7 @@ export class UmamiMapper implements ImportMapper { pid: string, importID: number, ): AsyncIterable { - const csvPath = this.extractCsvFromZip(filePath) + const csvPath = await this.extractCsvFromZip(filePath) let cleanedUp = false const cleanup = () => { From cbe346acac18484c9e57bcb3be3071aaf6467da8 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 17:33:17 +0100 Subject: [PATCH 08/29] Match cloud mapper --- backend/apps/community/src/data-import/mappers/umami.mapper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/apps/community/src/data-import/mappers/umami.mapper.ts b/backend/apps/community/src/data-import/mappers/umami.mapper.ts index 8e2ea25d4..a1158230b 100644 --- a/backend/apps/community/src/data-import/mappers/umami.mapper.ts +++ b/backend/apps/community/src/data-import/mappers/umami.mapper.ts @@ -318,7 +318,7 @@ export class UmamiMapper implements ImportMapper { try { fs.unlinkSync(csvPath) } catch { - // ignore + // } } } From 2afe6489491d84372bca069aa32e1034ceab566d Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 17:37:38 +0100 Subject: [PATCH 09/29] Use createHeadersWithCookies pattern --- web/app/routes/api.data-import.ts | 59 +++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/web/app/routes/api.data-import.ts b/web/app/routes/api.data-import.ts index 9da7f237a..0bac4d4bc 100644 --- a/web/app/routes/api.data-import.ts +++ b/web/app/routes/api.data-import.ts @@ -5,18 +5,7 @@ import { } from 'react-router' import { serverFetch } from '~/api/api.server' - -function toResponse(result: Awaited>) { - if (result.error) { - return data({ error: result.error }, { status: result.status }) - } - - return data(result.data, { - headers: result.cookies.length - ? { 'Set-Cookie': result.cookies.join(', ') } - : undefined, - }) -} +import { createHeadersWithCookies } from '~/utils/session.server' export async function loader({ request }: LoaderFunctionArgs) { const url = new URL(request.url) @@ -26,7 +15,18 @@ export async function loader({ request }: LoaderFunctionArgs) { return data({ error: 'endpoint param is required' }, { status: 400 }) } - return toResponse(await serverFetch(request, endpoint)) + const result = await serverFetch(request, endpoint) + + if (result.error) { + return data( + { error: result.error }, + { status: result.status, headers: createHeadersWithCookies(result.cookies) }, + ) + } + + return data(result.data, { + headers: createHeadersWithCookies(result.cookies), + }) } export async function action({ request }: ActionFunctionArgs) { @@ -38,9 +38,18 @@ export async function action({ request }: ActionFunctionArgs) { } if (request.method === 'DELETE') { - return toResponse( - await serverFetch(request, endpoint, { method: 'DELETE' }), - ) + const result = await serverFetch(request, endpoint, { method: 'DELETE' }) + + if (result.error) { + return data( + { error: result.error }, + { status: result.status, headers: createHeadersWithCookies(result.cookies) }, + ) + } + + return data(result.data, { + headers: createHeadersWithCookies(result.cookies), + }) } const contentType = request.headers.get('content-type') || '' @@ -57,9 +66,21 @@ export async function action({ request }: ActionFunctionArgs) { } } - return toResponse( - await serverFetch(request, endpoint, { method: 'POST', body: proxyForm }), - ) + const result = await serverFetch(request, endpoint, { + method: 'POST', + body: proxyForm, + }) + + if (result.error) { + return data( + { error: result.error }, + { status: result.status, headers: createHeadersWithCookies(result.cookies) }, + ) + } + + return data(result.data, { + headers: createHeadersWithCookies(result.cookies), + }) } return data({ error: 'Unsupported content type' }, { status: 400 }) From f42643cdcb6117ba891e03a2cec1c488260f52b7 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 17:37:56 +0100 Subject: [PATCH 10/29] Make FileUpload a reusable UI component --- .../Settings/components/DataImportTab.tsx | 84 +++-------- web/app/ui/FileUpload.tsx | 138 ++++++++++++++++++ 2 files changed, 157 insertions(+), 65 deletions(-) create mode 100644 web/app/ui/FileUpload.tsx diff --git a/web/app/pages/Project/Settings/components/DataImportTab.tsx b/web/app/pages/Project/Settings/components/DataImportTab.tsx index 517164fd3..a858336ae 100644 --- a/web/app/pages/Project/Settings/components/DataImportTab.tsx +++ b/web/app/pages/Project/Settings/components/DataImportTab.tsx @@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next' import { DataImport } from '~/lib/models/Project' import Modal from '~/ui/Modal' import Loader from '~/ui/Loader' +import FileUpload from '~/ui/FileUpload' import { Text } from '~/ui/Text' import UmamiSVG from '~/ui/icons/Umami' @@ -81,7 +82,6 @@ export default function DataImportTab({ projectId }: DataImportTabProps) { const [uploading, setUploading] = useState(false) const [deleteTarget, setDeleteTarget] = useState(null) const [deleting, setDeleting] = useState(false) - const fileInputRef = useRef(null) const pollRef = useRef | null>(null) const statusLabels: Record = { @@ -438,59 +438,16 @@ export default function DataImportTab({ projectId }: DataImportTabProps) {
- {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} -
{ - if (!uploading) fileInputRef.current?.click() - }} - onDragOver={(e) => { - e.preventDefault() - e.stopPropagation() - }} - onDrop={(e) => { - e.preventDefault() - e.stopPropagation() - if (uploading) return - const file = e.dataTransfer.files?.[0] - if (file) handleUpload(file) - }} - > - { - const file = e.target.files?.[0] - if (file) handleUpload(file) - e.target.value = '' - }} - disabled={uploading} - /> - - {uploading ? ( -
- - - {t('project.settings.dataImport.uploadingProcessing')} - -
- ) : ( -
- + {' '} {t('project.settings.dataImport.orDragDrop')} - - {t('project.settings.dataImport.maxFileSize', { + ) + } + hint={ + uploading + ? undefined + : t('project.settings.dataImport.maxFileSize', { fileType: providerFileType.toUpperCase(), - })} - -
- )} -
+ }) + } + /> } closeText={t('common.cancel')} diff --git a/web/app/ui/FileUpload.tsx b/web/app/ui/FileUpload.tsx new file mode 100644 index 000000000..ac039337b --- /dev/null +++ b/web/app/ui/FileUpload.tsx @@ -0,0 +1,138 @@ +import { useCallback, useId, useRef, useState } from 'react' +import { UploadIcon, SpinnerIcon } from '@phosphor-icons/react' + +import { cn } from '~/utils/generic' + +import { Text } from './Text' + +interface FileUploadProps { + accept?: string + disabled?: boolean + loading?: boolean + onFile: (file: File) => void + label?: React.ReactNode + hint?: React.ReactNode + className?: string +} + +export default function FileUpload({ + accept, + disabled, + loading, + onFile, + label, + hint, + className, +}: FileUploadProps) { + const inputId = useId() + const inputRef = useRef(null) + const [dragOver, setDragOver] = useState(false) + + const isDisabled = disabled || loading + + const handleFile = useCallback( + (file: File | undefined) => { + if (!file || isDisabled) return + onFile(file) + }, + [isDisabled, onFile], + ) + + const handleDragOver = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (!isDisabled) setDragOver(true) + }, + [isDisabled], + ) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setDragOver(false) + }, []) + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setDragOver(false) + if (isDisabled) return + handleFile(e.dataTransfer.files?.[0]) + }, + [isDisabled, handleFile], + ) + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + handleFile(e.target.files?.[0]) + e.target.value = '' + }, + [handleFile], + ) + + return ( + + ) +} From 49ffbdfdcdaf14247cf211e8aaad9d14c2ee5098 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 17:39:10 +0100 Subject: [PATCH 11/29] fix: Restrict endpoint to the data-import namespace --- web/app/routes/api.data-import.ts | 39 +++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/web/app/routes/api.data-import.ts b/web/app/routes/api.data-import.ts index 0bac4d4bc..ee495b826 100644 --- a/web/app/routes/api.data-import.ts +++ b/web/app/routes/api.data-import.ts @@ -7,14 +7,39 @@ import { import { serverFetch } from '~/api/api.server' import { createHeadersWithCookies } from '~/utils/session.server' +function validateEndpoint(raw: string | null): string | null { + if (!raw) return null + + const trimmed = raw.replace(/^\/+/, '') + + if ( + trimmed !== 'data-import' && + !trimmed.startsWith('data-import/') + ) { + return null + } + + if (trimmed.includes('..')) return null + + if (/^[a-z]+:\/\//i.test(trimmed)) return null + + return trimmed +} + export async function loader({ request }: LoaderFunctionArgs) { const url = new URL(request.url) - const endpoint = url.searchParams.get('endpoint') + const raw = url.searchParams.get('endpoint') - if (!endpoint) { + if (!raw) { return data({ error: 'endpoint param is required' }, { status: 400 }) } + const endpoint = validateEndpoint(raw) + + if (!endpoint) { + return data({ error: 'Invalid endpoint' }, { status: 400 }) + } + const result = await serverFetch(request, endpoint) if (result.error) { @@ -31,12 +56,18 @@ export async function loader({ request }: LoaderFunctionArgs) { export async function action({ request }: ActionFunctionArgs) { const url = new URL(request.url) - const endpoint = url.searchParams.get('endpoint') + const raw = url.searchParams.get('endpoint') - if (!endpoint) { + if (!raw) { return data({ error: 'endpoint param is required' }, { status: 400 }) } + const endpoint = validateEndpoint(raw) + + if (!endpoint) { + return data({ error: 'Invalid endpoint' }, { status: 400 }) + } + if (request.method === 'DELETE') { const result = await serverFetch(request, endpoint, { method: 'DELETE' }) From 068aa9edcc3f3e122a95dfc33d4358d3a00cd58e Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 17:40:36 +0100 Subject: [PATCH 12/29] 400 -> 503 error --- backend/apps/cloud/src/data-import/data-import.controller.ts | 3 ++- .../apps/community/src/data-import/data-import.controller.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/apps/cloud/src/data-import/data-import.controller.ts b/backend/apps/cloud/src/data-import/data-import.controller.ts index 8f4cd8d8d..434270c94 100644 --- a/backend/apps/cloud/src/data-import/data-import.controller.ts +++ b/backend/apps/cloud/src/data-import/data-import.controller.ts @@ -13,6 +13,7 @@ import { UseInterceptors, UploadedFile, BadRequestException, + ServiceUnavailableException, Query, Logger, } from '@nestjs/common' @@ -132,7 +133,7 @@ export class DataImportController { this.logger.error(`Failed to enqueue import job: ${error.message}`) await this.dataImportService.deleteImportRecord(dataImport.id) this.cleanupFile(file.path) - throw new BadRequestException( + throw new ServiceUnavailableException( 'Failed to start import. Please try again later.', ) } diff --git a/backend/apps/community/src/data-import/data-import.controller.ts b/backend/apps/community/src/data-import/data-import.controller.ts index 4ba84c384..168f479a2 100644 --- a/backend/apps/community/src/data-import/data-import.controller.ts +++ b/backend/apps/community/src/data-import/data-import.controller.ts @@ -12,6 +12,7 @@ import { UseInterceptors, UploadedFile, BadRequestException, + ServiceUnavailableException, Query, Logger, } from '@nestjs/common' @@ -127,7 +128,7 @@ export class DataImportController { this.logger.error(`Failed to enqueue import job: ${error.message}`) await this.dataImportService.deleteImportRecord(projectId, dataImport.id) this.cleanupFile(file.path) - throw new BadRequestException( + throw new ServiceUnavailableException( 'Failed to start import. Please try again later.', ) } From 169005fd0e606df325f808a7fc13343f140139be Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 17:41:24 +0100 Subject: [PATCH 13/29] Use UTC --- web/app/pages/Project/View/ViewProject.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/app/pages/Project/View/ViewProject.tsx b/web/app/pages/Project/View/ViewProject.tsx index 4be98a69f..f65b74035 100644 --- a/web/app/pages/Project/View/ViewProject.tsx +++ b/web/app/pages/Project/View/ViewProject.tsx @@ -432,8 +432,8 @@ const ViewProjectContent = () => { let to: string if (dateRange && dateRange.length >= 2) { - from = dayjs(dateRange[0]).format('YYYY-MM-DD HH:mm:ss') - to = dayjs(dateRange[1]).format('YYYY-MM-DD HH:mm:ss') + from = dayjs.utc(dateRange[0]).format('YYYY-MM-DD HH:mm:ss') + to = dayjs.utc(dateRange[1]).format('YYYY-MM-DD HH:mm:ss') } else { const now = dayjs() to = now.format('YYYY-MM-DD HH:mm:ss') From 3ac76571fe9e5f704efa172122eb5263ca425a37 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 17:43:19 +0100 Subject: [PATCH 14/29] finishedAt - microsecond precision timestamp for concistency --- backend/migrations/mysql/2026_03_31_data_import.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/migrations/mysql/2026_03_31_data_import.sql b/backend/migrations/mysql/2026_03_31_data_import.sql index 1a10c69be..09b4b3028 100644 --- a/backend/migrations/mysql/2026_03_31_data_import.sql +++ b/backend/migrations/mysql/2026_03_31_data_import.sql @@ -12,7 +12,7 @@ CREATE TABLE IF NOT EXISTS `data_import` ( `invalidRows` int NOT NULL DEFAULT 0, `errorMessage` text DEFAULT NULL, `createdAt` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), - `finishedAt` datetime DEFAULT NULL, + `finishedAt` datetime(6) DEFAULT NULL, PRIMARY KEY (`id`), KEY `FK_data_import_project` (`projectId`), CONSTRAINT `FK_data_import_project` FOREIGN KEY (`projectId`) REFERENCES `project` (`id`) ON DELETE CASCADE From 16f2a790de6d632d13907c911744ec4be71e731a Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 17:45:03 +0100 Subject: [PATCH 15/29] Clarify that we expect .zip --- docs/content/docs/data-import.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/content/docs/data-import.mdx b/docs/content/docs/data-import.mdx index c6381e060..346367800 100644 --- a/docs/content/docs/data-import.mdx +++ b/docs/content/docs/data-import.mdx @@ -33,7 +33,7 @@ Imported data is tagged internally so it can be identified and, if needed, remov 2. Go to **Settings** → **Websites** 3. Click on the website you want to export 4. Navigate to the **Data** section -5. Click **Export** to download a CSV file +5. Click **Export** to download a ZIP archive (`.zip`) [SCREENSHOT: Umami export data page] @@ -42,7 +42,7 @@ Imported data is tagged internally so it can be identified and, if needed, remov 1. Go to your project's **Settings** page in Swetrix 2. Click the **Import data** tab 3. Click the **Umami** provider card -4. In the upload modal, either drag and drop your CSV file or click to browse +4. In the upload modal, either drag and drop the `.zip` file or click to browse 5. Wait for the upload and processing to complete [SCREENSHOT: Swetrix import data tab with provider cards] From 4d1c7a75d92f1e310cc70b9dd67eaef10930d840 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 17:48:23 +0100 Subject: [PATCH 16/29] Data import queue name --- .../apps/cloud/src/data-import/data-import.controller.ts | 7 +++++-- backend/apps/cloud/src/data-import/data-import.module.ts | 7 +++++-- .../apps/cloud/src/data-import/data-import.processor.ts | 4 +++- .../community/src/data-import/data-import.controller.ts | 7 +++++-- .../apps/community/src/data-import/data-import.module.ts | 7 +++++-- .../community/src/data-import/data-import.processor.ts | 4 +++- 6 files changed, 26 insertions(+), 10 deletions(-) diff --git a/backend/apps/cloud/src/data-import/data-import.controller.ts b/backend/apps/cloud/src/data-import/data-import.controller.ts index 434270c94..924c36426 100644 --- a/backend/apps/cloud/src/data-import/data-import.controller.ts +++ b/backend/apps/cloud/src/data-import/data-import.controller.ts @@ -30,7 +30,10 @@ import { ProjectService } from '../project/project.service' import { DataImportService } from './data-import.service' import { UploadImportDto } from './dto' import { getMapper, SUPPORTED_PROVIDERS } from './mappers' -import { DataImportJobData } from './data-import.processor' +import { + DataImportJobData, + DATA_IMPORT_QUEUE, +} from './data-import.processor' const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100 MB @@ -43,7 +46,7 @@ export class DataImportController { constructor( private readonly dataImportService: DataImportService, private readonly projectService: ProjectService, - @InjectQueue('data-import') + @InjectQueue(DATA_IMPORT_QUEUE) private readonly importQueue: Queue, ) {} diff --git a/backend/apps/cloud/src/data-import/data-import.module.ts b/backend/apps/cloud/src/data-import/data-import.module.ts index 67e037f96..aeee47568 100644 --- a/backend/apps/cloud/src/data-import/data-import.module.ts +++ b/backend/apps/cloud/src/data-import/data-import.module.ts @@ -6,12 +6,15 @@ import { ProjectModule } from '../project/project.module' import { DataImport } from './entity/data-import.entity' import { DataImportService } from './data-import.service' import { DataImportController } from './data-import.controller' -import { DataImportProcessor } from './data-import.processor' +import { + DataImportProcessor, + DATA_IMPORT_QUEUE, +} from './data-import.processor' @Module({ imports: [ TypeOrmModule.forFeature([DataImport]), - BullModule.registerQueue({ name: 'data-import' }), + BullModule.registerQueue({ name: DATA_IMPORT_QUEUE }), forwardRef(() => ProjectModule), ], providers: [DataImportService, DataImportProcessor], diff --git a/backend/apps/cloud/src/data-import/data-import.processor.ts b/backend/apps/cloud/src/data-import/data-import.processor.ts index bce677e2a..e7f0bdf9c 100644 --- a/backend/apps/cloud/src/data-import/data-import.processor.ts +++ b/backend/apps/cloud/src/data-import/data-import.processor.ts @@ -10,6 +10,8 @@ import { clickhouse } from '../common/integrations/clickhouse' const CLICKHOUSE_DB = process.env.CLICKHOUSE_DATABASE || 'analytics' const BATCH_SIZE = 5000 +export const DATA_IMPORT_QUEUE = 'data-import' + export interface DataImportJobData { id: number importId: number @@ -18,7 +20,7 @@ export interface DataImportJobData { filePath: string } -@Processor('data-import', { concurrency: 1 }) +@Processor(DATA_IMPORT_QUEUE, { concurrency: 1 }) export class DataImportProcessor extends WorkerHost { private readonly logger = new Logger(DataImportProcessor.name) diff --git a/backend/apps/community/src/data-import/data-import.controller.ts b/backend/apps/community/src/data-import/data-import.controller.ts index 168f479a2..d89a3b869 100644 --- a/backend/apps/community/src/data-import/data-import.controller.ts +++ b/backend/apps/community/src/data-import/data-import.controller.ts @@ -28,7 +28,10 @@ import { ProjectService } from '../project/project.service' import { DataImportService } from './data-import.service' import { UploadImportDto } from './dto' import { getMapper, SUPPORTED_PROVIDERS } from './mappers' -import { DataImportJobData } from './data-import.processor' +import { + DataImportJobData, + DATA_IMPORT_QUEUE, +} from './data-import.processor' const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100 MB @@ -40,7 +43,7 @@ export class DataImportController { constructor( private readonly dataImportService: DataImportService, private readonly projectService: ProjectService, - @InjectQueue('data-import') + @InjectQueue(DATA_IMPORT_QUEUE) private readonly importQueue: Queue, ) {} diff --git a/backend/apps/community/src/data-import/data-import.module.ts b/backend/apps/community/src/data-import/data-import.module.ts index a863560d5..7bce1c112 100644 --- a/backend/apps/community/src/data-import/data-import.module.ts +++ b/backend/apps/community/src/data-import/data-import.module.ts @@ -4,11 +4,14 @@ import { BullModule } from '@nestjs/bullmq' import { ProjectModule } from '../project/project.module' import { DataImportService } from './data-import.service' import { DataImportController } from './data-import.controller' -import { DataImportProcessor } from './data-import.processor' +import { + DataImportProcessor, + DATA_IMPORT_QUEUE, +} from './data-import.processor' @Module({ imports: [ - BullModule.registerQueue({ name: 'data-import' }), + BullModule.registerQueue({ name: DATA_IMPORT_QUEUE }), forwardRef(() => ProjectModule), ], providers: [DataImportService, DataImportProcessor], diff --git a/backend/apps/community/src/data-import/data-import.processor.ts b/backend/apps/community/src/data-import/data-import.processor.ts index 9055442f6..5b8d0b0cf 100644 --- a/backend/apps/community/src/data-import/data-import.processor.ts +++ b/backend/apps/community/src/data-import/data-import.processor.ts @@ -10,6 +10,8 @@ import { clickhouse } from '../common/integrations/clickhouse' const CLICKHOUSE_DB = process.env.CLICKHOUSE_DATABASE || 'analytics' const BATCH_SIZE = 5000 +export const DATA_IMPORT_QUEUE = 'data-import' + export interface DataImportJobData { importId: number projectId: string @@ -17,7 +19,7 @@ export interface DataImportJobData { filePath: string } -@Processor('data-import', { concurrency: 1 }) +@Processor(DATA_IMPORT_QUEUE, { concurrency: 1 }) export class DataImportProcessor extends WorkerHost { private readonly logger = new Logger(DataImportProcessor.name) From 48614e6c82dd2d99dd756e67cf90f1f0e70ea333 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 17:49:18 +0100 Subject: [PATCH 17/29] Better provider type name --- backend/apps/cloud/src/data-import/dto/upload-import.dto.ts | 2 +- backend/apps/community/src/data-import/dto/upload-import.dto.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/apps/cloud/src/data-import/dto/upload-import.dto.ts b/backend/apps/cloud/src/data-import/dto/upload-import.dto.ts index 26ce83163..25413d40e 100644 --- a/backend/apps/cloud/src/data-import/dto/upload-import.dto.ts +++ b/backend/apps/cloud/src/data-import/dto/upload-import.dto.ts @@ -11,5 +11,5 @@ export class UploadImportDto { }) @IsString() @IsIn(SUPPORTED_PROVIDERS) - provider: string + provider: (typeof SUPPORTED_PROVIDERS)[number] } diff --git a/backend/apps/community/src/data-import/dto/upload-import.dto.ts b/backend/apps/community/src/data-import/dto/upload-import.dto.ts index 26ce83163..25413d40e 100644 --- a/backend/apps/community/src/data-import/dto/upload-import.dto.ts +++ b/backend/apps/community/src/data-import/dto/upload-import.dto.ts @@ -11,5 +11,5 @@ export class UploadImportDto { }) @IsString() @IsIn(SUPPORTED_PROVIDERS) - provider: string + provider: (typeof SUPPORTED_PROVIDERS)[number] } From a4ab416ac5ef11d6159191c7d1e379688ef70b1e Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 17:50:59 +0100 Subject: [PATCH 18/29] Move import manipulation logic --- .../Settings/components/DataImportTab.tsx | 146 +++++++++--------- web/app/pages/Project/View/ViewProject.tsx | 68 ++------ web/app/routes/api.data-import.ts | 118 -------------- web/app/routes/projects.$id.tsx | 58 +++++++ web/app/routes/projects.settings.$id.tsx | 90 ++++++++++- 5 files changed, 233 insertions(+), 247 deletions(-) delete mode 100644 web/app/routes/api.data-import.ts diff --git a/web/app/pages/Project/Settings/components/DataImportTab.tsx b/web/app/pages/Project/Settings/components/DataImportTab.tsx index a858336ae..cad78861a 100644 --- a/web/app/pages/Project/Settings/components/DataImportTab.tsx +++ b/web/app/pages/Project/Settings/components/DataImportTab.tsx @@ -12,8 +12,10 @@ import cx from 'clsx' import { toast } from 'sonner' import dayjs from 'dayjs' import { useTranslation } from 'react-i18next' +import { useFetcher } from 'react-router' import { DataImport } from '~/lib/models/Project' +import type { ProjectSettingsActionData } from '~/routes/projects.settings.$id' import Modal from '~/ui/Modal' import Loader from '~/ui/Loader' import FileUpload from '~/ui/FileUpload' @@ -79,11 +81,15 @@ export default function DataImportTab({ projectId }: DataImportTabProps) { const [imports, setImports] = useState([]) const [loading, setLoading] = useState(true) const [selectedProvider, setSelectedProvider] = useState(null) - const [uploading, setUploading] = useState(false) const [deleteTarget, setDeleteTarget] = useState(null) - const [deleting, setDeleting] = useState(false) const pollRef = useRef | null>(null) + const listFetcher = useFetcher() + const uploadFetcher = useFetcher() + const deleteFetcher = useFetcher() + + const settingsAction = `/projects/settings/${projectId}` + const statusLabels: Record = { pending: t('project.settings.dataImport.statusPending'), processing: t('project.settings.dataImport.statusProcessing'), @@ -91,25 +97,27 @@ export default function DataImportTab({ projectId }: DataImportTabProps) { failed: t('project.settings.dataImport.statusFailed'), } - const fetchImports = useCallback(async () => { - try { - const res = await fetch( - `/api/data-import?endpoint=${encodeURIComponent(`data-import/${projectId}`)}`, - ) - if (res.ok) { - const data = await res.json() - setImports(Array.isArray(data) ? data : []) - } - } catch { - // silently fail on poll - } finally { - setLoading(false) - } - }, [projectId]) + const fetchImports = useCallback(() => { + listFetcher.submit( + { intent: 'list-data-imports' }, + { method: 'POST', action: settingsAction }, + ) + }, [listFetcher, settingsAction]) useEffect(() => { fetchImports() - }, [fetchImports]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + if (listFetcher.state !== 'idle' || !listFetcher.data) return + if (listFetcher.data.intent !== 'list-data-imports') return + + if (listFetcher.data.dataImports) { + setImports(listFetcher.data.dataImports) + } + setLoading(false) + }, [listFetcher.state, listFetcher.data]) useEffect(() => { const hasActive = imports.some( @@ -130,73 +138,67 @@ export default function DataImportTab({ projectId }: DataImportTabProps) { } }, [imports, fetchImports]) - const handleUpload = async (file: File) => { - if (!selectedProvider) return - - setUploading(true) - - try { - const formData = new FormData() - formData.append('file', file) - formData.append('provider', selectedProvider) - - const res = await fetch( - `/api/data-import?endpoint=${encodeURIComponent(`data-import/${projectId}/upload`)}`, - { - method: 'POST', - body: formData, - }, - ) - - const result = await res.json() - - if (!res.ok) { - toast.error( - result.error || t('project.settings.dataImport.uploadFailed'), - ) - return - } + useEffect(() => { + if (uploadFetcher.state !== 'idle' || !uploadFetcher.data) return + if (uploadFetcher.data.intent !== 'upload-data-import') return + if (uploadFetcher.data.success) { toast.success(t('project.settings.dataImport.importStarted')) setSelectedProvider(null) fetchImports() - } catch { - toast.error(t('project.settings.dataImport.uploadFailed')) - } finally { - setUploading(false) - } - } - - const handleDelete = async () => { - if (!deleteTarget) return - - setDeleting(true) - - try { - const res = await fetch( - `/api/data-import?endpoint=${encodeURIComponent(`data-import/${projectId}/${deleteTarget.id}`)}`, - { method: 'DELETE' }, + } else if (uploadFetcher.data.error) { + toast.error( + uploadFetcher.data.error || + t('project.settings.dataImport.uploadFailed'), ) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [uploadFetcher.state, uploadFetcher.data]) - const result = await res.json() - - if (!res.ok) { - toast.error( - result.error || t('project.settings.dataImport.deleteFailed'), - ) - return - } + useEffect(() => { + if (deleteFetcher.state !== 'idle' || !deleteFetcher.data) return + if (deleteFetcher.data.intent !== 'delete-data-import') return + if (deleteFetcher.data.success) { toast.success(t('project.settings.dataImport.importDeleted')) setDeleteTarget(null) fetchImports() - } catch { - toast.error(t('project.settings.dataImport.deleteFailed')) - } finally { - setDeleting(false) + } else if (deleteFetcher.data.error) { + toast.error( + deleteFetcher.data.error || + t('project.settings.dataImport.deleteFailed'), + ) } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [deleteFetcher.state, deleteFetcher.data]) + + const handleUpload = (file: File) => { + if (!selectedProvider) return + + const formData = new FormData() + formData.append('intent', 'upload-data-import') + formData.append('file', file) + formData.append('provider', selectedProvider) + + uploadFetcher.submit(formData, { + method: 'POST', + action: settingsAction, + encType: 'multipart/form-data', + }) + } + + const handleDelete = () => { + if (!deleteTarget) return + + deleteFetcher.submit( + { intent: 'delete-data-import', importId: String(deleteTarget.id) }, + { method: 'POST', action: settingsAction }, + ) } + const uploading = uploadFetcher.state !== 'idle' + const deleting = deleteFetcher.state !== 'idle' + const providerFileType = selectedProvider ? t(`project.settings.dataImport.${selectedProvider}.fileType`) : '' diff --git a/web/app/pages/Project/View/ViewProject.tsx b/web/app/pages/Project/View/ViewProject.tsx index f65b74035..60187dc3b 100644 --- a/web/app/pages/Project/View/ViewProject.tsx +++ b/web/app/pages/Project/View/ViewProject.tsx @@ -38,6 +38,7 @@ import { useTranslation } from 'react-i18next' import { useNavigate, useSearchParams, + useLoaderData, LinkProps, useFetcher, } from 'react-router' @@ -71,7 +72,10 @@ import { import ViewProjectHotkeys from '~/modals/ViewProjectHotkeys' import { useAuth } from '~/providers/AuthProvider' import { useTheme } from '~/providers/ThemeProvider' -import { ProjectViewActionData } from '~/routes/projects.$id' +import { + ProjectViewActionData, + type ProjectLoaderData, +} from '~/routes/projects.$id' import Dropdown from '~/ui/Dropdown' import Flag from '~/ui/Flag' import Loader from '~/ui/Loader' @@ -423,66 +427,18 @@ const ViewProjectContent = () => { return null }, [period, searchParams]) + const loaderData = useLoaderData() const [hasImportedData, setHasImportedData] = useState(false) useEffect(() => { - if (!id) return - - let from: string - let to: string - - if (dateRange && dateRange.length >= 2) { - from = dayjs.utc(dateRange[0]).format('YYYY-MM-DD HH:mm:ss') - to = dayjs.utc(dateRange[1]).format('YYYY-MM-DD HH:mm:ss') + if (loaderData?.hasImportedData instanceof Promise) { + loaderData.hasImportedData + .then((value) => setHasImportedData(value)) + .catch(() => setHasImportedData(false)) } else { - const now = dayjs() - to = now.format('YYYY-MM-DD HH:mm:ss') - - const periodMap: Record = { - '1h': [1, 'hour'], - today: [0, 'day'], - yesterday: [1, 'day'], - '1d': [1, 'day'], - '7d': [7, 'day'], - '4w': [4, 'week'], - '3M': [3, 'month'], - '12M': [12, 'month'], - '24M': [24, 'month'], - } - - const mapping = periodMap[period] - if (mapping) { - from = now - .subtract(mapping[0], mapping[1]) - .format('YYYY-MM-DD HH:mm:ss') - if (period === 'today') { - from = now.startOf('day').format('YYYY-MM-DD HH:mm:ss') - } else if (period === 'yesterday') { - from = now - .subtract(1, 'day') - .startOf('day') - .format('YYYY-MM-DD HH:mm:ss') - to = now.subtract(1, 'day').endOf('day').format('YYYY-MM-DD HH:mm:ss') - } - } else if (period === 'all') { - from = '2020-01-01 00:00:00' - } else { - setHasImportedData(false) - return - } + setHasImportedData(false) } - - const endpoint = `data-import/${id}/has-imported-data?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}` - - fetch(`/api/data-import?endpoint=${encodeURIComponent(endpoint)}`) - .then((res) => (res.ok ? res.json() : null)) - .then((data) => { - setHasImportedData(data?.hasImportedData === true) - }) - .catch(() => { - setHasImportedData(false) - }) - }, [id, dateRange, period]) + }, [loaderData?.hasImportedData]) const periodPairs = useMemo(() => { let tbs = null diff --git a/web/app/routes/api.data-import.ts b/web/app/routes/api.data-import.ts deleted file mode 100644 index ee495b826..000000000 --- a/web/app/routes/api.data-import.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { - type ActionFunctionArgs, - type LoaderFunctionArgs, - data, -} from 'react-router' - -import { serverFetch } from '~/api/api.server' -import { createHeadersWithCookies } from '~/utils/session.server' - -function validateEndpoint(raw: string | null): string | null { - if (!raw) return null - - const trimmed = raw.replace(/^\/+/, '') - - if ( - trimmed !== 'data-import' && - !trimmed.startsWith('data-import/') - ) { - return null - } - - if (trimmed.includes('..')) return null - - if (/^[a-z]+:\/\//i.test(trimmed)) return null - - return trimmed -} - -export async function loader({ request }: LoaderFunctionArgs) { - const url = new URL(request.url) - const raw = url.searchParams.get('endpoint') - - if (!raw) { - return data({ error: 'endpoint param is required' }, { status: 400 }) - } - - const endpoint = validateEndpoint(raw) - - if (!endpoint) { - return data({ error: 'Invalid endpoint' }, { status: 400 }) - } - - const result = await serverFetch(request, endpoint) - - if (result.error) { - return data( - { error: result.error }, - { status: result.status, headers: createHeadersWithCookies(result.cookies) }, - ) - } - - return data(result.data, { - headers: createHeadersWithCookies(result.cookies), - }) -} - -export async function action({ request }: ActionFunctionArgs) { - const url = new URL(request.url) - const raw = url.searchParams.get('endpoint') - - if (!raw) { - return data({ error: 'endpoint param is required' }, { status: 400 }) - } - - const endpoint = validateEndpoint(raw) - - if (!endpoint) { - return data({ error: 'Invalid endpoint' }, { status: 400 }) - } - - if (request.method === 'DELETE') { - const result = await serverFetch(request, endpoint, { method: 'DELETE' }) - - if (result.error) { - return data( - { error: result.error }, - { status: result.status, headers: createHeadersWithCookies(result.cookies) }, - ) - } - - return data(result.data, { - headers: createHeadersWithCookies(result.cookies), - }) - } - - const contentType = request.headers.get('content-type') || '' - - if (contentType.includes('multipart/form-data')) { - const formData = await request.formData() - - const proxyForm = new FormData() - for (const [key, value] of formData.entries()) { - if (value instanceof File) { - proxyForm.append(key, value, value.name) - } else { - proxyForm.append(key, value) - } - } - - const result = await serverFetch(request, endpoint, { - method: 'POST', - body: proxyForm, - }) - - if (result.error) { - return data( - { error: result.error }, - { status: result.status, headers: createHeadersWithCookies(result.cookies) }, - ) - } - - return data(result.data, { - headers: createHeadersWithCookies(result.cookies), - }) - } - - return data({ error: 'Unsupported content type' }, { status: 400 }) -} diff --git a/web/app/routes/projects.$id.tsx b/web/app/routes/projects.$id.tsx index f2abddb91..c09beb611 100644 --- a/web/app/routes/projects.$id.tsx +++ b/web/app/routes/projects.$id.tsx @@ -63,6 +63,51 @@ import { hasAuthTokens, } from '~/utils/session.server' +function computeDateRangeForPeriod( + period: string, + from?: string, + to?: string, +): { from: string; to: string } | null { + if (from && to) { + return { from: `${from} 00:00:00`, to: `${to} 23:59:59` } + } + + const now = new Date() + const pad = (n: number) => String(n).padStart(2, '0') + const fmt = (d: Date) => + `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}` + + const subtract = (ms: number) => new Date(now.getTime() - ms) + const MS_HOUR = 3_600_000 + const MS_DAY = 86_400_000 + + const periodMap: Record { from: string; to: string }> = { + '1h': () => ({ from: fmt(subtract(MS_HOUR)), to: fmt(now) }), + today: () => { + const start = new Date(now) + start.setUTCHours(0, 0, 0, 0) + return { from: fmt(start), to: fmt(now) } + }, + yesterday: () => { + const dayStart = new Date(now.getTime() - MS_DAY) + dayStart.setUTCHours(0, 0, 0, 0) + const dayEnd = new Date(now.getTime() - MS_DAY) + dayEnd.setUTCHours(23, 59, 59, 999) + return { from: fmt(dayStart), to: fmt(dayEnd) } + }, + '1d': () => ({ from: fmt(subtract(MS_DAY)), to: fmt(now) }), + '7d': () => ({ from: fmt(subtract(7 * MS_DAY)), to: fmt(now) }), + '4w': () => ({ from: fmt(subtract(28 * MS_DAY)), to: fmt(now) }), + '3M': () => ({ from: fmt(subtract(90 * MS_DAY)), to: fmt(now) }), + '12M': () => ({ from: fmt(subtract(365 * MS_DAY)), to: fmt(now) }), + '24M': () => ({ from: fmt(subtract(730 * MS_DAY)), to: fmt(now) }), + all: () => ({ from: '2020-01-01 00:00:00', to: fmt(now) }), + } + + const compute = periodMap[period] + return compute ? compute() : null +} + function formatDateForBackend(dateStr: string): string { if (!dateStr) return '' @@ -269,6 +314,8 @@ export interface ProjectLoaderData { alertsData?: Promise // Funnels data funnelData?: Promise + // Data import + hasImportedData?: Promise } export async function loader({ request, params }: LoaderFunctionArgs) { @@ -386,6 +433,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { let goalsData: Promise | undefined let alertsData: Promise | undefined let funnelData: Promise | undefined + let hasImportedData: Promise | undefined // Only fetch data if project is valid and not locked if (project && !project.isLocked && projectId) { @@ -510,6 +558,15 @@ export async function loader({ request, params }: LoaderFunctionArgs) { ).then((res) => res.data) } } + + const importRange = computeDateRangeForPeriod(period, from, to) + if (importRange) { + hasImportedData = serverFetch<{ hasImportedData: boolean }>( + request, + `data-import/${projectId}/has-imported-data?from=${encodeURIComponent(importRange.from)}&to=${encodeURIComponent(importRange.to)}`, + ).then((res) => res.data?.hasImportedData === true) + .catch(() => false) + } } return data( @@ -532,6 +589,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { goalsData, alertsData, funnelData, + hasImportedData, }, { headers: createHeadersWithCookies(cookies) }, ) diff --git a/web/app/routes/projects.settings.$id.tsx b/web/app/routes/projects.settings.$id.tsx index 3e8af894c..1e8c1e231 100644 --- a/web/app/routes/projects.settings.$id.tsx +++ b/web/app/routes/projects.settings.$id.tsx @@ -8,7 +8,7 @@ import { data, redirect } from 'react-router' import { serverFetch } from '~/api/api.server' import { getOgImageUrl } from '~/lib/constants' -import { Project } from '~/lib/models/Project' +import { Project, DataImport } from '~/lib/models/Project' import { Subscriber } from '~/lib/models/Subscriber' import ProjectSettings from '~/pages/Project/Settings' import { getDescription, getPreviewImage, getTitle } from '~/utils/seo' @@ -91,6 +91,8 @@ export interface ProjectSettingsActionData { gscAuthUrl?: string gscStatus?: GscStatus gscProperties?: GscProperty[] + dataImports?: DataImport[] + dataImport?: unknown } export async function action({ request, params }: ActionFunctionArgs) { @@ -769,6 +771,92 @@ export async function action({ request, params }: ActionFunctionArgs) { ) } + case 'list-data-imports': { + const result = await serverFetch( + request, + `data-import/${id}`, + ) + + if (result.error) { + return data( + { intent, error: result.error as string }, + { status: 400 }, + ) + } + + return data( + { + intent, + success: true, + dataImports: (result.data as DataImport[]) || [], + }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'upload-data-import': { + const file = formData.get('file') as File + const provider = formData.get('provider')?.toString() + + if (!file || !provider) { + return data( + { intent, error: 'File and provider are required' }, + { status: 400 }, + ) + } + + const proxyForm = new FormData() + proxyForm.append('file', file, file.name) + proxyForm.append('provider', provider) + + const result = await serverFetch( + request, + `data-import/${id}/upload`, + { method: 'POST', body: proxyForm }, + ) + + if (result.error) { + return data( + { intent, error: result.error as string }, + { status: result.status }, + ) + } + + return data( + { intent, success: true, dataImport: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'delete-data-import': { + const importId = formData.get('importId')?.toString() + + if (!importId) { + return data( + { intent, error: 'Import ID is required' }, + { status: 400 }, + ) + } + + const result = await serverFetch( + request, + `data-import/${id}/${importId}`, + { method: 'DELETE' }, + ) + + if (result.error) { + return data( + { intent, error: result.error as string }, + { status: 400 }, + ) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + default: return data( { error: 'Unknown action' }, From bbecb4eaafe41a7a0af2f12c4d33580981f9033f Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 17:51:26 +0100 Subject: [PATCH 19/29] Remove types/multer --- backend/knip.jsonc | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/backend/knip.jsonc b/backend/knip.jsonc index 34cc19be7..096b8195c 100644 --- a/backend/knip.jsonc +++ b/backend/knip.jsonc @@ -1,10 +1,8 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", - "ignoreDependencies": [ - "@faker-js/faker", "@types/ioredis", "@types/multer" - ], + "ignoreDependencies": ["@faker-js/faker", "@types/ioredis"], "ignore": [], "exclude": ["enumMembers"], "entry": ["./apps/cloud/src/main.ts", "./apps/community/src/main.ts"], - "project": ["./apps/cloud/src/**/*.ts", "./apps/community/src/**/*.ts"] + "project": ["./apps/cloud/src/**/*.ts", "./apps/community/src/**/*.ts"], } From 2cd53f6500312393b56b82b1fc6453a8f2de6165 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 17:52:05 +0100 Subject: [PATCH 20/29] Format code --- .../apps/cloud/src/data-import/data-import.controller.ts | 5 +---- backend/apps/cloud/src/data-import/data-import.module.ts | 5 +---- .../community/src/data-import/data-import.controller.ts | 5 +---- .../apps/community/src/data-import/data-import.module.ts | 5 +---- web/app/routes/projects.$id.tsx | 3 ++- web/app/routes/projects.settings.$id.tsx | 9 ++++----- 6 files changed, 10 insertions(+), 22 deletions(-) diff --git a/backend/apps/cloud/src/data-import/data-import.controller.ts b/backend/apps/cloud/src/data-import/data-import.controller.ts index 924c36426..ab021b21a 100644 --- a/backend/apps/cloud/src/data-import/data-import.controller.ts +++ b/backend/apps/cloud/src/data-import/data-import.controller.ts @@ -30,10 +30,7 @@ import { ProjectService } from '../project/project.service' import { DataImportService } from './data-import.service' import { UploadImportDto } from './dto' import { getMapper, SUPPORTED_PROVIDERS } from './mappers' -import { - DataImportJobData, - DATA_IMPORT_QUEUE, -} from './data-import.processor' +import { DataImportJobData, DATA_IMPORT_QUEUE } from './data-import.processor' const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100 MB diff --git a/backend/apps/cloud/src/data-import/data-import.module.ts b/backend/apps/cloud/src/data-import/data-import.module.ts index aeee47568..593e1c0ee 100644 --- a/backend/apps/cloud/src/data-import/data-import.module.ts +++ b/backend/apps/cloud/src/data-import/data-import.module.ts @@ -6,10 +6,7 @@ import { ProjectModule } from '../project/project.module' import { DataImport } from './entity/data-import.entity' import { DataImportService } from './data-import.service' import { DataImportController } from './data-import.controller' -import { - DataImportProcessor, - DATA_IMPORT_QUEUE, -} from './data-import.processor' +import { DataImportProcessor, DATA_IMPORT_QUEUE } from './data-import.processor' @Module({ imports: [ diff --git a/backend/apps/community/src/data-import/data-import.controller.ts b/backend/apps/community/src/data-import/data-import.controller.ts index d89a3b869..213bf5aa0 100644 --- a/backend/apps/community/src/data-import/data-import.controller.ts +++ b/backend/apps/community/src/data-import/data-import.controller.ts @@ -28,10 +28,7 @@ import { ProjectService } from '../project/project.service' import { DataImportService } from './data-import.service' import { UploadImportDto } from './dto' import { getMapper, SUPPORTED_PROVIDERS } from './mappers' -import { - DataImportJobData, - DATA_IMPORT_QUEUE, -} from './data-import.processor' +import { DataImportJobData, DATA_IMPORT_QUEUE } from './data-import.processor' const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100 MB diff --git a/backend/apps/community/src/data-import/data-import.module.ts b/backend/apps/community/src/data-import/data-import.module.ts index 7bce1c112..90b6b48ee 100644 --- a/backend/apps/community/src/data-import/data-import.module.ts +++ b/backend/apps/community/src/data-import/data-import.module.ts @@ -4,10 +4,7 @@ import { BullModule } from '@nestjs/bullmq' import { ProjectModule } from '../project/project.module' import { DataImportService } from './data-import.service' import { DataImportController } from './data-import.controller' -import { - DataImportProcessor, - DATA_IMPORT_QUEUE, -} from './data-import.processor' +import { DataImportProcessor, DATA_IMPORT_QUEUE } from './data-import.processor' @Module({ imports: [ diff --git a/web/app/routes/projects.$id.tsx b/web/app/routes/projects.$id.tsx index c09beb611..b57b3dac4 100644 --- a/web/app/routes/projects.$id.tsx +++ b/web/app/routes/projects.$id.tsx @@ -564,7 +564,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) { hasImportedData = serverFetch<{ hasImportedData: boolean }>( request, `data-import/${projectId}/has-imported-data?from=${encodeURIComponent(importRange.from)}&to=${encodeURIComponent(importRange.to)}`, - ).then((res) => res.data?.hasImportedData === true) + ) + .then((res) => res.data?.hasImportedData === true) .catch(() => false) } } diff --git a/web/app/routes/projects.settings.$id.tsx b/web/app/routes/projects.settings.$id.tsx index 1e8c1e231..3edf4578a 100644 --- a/web/app/routes/projects.settings.$id.tsx +++ b/web/app/routes/projects.settings.$id.tsx @@ -809,11 +809,10 @@ export async function action({ request, params }: ActionFunctionArgs) { proxyForm.append('file', file, file.name) proxyForm.append('provider', provider) - const result = await serverFetch( - request, - `data-import/${id}/upload`, - { method: 'POST', body: proxyForm }, - ) + const result = await serverFetch(request, `data-import/${id}/upload`, { + method: 'POST', + body: proxyForm, + }) if (result.error) { return data( From 1e7bdd3c1d54d8e3620e163a95a4a77df949e5cd Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 18:52:01 +0100 Subject: [PATCH 21/29] Final polishing touches for Swetrix Data importer --- .gitignore | 2 + .../src/data-import/data-import.processor.ts | 8 +- .../data-import/mappers/mapper.interface.ts | 7 ++ .../src/data-import/mappers/umami.mapper.ts | 14 +-- docs/content/docs/data-import.mdx | 82 ++++++++---------- docs/next-env.d.ts | 6 -- .../data-import/imported-data-tooltip.png | Bin 0 -> 40560 bytes .../data-import/swetrix-import-data-tab.png | Bin 0 -> 48333 bytes .../swetrix-umami-upload-modal.png | Bin 0 -> 51098 bytes .../data-import/umami-export-data.png | Bin 0 -> 35280 bytes .../Settings/components/DataImportTab.tsx | 73 ++++++++-------- web/app/pages/Project/View/ViewProject.tsx | 25 +----- .../Project/tabs/Traffic/TrafficView.tsx | 9 +- web/public/locales/en.json | 7 +- 14 files changed, 111 insertions(+), 122 deletions(-) delete mode 100644 docs/next-env.d.ts create mode 100644 docs/public/img/sitesettings/data-import/imported-data-tooltip.png create mode 100644 docs/public/img/sitesettings/data-import/swetrix-import-data-tab.png create mode 100644 docs/public/img/sitesettings/data-import/swetrix-umami-upload-modal.png create mode 100644 docs/public/img/sitesettings/data-import/umami-export-data.png diff --git a/.gitignore b/.gitignore index 25d9e41dd..b05fe8575 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,5 @@ docs/.cache-loader .cursorrules .cursor + +next-env.d.ts diff --git a/backend/apps/cloud/src/data-import/data-import.processor.ts b/backend/apps/cloud/src/data-import/data-import.processor.ts index e7f0bdf9c..488b060b1 100644 --- a/backend/apps/cloud/src/data-import/data-import.processor.ts +++ b/backend/apps/cloud/src/data-import/data-import.processor.ts @@ -5,6 +5,7 @@ import { Job } from 'bullmq' import { DataImportService } from './data-import.service' import { getMapper } from './mappers' +import { ImportError } from './mappers/mapper.interface' import { clickhouse } from '../common/integrations/clickhouse' const CLICKHOUSE_DB = process.env.CLICKHOUSE_DATABASE || 'analytics' @@ -126,7 +127,12 @@ export class DataImportProcessor extends WorkerHost { ) } - await this.dataImportService.markFailed(id, error.message) + const userMessage = + error instanceof ImportError + ? error.message + : 'An unexpected error occurred while processing the import. Please try again or contact support.' + + await this.dataImportService.markFailed(id, userMessage) } } finally { this.cleanupFile(filePath) diff --git a/backend/apps/cloud/src/data-import/mappers/mapper.interface.ts b/backend/apps/cloud/src/data-import/mappers/mapper.interface.ts index 68cc5c3e8..b7bdc9bf0 100644 --- a/backend/apps/cloud/src/data-import/mappers/mapper.interface.ts +++ b/backend/apps/cloud/src/data-import/mappers/mapper.interface.ts @@ -1,3 +1,10 @@ +export class ImportError extends Error { + constructor(message: string) { + super(message) + this.name = 'ImportError' + } +} + export interface AnalyticsImportRow { table: 'analytics' | 'customEV' data: Record diff --git a/backend/apps/cloud/src/data-import/mappers/umami.mapper.ts b/backend/apps/cloud/src/data-import/mappers/umami.mapper.ts index a1158230b..99fc7a3fd 100644 --- a/backend/apps/cloud/src/data-import/mappers/umami.mapper.ts +++ b/backend/apps/cloud/src/data-import/mappers/umami.mapper.ts @@ -5,7 +5,11 @@ import * as crypto from 'crypto' import { Unzip, UnzipInflate } from 'fflate' import { parse } from 'csv-parse' -import { ImportMapper, AnalyticsImportRow } from './mapper.interface' +import { + ImportMapper, + ImportError, + AnalyticsImportRow, +} from './mapper.interface' const WEBSITE_EVENT_CSV = 'website_event.csv' const MAX_UMAMI_CSV_BYTES = 512 * 1024 * 1024 @@ -207,7 +211,7 @@ export class UmamiMapper implements ImportMapper { !Number.isFinite(file.originalSize) ) { fail( - new Error( + new ImportError( `Unable to validate ${WEBSITE_EVENT_CSV} size in the ZIP archive.`, ), ) @@ -216,7 +220,7 @@ export class UmamiMapper implements ImportMapper { if (file.originalSize > MAX_UMAMI_CSV_BYTES) { fail( - new Error( + new ImportError( `${WEBSITE_EVENT_CSV} exceeds the ${MAX_UMAMI_CSV_BYTES} byte limit.`, ), ) @@ -241,7 +245,7 @@ export class UmamiMapper implements ImportMapper { extractedBytes += chunk.length if (extractedBytes > MAX_UMAMI_CSV_BYTES) { fail( - new Error( + new ImportError( `${WEBSITE_EVENT_CSV} exceeds the ${MAX_UMAMI_CSV_BYTES} byte limit.`, ), ) @@ -291,7 +295,7 @@ export class UmamiMapper implements ImportMapper { if (!foundEntry) { fail( - new Error( + new ImportError( `ZIP does not contain ${WEBSITE_EVENT_CSV}. Please upload the export ZIP from Umami.`, ), ) diff --git a/docs/content/docs/data-import.mdx b/docs/content/docs/data-import.mdx index 346367800..cce446950 100644 --- a/docs/content/docs/data-import.mdx +++ b/docs/content/docs/data-import.mdx @@ -5,51 +5,54 @@ description: Import historical analytics data from third-party platforms into Sw # Data Import -Swetrix allows you to import historical analytics data from other platforms so you can switch without losing your history. Imported data appears alongside your live-tracked data in dashboards, charts, and reports. +Swetrix allows you to import historical analytics data from other platforms so you can switch without losing your history. Imported data appears alongside your live-tracked data in dashboards, charts, and reports. It's tagged internally so it can be identified and, if needed, removed later without affecting your live-tracked data. ## Supported Providers -| Provider | File Format | Status | -|----------|------------|--------| -| Umami | ZIP | Available | -| Plausible | ZIP (CSV) | Coming soon | -| Fathom | CSV | Coming soon | -| Simple Analytics | CSV | Coming soon | - -## How It Works - -1. **Export** your data from the source platform -2. **Upload** the exported file to Swetrix -3. **Swetrix processes** the data in the background -4. **Imported data** appears in your analytics dashboards - -Imported data is tagged internally so it can be identified and, if needed, removed later without affecting your live-tracked data. +- Umami ## Importing from Umami -### Step 1: Export Your Data from Umami + + +### Export Your Data from Umami 1. Log in to your Umami instance -2. Go to **Settings** → **Websites** -3. Click on the website you want to export -4. Navigate to the **Data** section -5. Click **Export** to download a ZIP archive (`.zip`) +2. Go to **Settings** → **Data** +3. Click on the export button +4. Select a website you want to export and a date range, then click export - Umami will email you a ZIP file with the exported data -[SCREENSHOT: Umami export data page] +Umami export data page -### Step 2: Import into Swetrix +You can learn more on how to export your data from Umami on their [data export documentation](https://docs.umami.is/docs/cloud/export-data). + + + +### Import into Swetrix 1. Go to your project's **Settings** page in Swetrix 2. Click the **Import data** tab + +Swetrix import data tab + 3. Click the **Umami** provider card + +Swetrix import data tab with provider cards + 4. In the upload modal, either drag and drop the `.zip` file or click to browse 5. Wait for the upload and processing to complete -[SCREENSHOT: Swetrix import data tab with provider cards] - -[SCREENSHOT: Swetrix upload modal] +Upload the `.zip` file from Umami directly - do not extract it first. -### Step 3: Monitor Progress + + +### Monitor Progress After uploading, the import will appear in the **Import history** table below the provider cards. You can see: @@ -59,13 +62,17 @@ After uploading, the import will appear in the **Import history** table below th The page auto-refreshes while an import is processing. You can navigate away and come back later. -[SCREENSHOT: Import history table showing a completed import] + + ## Viewing Imported Data Once an import completes, the data is immediately available in your analytics dashboard. When you select a date range that includes imported data, a small **"Imported"** badge appears near the period selector to let you know. -[SCREENSHOT: Dashboard with imported data indicator badge] +Dashboard with imported data indicator badge ## Deleting an Import @@ -78,30 +85,17 @@ If you want to remove imported data: This permanently removes all analytics rows associated with that import. Your live-tracked data is not affected. -## Data Mapping - -When importing from Umami, Swetrix maps field names and values to match its internal format: - -- **Browsers** — Umami's lowercase identifiers (e.g., `chrome`, `firefox`, `fxios`) are mapped to Swetrix display names (e.g., `Chrome`, `Firefox`, `Mobile Firefox`) -- **Operating systems** — Names are normalized (e.g., `Mac OS` → `macOS`, `Android OS` → `Android`) -- **Devices** — Categories are mapped (e.g., `laptop` → `desktop`) -- **UTM parameters** — Imported directly (`utm_source`, `utm_medium`, `utm_campaign`, etc.) -- **Geolocation** — Country codes, region codes, and city names are imported as-is -- **Custom events** — Umami event type `2` rows are imported as custom events - ## Limitations - **File size**: Maximum 100 MB per upload - **One at a time**: Only one import can be active (pending or processing) per project at a time -- **ZIP format**: Upload the `.zip` file from Umami directly — Swetrix extracts `website_event.csv` from it automatically (session and event metadata files are not used) -- **No performance data**: Web Vitals from Umami (LCP, FCP, etc.) are not imported as they use a different measurement schema - **Timestamps**: Events with invalid timestamps are skipped during import ## Troubleshooting ### Import stuck in "Processing" -Large files may take several minutes to process. If an import seems stuck, wait up to 30 minutes. If it still hasn't completed, the import may have failed — check for error messages in the import history. +Large files may take several minutes to process. If an import seems stuck, wait up to 30 minutes. If it still hasn't completed, the import may have failed - check for error messages in the import history. ### "An import is already in progress" @@ -109,7 +103,7 @@ Only one import can run at a time per project. Wait for the current import to co ### Invalid file type error -Make sure you're uploading the correct file format for your provider. For Umami, upload the `.zip` file from the export directly — do not extract it first. +Make sure you're uploading the correct file format for your provider. For Umami, upload the `.zip` file from the export directly - do not extract it first. ### Data not appearing in charts diff --git a/docs/next-env.d.ts b/docs/next-env.d.ts deleted file mode 100644 index 9edff1c7c..000000000 --- a/docs/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -import "./.next/types/routes.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/docs/public/img/sitesettings/data-import/imported-data-tooltip.png b/docs/public/img/sitesettings/data-import/imported-data-tooltip.png new file mode 100644 index 0000000000000000000000000000000000000000..45b1235f5e3074fb5372e5647233d2714b98f15f GIT binary patch literal 40560 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 literal 0 HcmV?d00001 diff --git a/docs/public/img/sitesettings/data-import/swetrix-umami-upload-modal.png b/docs/public/img/sitesettings/data-import/swetrix-umami-upload-modal.png new file mode 100644 index 0000000000000000000000000000000000000000..17824bfb0c3fded4ccab7c7b2e9608600a5e7364 GIT binary patch literal 51098 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 Y8(V%t9o>~ff)-X+(N?Zdw0QZy0KG`PP5=M^ literal 0 HcmV?d00001 diff --git a/docs/public/img/sitesettings/data-import/umami-export-data.png b/docs/public/img/sitesettings/data-import/umami-export-data.png new file mode 100644 index 0000000000000000000000000000000000000000..a3430cedb961326b9184e06d09214f0251586aff GIT binary patch literal 35280 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 ^z3OS|%8hClO8w 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 LP`lzLEb_kr9GLeH literal 0 HcmV?d00001 diff --git a/web/app/pages/Project/Settings/components/DataImportTab.tsx b/web/app/pages/Project/Settings/components/DataImportTab.tsx index cad78861a..be2ae570f 100644 --- a/web/app/pages/Project/Settings/components/DataImportTab.tsx +++ b/web/app/pages/Project/Settings/components/DataImportTab.tsx @@ -11,7 +11,7 @@ import { import cx from 'clsx' import { toast } from 'sonner' import dayjs from 'dayjs' -import { useTranslation } from 'react-i18next' +import { Trans, useTranslation } from 'react-i18next' import { useFetcher } from 'react-router' import { DataImport } from '~/lib/models/Project' @@ -202,9 +202,6 @@ export default function DataImportTab({ projectId }: DataImportTabProps) { const providerFileType = selectedProvider ? t(`project.settings.dataImport.${selectedProvider}.fileType`) : '' - const providerExportGuide = selectedProvider - ? t(`project.settings.dataImport.${selectedProvider}.exportGuide`) - : '' return (
@@ -229,9 +226,6 @@ export default function DataImportTab({ projectId }: DataImportTabProps) { ) const name = providerId.charAt(0).toUpperCase() + providerId.slice(1) - const description = t( - `project.settings.dataImport.${providerId}.description`, - ) const fileType = t( `project.settings.dataImport.${providerId}.fileType`, ) @@ -243,7 +237,7 @@ export default function DataImportTab({ projectId }: DataImportTabProps) { disabled={hasActive} onClick={() => setSelectedProvider(providerId)} className={cx( - 'group relative flex flex-col items-start rounded-xl border p-4 text-left transition-all', + 'group relative flex flex-col items-start rounded-lg border p-4 text-left transition-all', hasActive ? 'cursor-not-allowed border-gray-200 opacity-50 dark:border-slate-800' : 'cursor-pointer border-gray-200 hover:border-gray-300 hover:bg-gray-50 dark:border-slate-700/80 dark:hover:border-slate-600 dark:hover:bg-slate-900/50', @@ -273,7 +267,7 @@ export default function DataImportTab({ projectId }: DataImportTabProps) { colour='inherit' className='mt-2 text-gray-500 dark:text-gray-400' > - {description} + {t(`project.settings.dataImport.${providerId}.description`)} ) @@ -388,14 +382,18 @@ export default function DataImportTab({ projectId }: DataImportTabProps) { {(imp.status === 'completed' || imp.status === 'failed') && ( - +
+ +
)} @@ -420,25 +418,27 @@ export default function DataImportTab({ projectId }: DataImportTabProps) { size='medium' message={
-
- - {t('project.settings.dataImport.howToExport')} - - - {providerExportGuide} - -
+ + + ), + }} + /> + diff --git a/web/app/pages/Project/View/ViewProject.tsx b/web/app/pages/Project/View/ViewProject.tsx index 60187dc3b..e2e0a66dc 100644 --- a/web/app/pages/Project/View/ViewProject.tsx +++ b/web/app/pages/Project/View/ViewProject.tsx @@ -38,7 +38,6 @@ import { useTranslation } from 'react-i18next' import { useNavigate, useSearchParams, - useLoaderData, LinkProps, useFetcher, } from 'react-router' @@ -72,10 +71,7 @@ import { import ViewProjectHotkeys from '~/modals/ViewProjectHotkeys' import { useAuth } from '~/providers/AuthProvider' import { useTheme } from '~/providers/ThemeProvider' -import { - ProjectViewActionData, - type ProjectLoaderData, -} from '~/routes/projects.$id' +import { ProjectViewActionData } from '~/routes/projects.$id' import Dropdown from '~/ui/Dropdown' import Flag from '~/ui/Flag' import Loader from '~/ui/Loader' @@ -168,8 +164,6 @@ interface ViewProjectContextType { resetDateRange: () => void refreshStats: (isManual?: boolean) => Promise - hasImportedData: boolean - refCalendar: React.RefObject refCalendarCompare: React.RefObject @@ -231,7 +225,6 @@ const defaultViewProjectContext: ViewProjectContextType = { setShowFiltersSearch: () => {}, resetDateRange: () => {}, refreshStats: async () => {}, - hasImportedData: false, refCalendar: { current: null } as any, refCalendarCompare: { current: null } as any, @@ -427,19 +420,6 @@ const ViewProjectContent = () => { return null }, [period, searchParams]) - const loaderData = useLoaderData() - const [hasImportedData, setHasImportedData] = useState(false) - - useEffect(() => { - if (loaderData?.hasImportedData instanceof Promise) { - loaderData.hasImportedData - .then((value) => setHasImportedData(value)) - .catch(() => setHasImportedData(false)) - } else { - setHasImportedData(false) - } - }, [loaderData?.hasImportedData]) - const periodPairs = useMemo(() => { let tbs = null @@ -1212,8 +1192,6 @@ const ViewProjectContent = () => { resetDateRange, refreshStats, - hasImportedData, - refCalendar, refCalendarCompare, fullscreenMapRef, @@ -1255,7 +1233,6 @@ const ViewProjectContent = () => { setShowFiltersSearch, resetDateRange, refreshStats, - hasImportedData, refCalendar, refCalendarCompare, fullscreenMapRef, diff --git a/web/app/pages/Project/tabs/Traffic/TrafficView.tsx b/web/app/pages/Project/tabs/Traffic/TrafficView.tsx index af949c75e..c303b529b 100644 --- a/web/app/pages/Project/tabs/Traffic/TrafficView.tsx +++ b/web/app/pages/Project/tabs/Traffic/TrafficView.tsx @@ -141,6 +141,7 @@ interface DeferredTrafficData { trafficCompareData: ServerTrafficLogResponse | null overallCompareStats: Record | null customEventsData: { chart?: { events?: Record } } | null + hasImportedData?: boolean } interface TrafficErrorBoundaryState { @@ -198,6 +199,7 @@ function TrafficDataResolver({ trafficCompareData: trafficComparePromise, overallCompareStats: overallComparePromise, customEventsData: customEventsPromise, + hasImportedData: hasImportedDataPromise, } = useLoaderData() const trafficData = trafficDataPromise ? use(trafficDataPromise) : null @@ -209,6 +211,9 @@ function TrafficDataResolver({ ? use(overallComparePromise) : null const customEventsData = customEventsPromise ? use(customEventsPromise) : null + const hasImportedData = hasImportedDataPromise + ? use(hasImportedDataPromise) + : false return ( <> @@ -218,6 +223,7 @@ function TrafficDataResolver({ trafficCompareData, overallCompareStats, customEventsData, + hasImportedData, })} ) @@ -286,7 +292,6 @@ const TrafficViewInner = ({ isMapFullscreen, setIsMapFullscreen, fullscreenMapRef, - hasImportedData, } = useViewProjectContext() const { t, @@ -1057,7 +1062,7 @@ const TrafficViewInner = ({ onSwitch={setChartTypeOnClick} type={chartType} /> - {hasImportedData ? ( + {deferredData.hasImportedData ? ( see our docs.", "umami": { "description": "Import pageviews and custom events from Umami export", - "fileType": ".zip", - "exportGuide": "In Umami, go to Settings → Websites → click your site → Data → Export. Upload the resulting .zip file directly." + "fileType": ".zip" } }, "captcha": { From b20fff192f8de1a7a793c2832352a73ac9f0bece Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 18:55:24 +0100 Subject: [PATCH 22/29] fix: Uncontrolled data used in path expression --- .../src/data-import/data-import.controller.ts | 24 +++++++++++++++---- .../src/data-import/data-import.processor.ts | 21 ++++++++++++++-- .../src/data-import/data-import.controller.ts | 24 +++++++++++++++---- .../src/data-import/data-import.processor.ts | 21 ++++++++++++++-- 4 files changed, 76 insertions(+), 14 deletions(-) diff --git a/backend/apps/cloud/src/data-import/data-import.controller.ts b/backend/apps/cloud/src/data-import/data-import.controller.ts index ab021b21a..74e353cb7 100644 --- a/backend/apps/cloud/src/data-import/data-import.controller.ts +++ b/backend/apps/cloud/src/data-import/data-import.controller.ts @@ -33,6 +33,7 @@ import { getMapper, SUPPORTED_PROVIDERS } from './mappers' import { DataImportJobData, DATA_IMPORT_QUEUE } from './data-import.processor' const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100 MB +const IMPORT_TMP_DIR = path.resolve(os.tmpdir(), 'swetrix-imports') @ApiTags('Data Import') @UseGuards(AuthenticationGuard) @@ -65,9 +66,8 @@ export class DataImportController { limits: { fileSize: MAX_FILE_SIZE }, storage: diskStorage({ destination: (_req, _file, cb) => { - const dir = path.join(os.tmpdir(), 'swetrix-imports') - fs.mkdirSync(dir, { recursive: true }) - cb(null, dir) + fs.mkdirSync(IMPORT_TMP_DIR, { recursive: true }) + cb(null, IMPORT_TMP_DIR) }, filename: (_req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase() @@ -207,8 +207,22 @@ export class DataImportController { private cleanupFile(filePath: string): void { try { - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath) + const resolvedPath = path.resolve(filePath) + const relativePath = path.relative(IMPORT_TMP_DIR, resolvedPath) + const isImportTempFile = + relativePath !== '' && + !relativePath.startsWith('..') && + !path.isAbsolute(relativePath) + + if (!isImportTempFile) { + this.logger.warn( + `Skipped cleanup outside import temp dir: ${resolvedPath}`, + ) + return + } + + if (fs.existsSync(resolvedPath) && fs.lstatSync(resolvedPath).isFile()) { + fs.unlinkSync(resolvedPath) } } catch { // diff --git a/backend/apps/cloud/src/data-import/data-import.processor.ts b/backend/apps/cloud/src/data-import/data-import.processor.ts index 488b060b1..14eba24a1 100644 --- a/backend/apps/cloud/src/data-import/data-import.processor.ts +++ b/backend/apps/cloud/src/data-import/data-import.processor.ts @@ -1,4 +1,6 @@ import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' import { Processor, WorkerHost } from '@nestjs/bullmq' import { Logger } from '@nestjs/common' import { Job } from 'bullmq' @@ -10,6 +12,7 @@ import { clickhouse } from '../common/integrations/clickhouse' const CLICKHOUSE_DB = process.env.CLICKHOUSE_DATABASE || 'analytics' const BATCH_SIZE = 5000 +const IMPORT_TMP_DIR = path.resolve(os.tmpdir(), 'swetrix-imports') export const DATA_IMPORT_QUEUE = 'data-import' @@ -152,8 +155,22 @@ export class DataImportProcessor extends WorkerHost { private cleanupFile(filePath: string): void { try { - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath) + const resolvedPath = path.resolve(filePath) + const relativePath = path.relative(IMPORT_TMP_DIR, resolvedPath) + const isImportTempFile = + relativePath !== '' && + !relativePath.startsWith('..') && + !path.isAbsolute(relativePath) + + if (!isImportTempFile) { + this.logger.warn( + `Skipped cleanup outside import temp dir: ${resolvedPath}`, + ) + return + } + + if (fs.existsSync(resolvedPath) && fs.lstatSync(resolvedPath).isFile()) { + fs.unlinkSync(resolvedPath) } } catch { this.logger.warn(`Failed to clean up temp file: ${filePath}`) diff --git a/backend/apps/community/src/data-import/data-import.controller.ts b/backend/apps/community/src/data-import/data-import.controller.ts index 213bf5aa0..009634af3 100644 --- a/backend/apps/community/src/data-import/data-import.controller.ts +++ b/backend/apps/community/src/data-import/data-import.controller.ts @@ -31,6 +31,7 @@ import { getMapper, SUPPORTED_PROVIDERS } from './mappers' import { DataImportJobData, DATA_IMPORT_QUEUE } from './data-import.processor' const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100 MB +const IMPORT_TMP_DIR = path.resolve(os.tmpdir(), 'swetrix-imports') @ApiTags('Data Import') @Controller('data-import') @@ -61,9 +62,8 @@ export class DataImportController { limits: { fileSize: MAX_FILE_SIZE }, storage: diskStorage({ destination: (_req, _file, cb) => { - const dir = path.join(os.tmpdir(), 'swetrix-imports') - fs.mkdirSync(dir, { recursive: true }) - cb(null, dir) + fs.mkdirSync(IMPORT_TMP_DIR, { recursive: true }) + cb(null, IMPORT_TMP_DIR) }, filename: (_req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase() @@ -198,8 +198,22 @@ export class DataImportController { private cleanupFile(filePath: string): void { try { - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath) + const resolvedPath = path.resolve(filePath) + const relativePath = path.relative(IMPORT_TMP_DIR, resolvedPath) + const isImportTempFile = + relativePath !== '' && + !relativePath.startsWith('..') && + !path.isAbsolute(relativePath) + + if (!isImportTempFile) { + this.logger.warn( + `Skipped cleanup outside import temp dir: ${resolvedPath}`, + ) + return + } + + if (fs.existsSync(resolvedPath) && fs.lstatSync(resolvedPath).isFile()) { + fs.unlinkSync(resolvedPath) } } catch { // diff --git a/backend/apps/community/src/data-import/data-import.processor.ts b/backend/apps/community/src/data-import/data-import.processor.ts index 5b8d0b0cf..50c321096 100644 --- a/backend/apps/community/src/data-import/data-import.processor.ts +++ b/backend/apps/community/src/data-import/data-import.processor.ts @@ -1,4 +1,6 @@ import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' import { Processor, WorkerHost } from '@nestjs/bullmq' import { Logger } from '@nestjs/common' import { Job } from 'bullmq' @@ -9,6 +11,7 @@ import { clickhouse } from '../common/integrations/clickhouse' const CLICKHOUSE_DB = process.env.CLICKHOUSE_DATABASE || 'analytics' const BATCH_SIZE = 5000 +const IMPORT_TMP_DIR = path.resolve(os.tmpdir(), 'swetrix-imports') export const DATA_IMPORT_QUEUE = 'data-import' @@ -155,8 +158,22 @@ export class DataImportProcessor extends WorkerHost { private cleanupFile(filePath: string): void { try { - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath) + const resolvedPath = path.resolve(filePath) + const relativePath = path.relative(IMPORT_TMP_DIR, resolvedPath) + const isImportTempFile = + relativePath !== '' && + !relativePath.startsWith('..') && + !path.isAbsolute(relativePath) + + if (!isImportTempFile) { + this.logger.warn( + `Skipped cleanup outside import temp dir: ${resolvedPath}`, + ) + return + } + + if (fs.existsSync(resolvedPath) && fs.lstatSync(resolvedPath).isFile()) { + fs.unlinkSync(resolvedPath) } } catch { this.logger.warn(`Failed to clean up temp file: ${filePath}`) From 1811e8f840659f35f5239852bb5311120e3b6db9 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 18:56:56 +0100 Subject: [PATCH 23/29] fix: Parse integer for importId var --- backend/apps/cloud/src/data-import/data-import.controller.ts | 5 +++-- .../apps/community/src/data-import/data-import.controller.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/apps/cloud/src/data-import/data-import.controller.ts b/backend/apps/cloud/src/data-import/data-import.controller.ts index 74e353cb7..52a9d26db 100644 --- a/backend/apps/cloud/src/data-import/data-import.controller.ts +++ b/backend/apps/cloud/src/data-import/data-import.controller.ts @@ -8,6 +8,7 @@ import { Get, Delete, Param, + ParseIntPipe, Body, UseGuards, UseInterceptors, @@ -183,7 +184,7 @@ export class DataImportController { @ApiBearerAuth() async getOne( @Param('projectId') projectId: string, - @Param('importId') importId: number, + @Param('importId', ParseIntPipe) importId: number, @CurrentUserId() uid: string, ) { const project = await this.projectService.getFullProject(projectId) @@ -196,7 +197,7 @@ export class DataImportController { @ApiBearerAuth() async deleteImport( @Param('projectId') projectId: string, - @Param('importId') importId: number, + @Param('importId', ParseIntPipe) importId: number, @CurrentUserId() uid: string, ) { const project = await this.projectService.getFullProject(projectId) diff --git a/backend/apps/community/src/data-import/data-import.controller.ts b/backend/apps/community/src/data-import/data-import.controller.ts index 009634af3..7e08ba784 100644 --- a/backend/apps/community/src/data-import/data-import.controller.ts +++ b/backend/apps/community/src/data-import/data-import.controller.ts @@ -8,6 +8,7 @@ import { Get, Delete, Param, + ParseIntPipe, Body, UseInterceptors, UploadedFile, @@ -175,7 +176,7 @@ export class DataImportController { @Auth() async getOne( @Param('projectId') projectId: string, - @Param('importId') importId: number, + @Param('importId', ParseIntPipe) importId: number, @CurrentUserId() uid: string, ) { const project = await this.projectService.getFullProject(projectId) @@ -187,7 +188,7 @@ export class DataImportController { @Auth() async deleteImport( @Param('projectId') projectId: string, - @Param('importId') importId: number, + @Param('importId', ParseIntPipe) importId: number, @CurrentUserId() uid: string, ) { const project = await this.projectService.getFullProject(projectId) From 0610ed156ef37903965f50a12949536d39a6faee Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 18:58:02 +0100 Subject: [PATCH 24/29] fix: Catch any potential errors in job.updateProgress --- .../src/data-import/data-import.processor.ts | 16 ++++++++++++++-- .../src/data-import/data-import.processor.ts | 16 ++++++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/backend/apps/cloud/src/data-import/data-import.processor.ts b/backend/apps/cloud/src/data-import/data-import.processor.ts index 14eba24a1..1e7d91648 100644 --- a/backend/apps/cloud/src/data-import/data-import.processor.ts +++ b/backend/apps/cloud/src/data-import/data-import.processor.ts @@ -92,11 +92,23 @@ export class DataImportProcessor extends WorkerHost { } if (totalRows % 10000 === 0) { - await job.updateProgress({ + const progress = { importedRows: importedRows + analyticsBatch.length + customEVBatch.length, totalRows, - }) + } + + try { + await job.updateProgress(progress) + } catch (progressError) { + const errorMessage = + progressError instanceof Error + ? progressError.message + : String(progressError) + this.logger.error( + `Failed to update progress for import ${id} (importId=${importId}, importedRows=${progress.importedRows}, totalRows=${progress.totalRows}): ${errorMessage}`, + ) + } } } diff --git a/backend/apps/community/src/data-import/data-import.processor.ts b/backend/apps/community/src/data-import/data-import.processor.ts index 50c321096..ea68841af 100644 --- a/backend/apps/community/src/data-import/data-import.processor.ts +++ b/backend/apps/community/src/data-import/data-import.processor.ts @@ -91,11 +91,23 @@ export class DataImportProcessor extends WorkerHost { } if (totalRows % 10000 === 0) { - await job.updateProgress({ + const progress = { importedRows: importedRows + analyticsBatch.length + customEVBatch.length, totalRows, - }) + } + + try { + await job.updateProgress(progress) + } catch (progressError) { + const errorMessage = + progressError instanceof Error + ? progressError.message + : String(progressError) + this.logger.error( + `Failed to update progress for import ${importId} (projectId=${projectId}, importedRows=${progress.importedRows}, totalRows=${progress.totalRows}): ${errorMessage}`, + ) + } } } From 68ff38de4f105316dddab0a4e55fd0d956aa5310 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 19:08:49 +0100 Subject: [PATCH 25/29] Refactor file handling --- .../src/data-import/data-import.controller.ts | 38 +++++++++------- .../src/data-import/data-import.processor.ts | 43 ++++++++++++------ .../src/data-import/data-import.controller.ts | 38 +++++++++------- .../src/data-import/data-import.processor.ts | 44 +++++++++++++------ 4 files changed, 103 insertions(+), 60 deletions(-) diff --git a/backend/apps/cloud/src/data-import/data-import.controller.ts b/backend/apps/cloud/src/data-import/data-import.controller.ts index 52a9d26db..75bbcda9f 100644 --- a/backend/apps/cloud/src/data-import/data-import.controller.ts +++ b/backend/apps/cloud/src/data-import/data-import.controller.ts @@ -86,13 +86,13 @@ export class DataImportController { const project = await this.projectService.getFullProject(projectId) this.projectService.allowedToManage(project, uid) - if (!file?.path) { + if (!file?.filename) { throw new BadRequestException('No file uploaded') } const mapper = getMapper(dto.provider) if (!mapper) { - this.cleanupFile(file.path) + this.cleanupFile(file.filename) throw new BadRequestException( `Unsupported provider: ${dto.provider}. Supported: ${SUPPORTED_PROVIDERS.join(', ')}`, ) @@ -100,7 +100,7 @@ export class DataImportController { const ext = path.extname(file.originalname).toLowerCase() if (ext !== mapper.expectedFileExtension) { - this.cleanupFile(file.path) + this.cleanupFile(file.filename) throw new BadRequestException( `Invalid file type. Expected a ${mapper.expectedFileExtension} file for ${dto.provider} import.`, ) @@ -110,7 +110,7 @@ export class DataImportController { try { dataImport = await this.dataImportService.create(projectId, dto.provider) } catch (error) { - this.cleanupFile(file.path) + this.cleanupFile(file.filename) throw error } @@ -122,7 +122,7 @@ export class DataImportController { importId: dataImport.importId, projectId, provider: dto.provider, - filePath: file.path, + fileName: file.filename, }, { attempts: 1, @@ -133,7 +133,7 @@ export class DataImportController { } catch (error) { this.logger.error(`Failed to enqueue import job: ${error.message}`) await this.dataImportService.deleteImportRecord(dataImport.id) - this.cleanupFile(file.path) + this.cleanupFile(file.filename) throw new ServiceUnavailableException( 'Failed to start import. Please try again later.', ) @@ -206,18 +206,12 @@ export class DataImportController { return { success: true } } - private cleanupFile(filePath: string): void { + private cleanupFile(fileName: string): void { try { - const resolvedPath = path.resolve(filePath) - const relativePath = path.relative(IMPORT_TMP_DIR, resolvedPath) - const isImportTempFile = - relativePath !== '' && - !relativePath.startsWith('..') && - !path.isAbsolute(relativePath) - - if (!isImportTempFile) { + const resolvedPath = this.getImportFilePath(fileName) + if (!resolvedPath) { this.logger.warn( - `Skipped cleanup outside import temp dir: ${resolvedPath}`, + `Skipped cleanup for invalid import file name: ${fileName}`, ) return } @@ -229,4 +223,16 @@ export class DataImportController { // } } + + private getImportFilePath(fileName: string): string | null { + if ( + !fileName || + fileName !== path.basename(fileName) || + /[\\/]/.test(fileName) + ) { + return null + } + + return path.join(IMPORT_TMP_DIR, fileName) + } } diff --git a/backend/apps/cloud/src/data-import/data-import.processor.ts b/backend/apps/cloud/src/data-import/data-import.processor.ts index 1e7d91648..d64b66a14 100644 --- a/backend/apps/cloud/src/data-import/data-import.processor.ts +++ b/backend/apps/cloud/src/data-import/data-import.processor.ts @@ -21,7 +21,7 @@ export interface DataImportJobData { importId: number projectId: string provider: string - filePath: string + fileName: string } @Processor(DATA_IMPORT_QUEUE, { concurrency: 1 }) @@ -33,7 +33,8 @@ export class DataImportProcessor extends WorkerHost { } async process(job: Job): Promise { - const { id, importId, projectId, provider, filePath } = job.data + const { id, importId, projectId, provider, fileName } = job.data + const filePath = this.getImportFilePath(fileName) try { this.logger.log( @@ -42,6 +43,14 @@ export class DataImportProcessor extends WorkerHost { await this.dataImportService.markProcessing(id) + if (!filePath) { + await this.dataImportService.markFailed( + id, + 'Invalid import file reference. Please upload the file again.', + ) + return + } + const mapper = getMapper(provider) if (!mapper) { await this.dataImportService.markFailed( @@ -150,7 +159,7 @@ export class DataImportProcessor extends WorkerHost { await this.dataImportService.markFailed(id, userMessage) } } finally { - this.cleanupFile(filePath) + this.cleanupFile(fileName) } } @@ -165,18 +174,12 @@ export class DataImportProcessor extends WorkerHost { }) } - private cleanupFile(filePath: string): void { + private cleanupFile(fileName: string): void { try { - const resolvedPath = path.resolve(filePath) - const relativePath = path.relative(IMPORT_TMP_DIR, resolvedPath) - const isImportTempFile = - relativePath !== '' && - !relativePath.startsWith('..') && - !path.isAbsolute(relativePath) - - if (!isImportTempFile) { + const resolvedPath = this.getImportFilePath(fileName) + if (!resolvedPath) { this.logger.warn( - `Skipped cleanup outside import temp dir: ${resolvedPath}`, + `Skipped cleanup for invalid import file name: ${fileName}`, ) return } @@ -185,7 +188,19 @@ export class DataImportProcessor extends WorkerHost { fs.unlinkSync(resolvedPath) } } catch { - this.logger.warn(`Failed to clean up temp file: ${filePath}`) + this.logger.warn(`Failed to clean up temp file: ${fileName}`) } } + + private getImportFilePath(fileName: string): string | null { + if ( + !fileName || + fileName !== path.basename(fileName) || + /[\\/]/.test(fileName) + ) { + return null + } + + return path.join(IMPORT_TMP_DIR, fileName) + } } diff --git a/backend/apps/community/src/data-import/data-import.controller.ts b/backend/apps/community/src/data-import/data-import.controller.ts index 7e08ba784..ed7b259d1 100644 --- a/backend/apps/community/src/data-import/data-import.controller.ts +++ b/backend/apps/community/src/data-import/data-import.controller.ts @@ -82,13 +82,13 @@ export class DataImportController { const project = await this.projectService.getFullProject(projectId) this.projectService.allowedToManage(project, uid) - if (!file?.path) { + if (!file?.filename) { throw new BadRequestException('No file uploaded') } const mapper = getMapper(dto.provider) if (!mapper) { - this.cleanupFile(file.path) + this.cleanupFile(file.filename) throw new BadRequestException( `Unsupported provider: ${dto.provider}. Supported: ${SUPPORTED_PROVIDERS.join(', ')}`, ) @@ -96,7 +96,7 @@ export class DataImportController { const ext = path.extname(file.originalname).toLowerCase() if (ext !== mapper.expectedFileExtension) { - this.cleanupFile(file.path) + this.cleanupFile(file.filename) throw new BadRequestException( `Invalid file type. Expected a ${mapper.expectedFileExtension} file for ${dto.provider} import.`, ) @@ -106,7 +106,7 @@ export class DataImportController { try { dataImport = await this.dataImportService.create(projectId, dto.provider) } catch (error) { - this.cleanupFile(file.path) + this.cleanupFile(file.filename) throw error } @@ -117,7 +117,7 @@ export class DataImportController { importId: dataImport.id, projectId, provider: dto.provider, - filePath: file.path, + fileName: file.filename, }, { attempts: 1, @@ -128,7 +128,7 @@ export class DataImportController { } catch (error) { this.logger.error(`Failed to enqueue import job: ${error.message}`) await this.dataImportService.deleteImportRecord(projectId, dataImport.id) - this.cleanupFile(file.path) + this.cleanupFile(file.filename) throw new ServiceUnavailableException( 'Failed to start import. Please try again later.', ) @@ -197,18 +197,12 @@ export class DataImportController { return { success: true } } - private cleanupFile(filePath: string): void { + private cleanupFile(fileName: string): void { try { - const resolvedPath = path.resolve(filePath) - const relativePath = path.relative(IMPORT_TMP_DIR, resolvedPath) - const isImportTempFile = - relativePath !== '' && - !relativePath.startsWith('..') && - !path.isAbsolute(relativePath) - - if (!isImportTempFile) { + const resolvedPath = this.getImportFilePath(fileName) + if (!resolvedPath) { this.logger.warn( - `Skipped cleanup outside import temp dir: ${resolvedPath}`, + `Skipped cleanup for invalid import file name: ${fileName}`, ) return } @@ -220,4 +214,16 @@ export class DataImportController { // } } + + private getImportFilePath(fileName: string): string | null { + if ( + !fileName || + fileName !== path.basename(fileName) || + /[\\/]/.test(fileName) + ) { + return null + } + + return path.join(IMPORT_TMP_DIR, fileName) + } } diff --git a/backend/apps/community/src/data-import/data-import.processor.ts b/backend/apps/community/src/data-import/data-import.processor.ts index ea68841af..0ede6681f 100644 --- a/backend/apps/community/src/data-import/data-import.processor.ts +++ b/backend/apps/community/src/data-import/data-import.processor.ts @@ -19,7 +19,7 @@ export interface DataImportJobData { importId: number projectId: string provider: string - filePath: string + fileName: string } @Processor(DATA_IMPORT_QUEUE, { concurrency: 1 }) @@ -31,7 +31,8 @@ export class DataImportProcessor extends WorkerHost { } async process(job: Job): Promise { - const { importId, projectId, provider, filePath } = job.data + const { importId, projectId, provider, fileName } = job.data + const filePath = this.getImportFilePath(fileName) try { this.logger.log( @@ -40,6 +41,15 @@ export class DataImportProcessor extends WorkerHost { await this.dataImportService.markProcessing(importId, projectId) + if (!filePath) { + await this.dataImportService.markFailed( + importId, + projectId, + 'Invalid import file reference. Please upload the file again.', + ) + return + } + const mapper = getMapper(provider) if (!mapper) { await this.dataImportService.markFailed( @@ -153,7 +163,7 @@ export class DataImportProcessor extends WorkerHost { ) } } finally { - this.cleanupFile(filePath) + this.cleanupFile(fileName) } } @@ -168,18 +178,12 @@ export class DataImportProcessor extends WorkerHost { }) } - private cleanupFile(filePath: string): void { + private cleanupFile(fileName: string): void { try { - const resolvedPath = path.resolve(filePath) - const relativePath = path.relative(IMPORT_TMP_DIR, resolvedPath) - const isImportTempFile = - relativePath !== '' && - !relativePath.startsWith('..') && - !path.isAbsolute(relativePath) - - if (!isImportTempFile) { + const resolvedPath = this.getImportFilePath(fileName) + if (!resolvedPath) { this.logger.warn( - `Skipped cleanup outside import temp dir: ${resolvedPath}`, + `Skipped cleanup for invalid import file name: ${fileName}`, ) return } @@ -188,7 +192,19 @@ export class DataImportProcessor extends WorkerHost { fs.unlinkSync(resolvedPath) } } catch { - this.logger.warn(`Failed to clean up temp file: ${filePath}`) + this.logger.warn(`Failed to clean up temp file: ${fileName}`) } } + + private getImportFilePath(fileName: string): string | null { + if ( + !fileName || + fileName !== path.basename(fileName) || + /[\\/]/.test(fileName) + ) { + return null + } + + return path.join(IMPORT_TMP_DIR, fileName) + } } From aeda3e64d1de15a0ed5bdb9f7a9a30ac61e90840 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 19:11:26 +0100 Subject: [PATCH 26/29] Minor fixes --- .../cloud/src/data-import/data-import.controller.ts | 8 +++++--- .../apps/cloud/src/data-import/data-import.service.ts | 10 +++++----- web/app/lib/models/Project.ts | 1 + .../Project/Settings/components/DataImportTab.tsx | 2 +- web/app/routes/projects.$id.tsx | 3 +++ 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/backend/apps/cloud/src/data-import/data-import.controller.ts b/backend/apps/cloud/src/data-import/data-import.controller.ts index 75bbcda9f..5edb0ff75 100644 --- a/backend/apps/cloud/src/data-import/data-import.controller.ts +++ b/backend/apps/cloud/src/data-import/data-import.controller.ts @@ -17,6 +17,7 @@ import { ServiceUnavailableException, Query, Logger, + Headers, } from '@nestjs/common' import { FileInterceptor } from '@nestjs/platform-express' import { diskStorage } from 'multer' @@ -155,16 +156,17 @@ export class DataImportController { } @Get(':projectId/has-imported-data') - @Auth() + @Auth(true, true) @ApiBearerAuth() async hasImportedData( @Param('projectId') projectId: string, - @CurrentUserId() uid: string, + @CurrentUserId() uid: string | null, + @Headers() headers: { 'x-password'?: string }, @Query('from') from: string, @Query('to') to: string, ) { const project = await this.projectService.getFullProject(projectId) - this.projectService.allowedToView(project, uid) + this.projectService.allowedToView(project, uid, headers['x-password']) if (!from || !to) { throw new BadRequestException('"from" and "to" query params are required') diff --git a/backend/apps/cloud/src/data-import/data-import.service.ts b/backend/apps/cloud/src/data-import/data-import.service.ts index 37f0355e9..c5c60649f 100644 --- a/backend/apps/cloud/src/data-import/data-import.service.ts +++ b/backend/apps/cloud/src/data-import/data-import.service.ts @@ -105,9 +105,9 @@ export class DataImportService { }) } - async findOne(projectId: string, id: number): Promise { + async findOne(projectId: string, importId: number): Promise { const dataImport = await this.dataImportRepository.findOne({ - where: { id, projectId }, + where: { importId, projectId }, }) if (!dataImport) { @@ -166,8 +166,8 @@ export class DataImportService { }) } - async deleteImport(projectId: string, id: number): Promise { - const dataImport = await this.findOne(projectId, id) + async deleteImport(projectId: string, importId: number): Promise { + const dataImport = await this.findOne(projectId, importId) if ( dataImport.status === DataImportStatus.PENDING || @@ -189,7 +189,7 @@ export class DataImportService { ) } - await this.dataImportRepository.delete(id) + await this.dataImportRepository.delete(dataImport.id) } async deleteImportRecord(id: number): Promise { diff --git a/web/app/lib/models/Project.ts b/web/app/lib/models/Project.ts index 448bf00bb..f761bb03c 100644 --- a/web/app/lib/models/Project.ts +++ b/web/app/lib/models/Project.ts @@ -174,6 +174,7 @@ export interface AnalyticsFunnel { export interface DataImport { id: number + importId: number projectId: string provider: string status: 'pending' | 'processing' | 'completed' | 'failed' diff --git a/web/app/pages/Project/Settings/components/DataImportTab.tsx b/web/app/pages/Project/Settings/components/DataImportTab.tsx index be2ae570f..7b868a3ae 100644 --- a/web/app/pages/Project/Settings/components/DataImportTab.tsx +++ b/web/app/pages/Project/Settings/components/DataImportTab.tsx @@ -191,7 +191,7 @@ export default function DataImportTab({ projectId }: DataImportTabProps) { if (!deleteTarget) return deleteFetcher.submit( - { intent: 'delete-data-import', importId: String(deleteTarget.id) }, + { intent: 'delete-data-import', importId: String(deleteTarget.importId) }, { method: 'POST', action: settingsAction }, ) } diff --git a/web/app/routes/projects.$id.tsx b/web/app/routes/projects.$id.tsx index b57b3dac4..e724719ea 100644 --- a/web/app/routes/projects.$id.tsx +++ b/web/app/routes/projects.$id.tsx @@ -564,6 +564,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) { hasImportedData = serverFetch<{ hasImportedData: boolean }>( request, `data-import/${projectId}/has-imported-data?from=${encodeURIComponent(importRange.from)}&to=${encodeURIComponent(importRange.to)}`, + { + headers: password ? { 'x-password': password } : undefined, + }, ) .then((res) => res.data?.hasImportedData === true) .catch(() => false) From fbb7ac7a6df8bc615e02aa54d80e601da23c9c4b Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 19:16:46 +0100 Subject: [PATCH 27/29] fix: Relax .zip file checks --- .../cloud/src/data-import/mappers/umami.mapper.ts | 14 +++----------- .../src/data-import/mappers/umami.mapper.ts | 14 +++----------- 2 files changed, 6 insertions(+), 22 deletions(-) diff --git a/backend/apps/cloud/src/data-import/mappers/umami.mapper.ts b/backend/apps/cloud/src/data-import/mappers/umami.mapper.ts index 99fc7a3fd..038ce257f 100644 --- a/backend/apps/cloud/src/data-import/mappers/umami.mapper.ts +++ b/backend/apps/cloud/src/data-import/mappers/umami.mapper.ts @@ -207,18 +207,10 @@ export class UmamiMapper implements ImportMapper { foundEntry = true if ( - typeof file.originalSize !== 'number' || - !Number.isFinite(file.originalSize) + typeof file.originalSize === 'number' && + Number.isFinite(file.originalSize) && + file.originalSize > MAX_UMAMI_CSV_BYTES ) { - fail( - new ImportError( - `Unable to validate ${WEBSITE_EVENT_CSV} size in the ZIP archive.`, - ), - ) - return - } - - if (file.originalSize > MAX_UMAMI_CSV_BYTES) { fail( new ImportError( `${WEBSITE_EVENT_CSV} exceeds the ${MAX_UMAMI_CSV_BYTES} byte limit.`, diff --git a/backend/apps/community/src/data-import/mappers/umami.mapper.ts b/backend/apps/community/src/data-import/mappers/umami.mapper.ts index a1158230b..aa152e8aa 100644 --- a/backend/apps/community/src/data-import/mappers/umami.mapper.ts +++ b/backend/apps/community/src/data-import/mappers/umami.mapper.ts @@ -203,18 +203,10 @@ export class UmamiMapper implements ImportMapper { foundEntry = true if ( - typeof file.originalSize !== 'number' || - !Number.isFinite(file.originalSize) + typeof file.originalSize === 'number' && + Number.isFinite(file.originalSize) && + file.originalSize > MAX_UMAMI_CSV_BYTES ) { - fail( - new Error( - `Unable to validate ${WEBSITE_EVENT_CSV} size in the ZIP archive.`, - ), - ) - return - } - - if (file.originalSize > MAX_UMAMI_CSV_BYTES) { fail( new Error( `${WEBSITE_EVENT_CSV} exceeds the ${MAX_UMAMI_CSV_BYTES} byte limit.`, From 20375871514f4ffed5373b4827df76fc08de8d97 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 19:26:06 +0100 Subject: [PATCH 28/29] Don't block UI --- .../Settings/components/DataImportTab.tsx | 10 +++-- .../Project/tabs/Traffic/TrafficView.tsx | 42 ++++++++++++------- web/public/locales/en.json | 1 + 3 files changed, 34 insertions(+), 19 deletions(-) diff --git a/web/app/pages/Project/Settings/components/DataImportTab.tsx b/web/app/pages/Project/Settings/components/DataImportTab.tsx index 7b868a3ae..8367ebb02 100644 --- a/web/app/pages/Project/Settings/components/DataImportTab.tsx +++ b/web/app/pages/Project/Settings/components/DataImportTab.tsx @@ -113,10 +113,15 @@ export default function DataImportTab({ projectId }: DataImportTabProps) { if (listFetcher.state !== 'idle' || !listFetcher.data) return if (listFetcher.data.intent !== 'list-data-imports') return - if (listFetcher.data.dataImports) { + if (listFetcher.data.error) { + toast.error( + listFetcher.data.error || t('project.settings.dataImport.loadFailed'), + ) + } else if (listFetcher.data.dataImports) { setImports(listFetcher.data.dataImports) } setLoading(false) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [listFetcher.state, listFetcher.data]) useEffect(() => { @@ -343,7 +348,7 @@ export default function DataImportTab({ projectId }: DataImportTabProps) { {imp.provider} - + {imp.errorMessage} diff --git a/web/app/pages/Project/tabs/Traffic/TrafficView.tsx b/web/app/pages/Project/tabs/Traffic/TrafficView.tsx index c303b529b..cfab31886 100644 --- a/web/app/pages/Project/tabs/Traffic/TrafficView.tsx +++ b/web/app/pages/Project/tabs/Traffic/TrafficView.tsx @@ -141,7 +141,6 @@ interface DeferredTrafficData { trafficCompareData: ServerTrafficLogResponse | null overallCompareStats: Record | null customEventsData: { chart?: { events?: Record } } | null - hasImportedData?: boolean } interface TrafficErrorBoundaryState { @@ -199,7 +198,6 @@ function TrafficDataResolver({ trafficCompareData: trafficComparePromise, overallCompareStats: overallComparePromise, customEventsData: customEventsPromise, - hasImportedData: hasImportedDataPromise, } = useLoaderData() const trafficData = trafficDataPromise ? use(trafficDataPromise) : null @@ -211,9 +209,6 @@ function TrafficDataResolver({ ? use(overallComparePromise) : null const customEventsData = customEventsPromise ? use(customEventsPromise) : null - const hasImportedData = hasImportedDataPromise - ? use(hasImportedDataPromise) - : false return ( <> @@ -223,12 +218,34 @@ function TrafficDataResolver({ trafficCompareData, overallCompareStats, customEventsData, - hasImportedData, })} ) } +function HasImportedIndicator() { + const { t } = useTranslation('common') + const { hasImportedData: hasImportedDataPromise } = + useLoaderData() + + const hasImportedData = hasImportedDataPromise + ? use(hasImportedDataPromise) + : false + + if (!hasImportedData) return null + + return ( + + + + } + /> + ) +} + function TrafficViewWrapper(props: TrafficViewProps) { return ( @@ -1062,16 +1079,9 @@ const TrafficViewInner = ({ onSwitch={setChartTypeOnClick} type={chartType} /> - {deferredData.hasImportedData ? ( - - - - } - /> - ) : null} + + +
{!_isEmpty(overall) ? (
diff --git a/web/public/locales/en.json b/web/public/locales/en.json index 6d414cea4..af8e90e00 100644 --- a/web/public/locales/en.json +++ b/web/public/locales/en.json @@ -1750,6 +1750,7 @@ "deleteImportConfirm": "This will permanently remove all imported data ({{count}} rows) from your analytics. This action cannot be undone.", "importStarted": "Import started. Your data is being processed.", "importDeleted": "Import deleted successfully", + "loadFailed": "Failed to load imports", "uploadFailed": "Failed to upload file. Please try again.", "deleteFailed": "Failed to delete import", "clickToUpload": "Click to upload", From 4b8a43beb338f2d41cbbe9e077c44a7dba50971f Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 1 Apr 2026 19:32:42 +0100 Subject: [PATCH 29/29] fix: Check for import status before processing --- .../src/data-import/data-import.processor.ts | 20 +++++++++++++++++++ .../src/data-import/data-import.service.ts | 4 ++++ .../src/data-import/data-import.processor.ts | 20 +++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/backend/apps/cloud/src/data-import/data-import.processor.ts b/backend/apps/cloud/src/data-import/data-import.processor.ts index d64b66a14..bb68b50fb 100644 --- a/backend/apps/cloud/src/data-import/data-import.processor.ts +++ b/backend/apps/cloud/src/data-import/data-import.processor.ts @@ -6,6 +6,7 @@ import { Logger } from '@nestjs/common' import { Job } from 'bullmq' import { DataImportService } from './data-import.service' +import { DataImportStatus } from './entity/data-import.entity' import { getMapper } from './mappers' import { ImportError } from './mappers/mapper.interface' import { clickhouse } from '../common/integrations/clickhouse' @@ -41,6 +42,25 @@ export class DataImportProcessor extends WorkerHost { `Processing import ${id} (importId=${importId}) for project ${projectId} (provider: ${provider})`, ) + const dataImport = await this.dataImportService.findById(id) + + if (!dataImport) { + this.logger.warn(`Import record ${id} not found, skipping`) + return + } + + if (dataImport.status === DataImportStatus.COMPLETED) { + this.logger.log(`Import ${id} already completed, skipping reprocessing`) + return + } + + if (dataImport.status === DataImportStatus.PROCESSING) { + this.logger.warn( + `Import ${id} has status PROCESSING (stalled job replay), cleaning up partial data before reprocessing`, + ) + await this.dataImportService.cleanupImportedRows(projectId, importId) + } + await this.dataImportService.markProcessing(id) if (!filePath) { diff --git a/backend/apps/cloud/src/data-import/data-import.service.ts b/backend/apps/cloud/src/data-import/data-import.service.ts index c5c60649f..63f60b59e 100644 --- a/backend/apps/cloud/src/data-import/data-import.service.ts +++ b/backend/apps/cloud/src/data-import/data-import.service.ts @@ -105,6 +105,10 @@ export class DataImportService { }) } + async findById(id: number): Promise { + return this.dataImportRepository.findOneBy({ id }) + } + async findOne(projectId: string, importId: number): Promise { const dataImport = await this.dataImportRepository.findOne({ where: { importId, projectId }, diff --git a/backend/apps/community/src/data-import/data-import.processor.ts b/backend/apps/community/src/data-import/data-import.processor.ts index 0ede6681f..0d0646d1b 100644 --- a/backend/apps/community/src/data-import/data-import.processor.ts +++ b/backend/apps/community/src/data-import/data-import.processor.ts @@ -6,6 +6,7 @@ import { Logger } from '@nestjs/common' import { Job } from 'bullmq' import { DataImportService } from './data-import.service' +import { DataImportStatus } from './entity/data-import.entity' import { getMapper } from './mappers' import { clickhouse } from '../common/integrations/clickhouse' @@ -39,6 +40,25 @@ export class DataImportProcessor extends WorkerHost { `Processing import ${importId} for project ${projectId} (provider: ${provider})`, ) + const dataImport = await this.dataImportService.findOne( + projectId, + importId, + ) + + if (dataImport.status === DataImportStatus.COMPLETED) { + this.logger.log( + `Import ${importId} already completed, skipping reprocessing`, + ) + return + } + + if (dataImport.status === DataImportStatus.PROCESSING) { + this.logger.warn( + `Import ${importId} has status PROCESSING (stalled job replay), cleaning up partial data before reprocessing`, + ) + await this.dataImportService.cleanupImportedRows(projectId, importId) + } + await this.dataImportService.markProcessing(importId, projectId) if (!filePath) {