Repository: wireapp/wire-webapp
Pull request #19047: feat(backup): allow cross platform backup export [WPB-17310]
Author: login: V-Gira
Labels: name: type: feature / request ✨
name: 👕 size: S
Created: 2025-04-25T11:58:21Z | Merged: 2025-04-25T12:35:39Z


<!--do not remove this marker, its needed to replace info when ticket title is updated -->
<!--jira-description-action-hidden-marker-start-->

<table>
<td>
  <a href="https://wearezeta.atlassian.net/browse/WPB-17310" title="WPB-17310" target="_blank"><img alt="Task" src="https://wearezeta.atlassian.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10818?size=medium" />WPB-17310</a>  [Web] Implement export of Cross Platform Backups
  </td></table>
  <br />
 

<!--jira-description-action-hidden-marker-end-->

## Description

We no longer need to compress or encrypt exported data, the `kalium-backup` library does this for us

<!-- Uncomment this section if your PR has UI changes -->
<!--
## Screenshots/Screencast (for UI changes)
-->

## Checklist

- [x] mentions the JIRA issue in the PR name (Ex. [WPB-XXXX])
- [x] PR has been self reviewed by the author;
- [ ] Hard-to-understand areas of the code have been commented;
- [ ] If it is a core feature, unit tests have been added;

<!-- Uncomment this section if it is necessary to understand the PR -->
<!-- ## Important Details for the Reviewers

- use (x) data
- can be reviewed commit-by-commit
- be sure to look at ... -->



Diff:
diff --git a/src/script/backup/BackupRepository.ts b/src/script/backup/BackupRepository.ts
index 3a2c50dbe8f..cab493c580e 100644
--- a/src/script/backup/BackupRepository.ts
+++ b/src/script/backup/BackupRepository.ts
@@ -27,7 +27,7 @@ import {WebWorker} from 'Util/worker';
 import {ProgressCallback, Filename, FileDescriptor} from './Backup.types';
 import {BackUpHeader, ERROR_TYPES} from './BackUpHeader';
 import {BackupService} from './BackupService';
-import {exportCPBHistoryFromDatabase, importCPBHistoryToDatabase, CPBackup, isCPBackup} from './CrossPlatformBackup';
+import {exportCPBHistoryFromDatabase, importCPBHistoryToDatabase, isCPBackup} from './CrossPlatformBackup';
 import {
   CancelError,
   DifferentAccountError,
@@ -102,13 +102,16 @@ export class BackupRepository {
           progressCallback,
           user,
           backupService: this.backupService,
+          password,
           checkCancelStatus,
         });
-        // If the feature flag is disabled, export the history as a legacy backup
-      } else {
-        exportedData = await exportHistory(progressCallback, this.backupService, checkCancelStatus);
+        // Compression and encryption are handled by the kalium-backup library
+        return new Blob([exportedData], {type: 'application/zip'});
       }
 
+      // If the feature flag is disabled, export the history as a legacy backup
+      exportedData = await exportHistory(progressCallback, this.backupService, checkCancelStatus);
+
       if (exportedData === null) {
         throw new Error('Exported data is null');
       }
@@ -124,8 +127,8 @@ export class BackupRepository {
 
   private async compressHistoryFiles(
     user: User,
-    clientId: string, // TODO: Add clientId to metadata
-    exportedData: Int8Array | Record<string, any[]>,
+    clientId: string,
+    exportedData: Record<string, any[]>,
     password: string,
   ): Promise<Blob> {
     const files: Record<string, Uint8Array> = {};
@@ -134,24 +137,18 @@ export class BackupRepository {
       throw new CancelError();
     }
 
-    // If the exported data is an Int8Array, it is a cross-platform backup
-    if (exportedData instanceof Int8Array) {
-      files[CPBackup.ZIP_ENTRY_DATA] = new Uint8Array(exportedData.buffer, 0, exportedData.byteLength);
-      // If the exported data is an object, it is a legacy backup
-    } else {
-      const metaData = createMetaData(user, clientId, this.backupService);
+    const metaData = createMetaData(user, clientId, this.backupService);
 
-      const stringifiedMetadata = JSON.stringify(metaData, null, 2);
-      const encodedMetadata = new TextEncoder().encode(stringifiedMetadata);
+    const stringifiedMetadata = JSON.stringify(metaData, null, 2);
+    const encodedMetadata = new TextEncoder().encode(stringifiedMetadata);
 
-      for (const tableName in exportedData) {
-        const stringifiedData = JSON.stringify(exportedData[tableName]);
-        const encodedData = new TextEncoder().encode(stringifiedData);
-        const fileName = `${tableName}.json`;
-        files[fileName] = encodedData;
-      }
-      files[Filename.METADATA] = encodedMetadata;
+    for (const tableName in exportedData) {
+      const stringifiedData = JSON.stringify(exportedData[tableName]);
+      const encodedData = new TextEncoder().encode(stringifiedData);
+      const fileName = `${tableName}.json`;
+      files[fileName] = encodedData;
     }
+    files[Filename.METADATA] = encodedMetadata;
 
     if (password) {
       return this.createEncryptedBackup(files, user, password);
diff --git a/src/script/backup/CrossPlatformBackup/CPB.export.ts b/src/script/backup/CrossPlatformBackup/CPB.export.ts
index 3b12ad5aa91..5c1f78a8f48 100644
--- a/src/script/backup/CrossPlatformBackup/CPB.export.ts
+++ b/src/script/backup/CrossPlatformBackup/CPB.export.ts
@@ -28,6 +28,7 @@ import {
   BackupDateTime,
   BackupMessageContent,
   BackupMessage,
+  BackupExportResult,
 } from './CPB.library';
 import {ExportHistoryFromDatabaseParams} from './CPB.types';
 import {
@@ -37,11 +38,14 @@ import {
   UserTableEntrySchema,
 } from './data.schema';
 
-import {CancelError} from '../Error';
+import {CancelError, ExportError} from '../Error';
 import {preprocessConversations, preprocessUsers, preprocessEvents} from '../recordPreprocessors';
 
 import {CPBLogger, exportTable, isAssetAddEvent, isMessageAddEvent, isSupportedEventType} from '.';
 
+const PROGRESSION_STEP = 10;
+const PROGRESSION_COMPLETE = 100;
+
 // Helper function to transform an Int8Array to an object
 const transformObjectToArray = (array: {[key: number]: number}): Int8Array => {
   const result = new Int8Array(Object.keys(array).length);
@@ -58,8 +62,9 @@ export const exportCPBHistoryFromDatabase = async ({
   backupService,
   progressCallback,
   user,
+  password,
   checkCancelStatus,
-}: ExportHistoryFromDatabaseParams): Promise<Int8Array> => {
+}: ExportHistoryFromDatabaseParams): Promise<Uint8Array> => {
   const [conversationTable, eventsTable, usersTable] = backupService.getTables();
   const backupExporter = new CPBackupExporter(new BackupQualifiedId(user.id, user.domain));
 
@@ -86,12 +91,14 @@ export const exportCPBHistoryFromDatabase = async ({
     const {success, data, error} = ConversationTableEntrySchema.safeParse(record);
 
     if (success) {
-      backupExporter.addConversation(new BackUpConversation(new BackupQualifiedId(data.id, data.domain), data.name));
+      backupExporter.addConversation(
+        new BackUpConversation(new BackupQualifiedId(data.id, data.domain), data.name ?? ''),
+      );
     } else {
       CPBLogger.error('Conversation data schema validation failed', error);
     }
 
-    if (index % 10 === 0) {
+    if (index % PROGRESSION_STEP === 0) {
       checkIfCancelled();
     }
   });
@@ -110,7 +117,7 @@ export const exportCPBHistoryFromDatabase = async ({
       backupExporter.addUser(
         new BackupUser(
           new BackupQualifiedId(data?.qualified_id?.id ?? data.id, data?.qualified_id?.domain ?? ''),
-          data.name,
+          data.name ?? '',
           data.handle ?? '',
         ),
       );
@@ -118,7 +125,7 @@ export const exportCPBHistoryFromDatabase = async ({
       CPBLogger.error('User data schema validation failed', error);
     }
 
-    if (index % 10 === 0) {
+    if (index % PROGRESSION_STEP === 0) {
       checkIfCancelled();
     }
   });
@@ -202,12 +209,24 @@ export const exportCPBHistoryFromDatabase = async ({
       CPBLogger.error('Event data schema validation failed', error);
     }
 
-    if (index % 100 === 0) {
+    if (index % PROGRESSION_COMPLETE === 0) {
       checkIfCancelled();
     }
   });
 
   // ------------------------------
 
-  return backupExporter.serialize();
+  const result = await backupExporter.finalize(password);
+
+  if (result instanceof BackupExportResult.Success) {
+    return result.bytes;
+  }
+
+  if (result instanceof BackupExportResult.Failure) {
+    CPBLogger.error(`Backup export failed: ${result.message}`);
+    throw new ExportError(result.message);
+  }
+
+  CPBLogger.error(`Backup export failed.`);
+  throw new ExportError('Backup export failed');
 };
diff --git a/src/script/backup/CrossPlatformBackup/CPB.library.ts b/src/script/backup/CrossPlatformBackup/CPB.library.ts
index 4073ade4116..a970fa8b783 100644
--- a/src/script/backup/CrossPlatformBackup/CPB.library.ts
+++ b/src/script/backup/CrossPlatformBackup/CPB.library.ts
@@ -24,6 +24,7 @@ import {com} from '@wireapp/kalium-backup';
 import CPBackup = com.wire.backup.MPBackup;
 import CPBackupImporter = com.wire.backup.ingest.MPBackupImporter;
 import BackupImportResult = com.wire.backup.ingest.BackupImportResult;
+import BackupExportResult = com.wire.backup.dump.BackupExportResult;
 import CPBackupExporter = com.wire.backup.dump.MPBackupExporter;
 import BackupQualifiedId = com.wire.backup.data.BackupQualifiedId;
 import BackupMessage = com.wire.backup.data.BackupMessage;
@@ -38,6 +39,7 @@ import AssetMetaData = com.wire.backup.data.BackupMessageContent.Asset.AssetMeta
 export {
   CPBackup,
   CPBackupImporter,
+  BackupExportResult,
   BackupImportResult,
   CPBackupExporter,
   BackupQualifiedId,
diff --git a/src/script/backup/CrossPlatformBackup/CPB.types.ts b/src/script/backup/CrossPlatformBackup/CPB.types.ts
index 9b310f025d1..863566c4c42 100644
--- a/src/script/backup/CrossPlatformBackup/CPB.types.ts
+++ b/src/script/backup/CrossPlatformBackup/CPB.types.ts
@@ -26,6 +26,7 @@ export interface ExportHistoryFromDatabaseParams {
   backupService: BackupService;
   progressCallback: ProgressCallback;
   user: User;
+  password: string;
   checkCancelStatus: () => boolean;
 }
 
diff --git a/src/script/backup/CrossPlatformBackup/data.schema.ts b/src/script/backup/CrossPlatformBackup/data.schema.ts
index ba69afd0df6..7b9a3f33559 100644
--- a/src/script/backup/CrossPlatformBackup/data.schema.ts
+++ b/src/script/backup/CrossPlatformBackup/data.schema.ts
@@ -23,7 +23,7 @@ import zod from 'zod';
 export const ConversationTableEntrySchema = zod.object({
   domain: zod.string(),
   id: zod.string(),
-  name: zod.string(),
+  name: zod.string().nullable().optional(),
   // accessModes: zod.array(zod.string()).optional(),
   // accessRole: zod.array(zod.any()).optional(),
   // accessRoleV2: zod.string().optional(),
@@ -51,7 +51,7 @@ export type ConversationTableEntry = zod.infer<typeof ConversationTableEntrySche
 export const UserTableEntrySchema = zod.object({
   handle: zod.string().optional(),
   id: zod.string(),
-  name: zod.string(),
+  name: zod.string().optional(),
   qualified_id: zod
     .object({
       domain: zod.string(),


Reviews:
user: login: V-Gira
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T12:02:17Z
user: login: V-Gira
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T12:21:00Z
user: login: V-Gira
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T12:21:54Z
user: login: aweiss-dev
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T12:27:59Z
user: login: e-maad
body: LGTM 🚀 
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T13:42:15Z

Review comments:
diff_hunk: @@ -23,7 +23,7 @@ import zod from 'zod';
 export const ConversationTableEntrySchema = zod.object({
   domain: zod.string(),
   id: zod.string(),
-  name: zod.string(),
+  name: zod.string().nullable().optional(),
path: src/script/backup/CrossPlatformBackup/data.schema.ts
user: login: V-Gira
body: 1:1  conversations with team members have a `null` name
created_at: 2025-04-25T12:02:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -202,12 +209,24 @@ export const exportCPBHistoryFromDatabase = async ({
       CPBLogger.error('Event data schema validation failed', error);
     }
 
-    if (index % 100 === 0) {
+    if (index % PROGRESSION_COMPLETE === 0) {
       checkIfCancelled();
     }
   });
 
   // ------------------------------
 
-  return backupExporter.serialize();
+  const result = await backupExporter.finalize(password);
+
+  if (result instanceof BackupExportResult.Success) {
+    return result.bytes;
path: src/script/backup/CrossPlatformBackup/CPB.export.ts
user: login: V-Gira
body: this returns the already compressed / encrypted file
created_at: 2025-04-25T12:21:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -102,13 +102,16 @@ export class BackupRepository {
           progressCallback,
           user,
           backupService: this.backupService,
+          password,
           checkCancelStatus,
         });
-        // If the feature flag is disabled, export the history as a legacy backup
-      } else {
-        exportedData = await exportHistory(progressCallback, this.backupService, checkCancelStatus);
+        // Compression and encryption are handled by the kalium-backup library
+        return new Blob([exportedData], {type: 'application/zip'});
path: src/script/backup/BackupRepository.ts
user: login: V-Gira
body: no need to go through the compression / encrition step for CPB
created_at: 2025-04-25T12:21:54Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-04-25T12:20:35Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=wireapp_wire-webapp&pullRequest=19047) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=wireapp_wire-webapp&pullRequest=19047&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=wireapp_wire-webapp&pullRequest=19047&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=wireapp_wire-webapp&pullRequest=19047&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=wireapp_wire-webapp&pullRequest=19047&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=wireapp_wire-webapp&pullRequest=19047&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=wireapp_wire-webapp&pullRequest=19047)


author_association: NONE