diff --git a/packages/backup-format/package.json b/packages/backup-format/package.json index d66be8ee5bfe..3620d67d6237 100644 --- a/packages/backup-format/package.json +++ b/packages/backup-format/package.json @@ -10,6 +10,7 @@ "types": "./dist/index.d.ts", "type": "module", "dependencies": { + "@masknet/shared-base": "workspace:*", "@msgpack/msgpack": "^2.7.2" } } diff --git a/packages/backup-format/src/index.ts b/packages/backup-format/src/index.ts index a5d21d90d333..65876c2f581d 100644 --- a/packages/backup-format/src/index.ts +++ b/packages/backup-format/src/index.ts @@ -1,2 +1,4 @@ -export * from './v3-EncryptedJSON' -export * from './BackupErrors' +export { encryptBackup, decryptBackup } from './version-3' +export { BackupErrors } from './BackupErrors' +export { normalizeBackup, type NormalizedBackup, createEmptyNormalizedBackup, generateBackupRAW } from './normalize' +export { getBackupPreviewInfo, type BackupPreview } from './utils/backupPreview' diff --git a/packages/backup-format/src/normalize/index.ts b/packages/backup-format/src/normalize/index.ts new file mode 100644 index 000000000000..e8c594a61f1f --- /dev/null +++ b/packages/backup-format/src/normalize/index.ts @@ -0,0 +1,44 @@ +import { ECKeyIdentifier, IdentifierMap, PostIVIdentifier, ProfileIdentifier } from '@masknet/shared-base' +import { None } from 'ts-results' +import { BackupErrors } from '../BackupErrors' +import { isBackupVersion0, normalizeBackupVersion0 } from '../version-0' +import { isBackupVersion1, normalizeBackupVersion1 } from '../version-1' +import { generateBackupVersion2, isBackupVersion2, normalizeBackupVersion2 } from '../version-2' +import type { NormalizedBackup } from './type' + +export * from './type' +function __normalizeBackup(data: unknown): NormalizedBackup.Data { + if (isBackupVersion2(data)) return normalizeBackupVersion2(data) + if (isBackupVersion1(data)) return normalizeBackupVersion1(data) + if (isBackupVersion0(data)) return normalizeBackupVersion0(data) + throw new TypeError(BackupErrors.UnknownFormat) +} + +export function normalizeBackup(data: unknown): NormalizedBackup.Data { + const normalized = __normalizeBackup(data) + + // fix invalid URL + normalized.settings.grantedHostPermissions = normalized.settings.grantedHostPermissions.filter((url) => + /^(http|)/.test(url), + ) + return normalized +} + +/** It will return the internal format. DO NOT rely on the detail of it! */ +export function generateBackupRAW(data: NormalizedBackup.Data): unknown { + const result = generateBackupVersion2(data) + return result +} + +export function createEmptyNormalizedBackup(): NormalizedBackup.Data { + return { + meta: { version: 2, createdAt: None, maskVersion: None }, + personas: new IdentifierMap(new Map(), ECKeyIdentifier), + profiles: new IdentifierMap(new Map(), ProfileIdentifier), + posts: new IdentifierMap(new Map(), PostIVIdentifier), + relations: [], + settings: { grantedHostPermissions: [] }, + wallets: [], + plugins: {}, + } +} diff --git a/packages/backup-format/src/normalize/type.ts b/packages/backup-format/src/normalize/type.ts new file mode 100644 index 000000000000..c8aae5ea39a6 --- /dev/null +++ b/packages/backup-format/src/normalize/type.ts @@ -0,0 +1,99 @@ +import type { + PersonaIdentifier, + EC_Private_JsonWebKey, + EC_Public_JsonWebKey, + AESJsonWebKey, + ProfileIdentifier, + RelationFavor, + PostIVIdentifier, + IdentifierMap, +} from '@masknet/shared-base' +import type { Option } from 'ts-results' + +// All optional type in this file is marked by Option because we don't want to miss any field. +export namespace NormalizedBackup { + export interface Data { + /** Meta about this backup */ + meta: Meta + personas: IdentifierMap + profiles: IdentifierMap + relations: RelationBackup[] + posts: IdentifierMap + wallets: WalletBackup[] + settings: SettingsBackup + plugins: Record + } + export interface Meta { + /** Backup file version */ + version: 0 | 1 | 2 + /** Backup created by which Mask version */ + maskVersion: Option + createdAt: Option + } + export interface PersonaBackup { + identifier: PersonaIdentifier + mnemonic: Option + publicKey: EC_Public_JsonWebKey + privateKey: Option + localKey: Option + linkedProfiles: IdentifierMap + nickname: Option + createdAt: Option + updatedAt: Option + } + export interface Mnemonic { + words: string + path: string + hasPassword: boolean + } + export interface ProfileBackup { + identifier: ProfileIdentifier + nickname: Option + localKey: Option + linkedPersona: Option + createdAt: Option + updatedAt: Option + } + export interface RelationBackup { + profile: ProfileIdentifier + persona: PersonaIdentifier + favor: RelationFavor + } + export interface PostBackup { + identifier: PostIVIdentifier + postBy: ProfileIdentifier + postCryptoKey: Option + recipients: Option + foundAt: Date + encryptBy: Option + url: Option + summary: Option + interestedMeta: ReadonlyMap + } + export interface PostReceiverPublic { + type: 'public' + } + export interface PostReceiverE2E { + type: 'e2e' + receivers: IdentifierMap + } + export interface RecipientReason { + type: 'auto-share' | 'direct' | 'group' + // We don't care about this field anymore. Do not wrap it with Option + group?: unknown + at: Date + } + export interface WalletBackup { + address: string + name: string + passphrase: Option + publicKey: Option + privateKey: Option + mnemonic: Option + createdAt: Date + updatedAt: Date + } + export interface SettingsBackup { + grantedHostPermissions: string[] + } +} diff --git a/packages/backup-format/src/utils/backupPreview.ts b/packages/backup-format/src/utils/backupPreview.ts new file mode 100644 index 000000000000..eb1be97c3095 --- /dev/null +++ b/packages/backup-format/src/utils/backupPreview.ts @@ -0,0 +1,31 @@ +import type { NormalizedBackup } from '@masknet/backup-format' + +export interface BackupPreview { + personas: number + accounts: number + posts: number + contacts: number + relations: number + files: number + wallets: number + createdAt: number +} + +export function getBackupPreviewInfo(json: NormalizedBackup.Data): BackupPreview { + let files = 0 + + try { + files = Number((json.plugins?.['com.maskbook.fileservice'] as any)?.length || 0) + } catch {} + + return { + personas: json.personas.size, + accounts: [...json.personas.values()].reduce((a, b) => a + b.linkedProfiles.size, 0), + posts: json.posts.size, + contacts: json.profiles.size, + relations: json.relations.length, + files, + wallets: json.wallets.length, + createdAt: Number(json.meta.createdAt), + } +} diff --git a/packages/backup-format/src/version-0/index.ts b/packages/backup-format/src/version-0/index.ts new file mode 100644 index 000000000000..d176ff110e08 --- /dev/null +++ b/packages/backup-format/src/version-0/index.ts @@ -0,0 +1,75 @@ +import { + AESJsonWebKey, + ECKeyIdentifierFromJsonWebKey, + EC_Private_JsonWebKey, + EC_Public_JsonWebKey, + IdentifierMap, + isAESJsonWebKey, + isEC_Private_JsonWebKey, + isEC_Public_JsonWebKey, + ProfileIdentifier, +} from '@masknet/shared-base' +import { isObjectLike } from 'lodash-unified' +import { None, Some } from 'ts-results' +import { createEmptyNormalizedBackup } from '../normalize' +import type { NormalizedBackup } from '../normalize/type' + +export function isBackupVersion0(obj: unknown): obj is BackupJSONFileVersion0 { + if (!isObjectLike(obj)) return false + try { + const data: BackupJSONFileVersion0 = obj as any + if (!data.local || !data.key || !data.key.key || !data.key.key.privateKey || !data.key.key.publicKey) + return false + return true + } catch { + return false + } +} +export function normalizeBackupVersion0(file: BackupJSONFileVersion0): NormalizedBackup.Data { + const backup = createEmptyNormalizedBackup() + backup.meta.version = 0 + backup.meta.maskVersion = Some('<=1.3.2') + + const { local } = file + const { username, key } = file.key + const { publicKey, privateKey } = key + + if (!isEC_Public_JsonWebKey(publicKey)) return backup + + const persona: NormalizedBackup.PersonaBackup = { + identifier: ECKeyIdentifierFromJsonWebKey(publicKey), + publicKey, + linkedProfiles: new IdentifierMap(new Map(), ProfileIdentifier), + localKey: isAESJsonWebKey(local) ? Some(local) : None, + privateKey: isEC_Private_JsonWebKey(privateKey) ? Some(privateKey) : None, + mnemonic: None, + nickname: None, + createdAt: None, + updatedAt: None, + } + backup.personas.set(persona.identifier, persona) + + if (username && username !== '$unknown' && username !== '$local') { + const profile: NormalizedBackup.ProfileBackup = { + identifier: new ProfileIdentifier('facebook.com', username), + linkedPersona: Some(persona.identifier), + createdAt: None, + updatedAt: None, + localKey: isAESJsonWebKey(local) ? Some(local) : None, + nickname: None, + } + backup.profiles.set(profile.identifier, profile) + persona.linkedProfiles.set(profile.identifier, void 0) + } + + return backup +} +interface BackupJSONFileVersion0 { + key: { + username: string + key: { publicKey: EC_Public_JsonWebKey; privateKey?: EC_Private_JsonWebKey } + algor: unknown + usages: string[] + } + local: AESJsonWebKey +} diff --git a/packages/backup-format/src/version-1/index.ts b/packages/backup-format/src/version-1/index.ts new file mode 100644 index 000000000000..d4029cd046b4 --- /dev/null +++ b/packages/backup-format/src/version-1/index.ts @@ -0,0 +1,108 @@ +import { + AESJsonWebKey, + ECKeyIdentifierFromJsonWebKey, + EC_Private_JsonWebKey, + EC_Public_JsonWebKey, + isEC_Private_JsonWebKey, + isEC_Public_JsonWebKey, + isAESJsonWebKey, + ProfileIdentifier, + IdentifierMap, +} from '@masknet/shared-base' +import { isObjectLike } from 'lodash-unified' +import { None, Some } from 'ts-results' +import { createEmptyNormalizedBackup } from '../normalize' +import type { NormalizedBackup } from '../normalize/type' + +export function isBackupVersion1(obj: unknown): obj is BackupJSONFileVersion1 { + if (!isObjectLike(obj)) return false + try { + const data: BackupJSONFileVersion1 = obj as any + if (data.version !== 1) return false + if (!Array.isArray(data.whoami)) return false + if (!data.whoami) return false + return true + } catch { + return false + } +} +export function normalizeBackupVersion1(file: BackupJSONFileVersion1): NormalizedBackup.Data { + const backup = createEmptyNormalizedBackup() + + backup.meta.version = 1 + if (!file.grantedHostPermissions) backup.meta.maskVersion = Some('<=1.5.2') + else if (!file.maskbookVersion) backup.meta.maskVersion = Some('<=1.6.0') + + if (file.grantedHostPermissions) { + backup.settings.grantedHostPermissions = file.grantedHostPermissions + } + + const { whoami, people } = file + for (const { network, publicKey, userId, nickname, localKey, privateKey } of [...whoami, ...(people || [])]) { + const profile: NormalizedBackup.ProfileBackup = { + identifier: new ProfileIdentifier(network, userId), + nickname: nickname ? Some(nickname) : None, + createdAt: None, + updatedAt: None, + localKey: None, + linkedPersona: None, + } + + if (isEC_Public_JsonWebKey(publicKey)) { + const personaID = ECKeyIdentifierFromJsonWebKey(publicKey) + const persona: NormalizedBackup.PersonaBackup = backup.personas.get(personaID) || { + identifier: personaID, + nickname: None, + linkedProfiles: new IdentifierMap(new Map(), ProfileIdentifier), + publicKey, + privateKey: None, + localKey: None, + mnemonic: None, + createdAt: None, + updatedAt: None, + } + profile.linkedPersona = Some(personaID) + + if (isEC_Private_JsonWebKey(privateKey)) { + persona.privateKey = Some(privateKey) + } + backup.personas.set(personaID, persona) + persona.linkedProfiles.set(profile.identifier, void 0) + } + if (isAESJsonWebKey(localKey)) { + profile.localKey = Some(localKey) + if (profile.linkedPersona.some && backup.personas.has(profile.linkedPersona.val)) { + backup.personas.get(profile.linkedPersona.val)!.localKey = Some(localKey) + } + } + } + + return backup +} + +interface BackupJSONFileVersion1 { + maskbookVersion?: string + version: 1 + whoami: Array<{ + network: string + userId: string + publicKey: EC_Public_JsonWebKey + privateKey: EC_Private_JsonWebKey + localKey: AESJsonWebKey + previousIdentifiers?: { network: string; userId: string }[] + nickname?: string + }> + people?: Array<{ + network: string + userId: string + publicKey: EC_Public_JsonWebKey + previousIdentifiers?: { network: string; userId: string }[] + nickname?: string + groups?: { network: string; groupID: string; virtualGroupOwner: string | null }[] + + // Note: those props are not existed in the backup, just to make the code more readable + privateKey?: EC_Private_JsonWebKey + localKey?: AESJsonWebKey + }> + grantedHostPermissions?: string[] +} diff --git a/packages/backup-format/src/version-2/index.ts b/packages/backup-format/src/version-2/index.ts new file mode 100644 index 000000000000..46aa0b0ecb93 --- /dev/null +++ b/packages/backup-format/src/version-2/index.ts @@ -0,0 +1,377 @@ +import { decodeArrayBuffer, encodeArrayBuffer, safeUnreachable } from '@dimensiondev/kit' +import { + ECKeyIdentifier, + ECKeyIdentifierFromJsonWebKey, + IdentifierMap, + isAESJsonWebKey, + isEC_Private_JsonWebKey, + isEC_Public_JsonWebKey, + PostIVIdentifier, + ProfileIdentifier, + RelationFavor, +} from '@masknet/shared-base' +import { decode, encode } from '@msgpack/msgpack' +import { Err, None, Some } from 'ts-results' +import { createEmptyNormalizedBackup } from '../normalize' +import type { NormalizedBackup } from '../normalize/type' + +export function isBackupVersion2(item: unknown): item is BackupJSONFileVersion2 { + try { + const x = item as BackupJSONFileVersion2 + return x._meta_.version === 2 + } catch {} + return false +} + +export function normalizeBackupVersion2(item: BackupJSONFileVersion2): NormalizedBackup.Data { + const backup = createEmptyNormalizedBackup() + + backup.meta.version = 2 + backup.meta.maskVersion = Some(item._meta_.maskbookVersion) + backup.meta.createdAt = Some(new Date(item._meta_.createdAt)) + backup.settings.grantedHostPermissions = item.grantedHostPermissions + + const { personas, posts, profiles, relations, wallets, plugin } = item + + for (const persona of personas) { + const { publicKey } = persona + if (!isEC_Public_JsonWebKey(publicKey)) continue + const identifier = ECKeyIdentifierFromJsonWebKey(publicKey) + const normalizedPersona: NormalizedBackup.PersonaBackup = { + identifier, + linkedProfiles: new IdentifierMap(new Map(), ProfileIdentifier), + publicKey, + privateKey: isEC_Private_JsonWebKey(persona.privateKey) ? Some(persona.privateKey) : None, + localKey: isAESJsonWebKey(persona.localKey) ? Some(persona.localKey) : None, + createdAt: Some(new Date(persona.createdAt)), + updatedAt: Some(new Date(persona.updatedAt)), + nickname: persona.nickname ? Some(persona.nickname) : None, + mnemonic: None, + } + for (const [profile] of persona.linkedProfiles) { + const id = ProfileIdentifier.fromString(profile, ProfileIdentifier) + if (id.err) continue + normalizedPersona.linkedProfiles.set(id.val, null) + } + if (persona.mnemonic) { + const { words, parameter } = persona.mnemonic + normalizedPersona.mnemonic = Some({ words, hasPassword: parameter.withPassword, path: parameter.path }) + } + + backup.personas.set(identifier, normalizedPersona) + } + + for (const profile of profiles) { + const identifier = ProfileIdentifier.fromString(profile.identifier, ProfileIdentifier) + if (identifier.err) continue + const normalizedProfile: NormalizedBackup.ProfileBackup = { + identifier: identifier.val, + createdAt: Some(new Date(profile.createdAt)), + updatedAt: Some(new Date(profile.updatedAt)), + nickname: profile.nickname ? Some(profile.nickname) : None, + linkedPersona: profile.linkedPersona + ? ECKeyIdentifier.fromString(profile.linkedPersona, ECKeyIdentifier).toOption() + : None, + localKey: isAESJsonWebKey(profile.localKey) ? Some(profile.localKey) : None, + } + backup.profiles.set(identifier.val, normalizedProfile) + } + + for (const persona of backup.personas.values()) { + const toRemove: ProfileIdentifier[] = [] + for (const profile of persona.linkedProfiles.keys()) { + if (backup.profiles.get(profile)?.linkedPersona?.unwrapOr(undefined)?.equals(persona.identifier)) { + // do nothing + } else toRemove.push(profile) + } + for (const profile of toRemove) persona.linkedProfiles.delete(profile) + } + + for (const post of posts) { + const identifier = PostIVIdentifier.fromString(post.identifier, PostIVIdentifier) + const postBy = ProfileIdentifier.fromString(post.postBy, ProfileIdentifier) + const encryptBy = post.encryptBy ? ECKeyIdentifier.fromString(post.encryptBy, ECKeyIdentifier) : Err.EMPTY + + if (identifier.err) continue + const interestedMeta = new Map() + const normalizedPost: NormalizedBackup.PostBackup = { + identifier: identifier.val, + foundAt: new Date(post.foundAt), + postBy: postBy.unwrapOr(ProfileIdentifier.unknown), + interestedMeta, + encryptBy: encryptBy.toOption(), + summary: post.summary ? Some(post.summary) : None, + url: post.url ? Some(post.url) : None, + postCryptoKey: isAESJsonWebKey(post.postCryptoKey) ? Some(post.postCryptoKey) : None, + recipients: None, + } + + if (post.recipients) { + if (post.recipients === 'everyone') + normalizedPost.recipients = Some({ type: 'public' }) + else { + const map = new IdentifierMap( + new Map(), + ProfileIdentifier, + ) + for (const [recipient, { reason }] of post.recipients) { + const id = ProfileIdentifier.fromString(recipient, ProfileIdentifier) + if (id.err) continue + const reasons: NormalizedBackup.RecipientReason[] = [] + map.set(id.val, reasons) + for (const r of reason) { + // we ignore the original reason because we no longer support group / auto sharing + reasons.push({ type: 'direct', at: new Date(r.at) }) + } + } + normalizedPost.recipients = Some({ type: 'e2e', receivers: map }) + } + } + if (post.interestedMeta) normalizedPost.interestedMeta = MetaFromJson(post.interestedMeta) + + backup.posts.set(identifier.val, normalizedPost) + } + + for (const relation of relations || []) { + const { profile, persona, favor } = relation + const a = ProfileIdentifier.fromString(profile, ProfileIdentifier) + const b = ECKeyIdentifier.fromString(persona, ECKeyIdentifier) + if (a.ok && b.ok) { + backup.relations.push({ + profile: a.val, + persona: b.val, + favor: favor, + }) + } + } + + for (const wallet of wallets || []) { + const normalizedWallet: NormalizedBackup.WalletBackup = { + address: wallet.address, + name: wallet.name, + passphrase: wallet.passphrase ? Some(wallet.passphrase) : None, + publicKey: isEC_Public_JsonWebKey(wallet.publicKey) ? Some(wallet.publicKey) : None, + privateKey: isEC_Private_JsonWebKey(wallet.privateKey) ? Some(wallet.privateKey) : None, + mnemonic: wallet.mnemonic + ? Some({ + words: wallet.mnemonic.words, + hasPassword: wallet.mnemonic.parameter.withPassword, + path: wallet.mnemonic.parameter.path, + }) + : None, + createdAt: new Date(wallet.createdAt), + updatedAt: new Date(wallet.updatedAt), + } + backup.wallets.push(normalizedWallet) + } + + backup.plugins = plugin || {} + + return backup +} + +export function generateBackupVersion2(item: NormalizedBackup.Data): BackupJSONFileVersion2 { + const result: BackupJSONFileVersion2 = { + _meta_: { + maskbookVersion: item.meta.maskVersion.unwrapOr('>=2.5.0'), + createdAt: Number(item.meta.createdAt), + type: 'maskbook-backup', + version: 2, + }, + grantedHostPermissions: item.settings.grantedHostPermissions, + plugin: item.plugins, + personas: [], + posts: [], + profiles: [], + relations: [], + wallets: [], + userGroups: [], + } + for (const [id, data] of item.personas) { + result.personas.push({ + identifier: id.toText(), + createdAt: Number(data.createdAt), + updatedAt: Number(data.updatedAt), + nickname: data.nickname.unwrapOr(undefined), + linkedProfiles: [...data.linkedProfiles.keys()].map((id) => [ + id.toText(), + { connectionConfirmState: 'confirmed' } as LinkedProfileDetails, + ]), + publicKey: data.publicKey, + privateKey: data.privateKey.unwrapOr(undefined), + mnemonic: data.mnemonic + .map((data) => ({ + words: data.words, + parameter: { path: data.path, withPassword: data.hasPassword }, + })) + .unwrapOr(undefined), + localKey: data.localKey.unwrapOr(undefined), + }) + } + + for (const [id, data] of item.profiles) { + result.profiles.push({ + identifier: id.toText(), + createdAt: Number(data.createdAt), + updatedAt: Number(data.updatedAt), + nickname: data.nickname.unwrapOr(undefined), + linkedPersona: data.linkedPersona.unwrapOr(undefined)?.toText(), + localKey: data.localKey.unwrapOr(undefined), + }) + } + + for (const [id, data] of item.posts) { + const item: BackupJSONFileVersion2['posts'][0] = { + identifier: id.toText(), + foundAt: Number(data.foundAt), + postBy: data.postBy.toText(), + interestedMeta: MetaToJson(data.interestedMeta), + encryptBy: data.encryptBy.unwrapOr(undefined)?.toText(), + summary: data.summary.unwrapOr(undefined), + url: data.url.unwrapOr(undefined), + postCryptoKey: data.postCryptoKey.unwrapOr(undefined), + recipientGroups: [], + recipients: [], + } + result.posts.push(item) + if (data.recipients.some) { + if (data.recipients.val.type === 'public') item.recipients = 'everyone' + else if (data.recipients.val.type === 'e2e') { + item.recipients = [] + for (const [recipient, reasons] of data.recipients.val.receivers) { + if (!reasons.length) continue + item.recipients.push([ + recipient.toText(), + { + reason: [ + { + at: Number(reasons[0].at), + type: 'direct', + }, + ], + }, + ]) + } + } else safeUnreachable(data.recipients.val) + } + } + + for (const data of item.relations) { + result.relations!.push({ + profile: data.profile.toText(), + persona: data.persona.toText(), + favor: data.favor, + }) + } + + for (const data of item.wallets) { + result.wallets!.push({ + address: data.address, + name: data.name, + passphrase: data.passphrase.unwrapOr(undefined), + publicKey: data.publicKey.unwrapOr(undefined), + privateKey: data.privateKey.unwrapOr(undefined), + mnemonic: data.mnemonic + .map((data) => ({ + words: data.words, + parameter: { path: data.path, withPassword: data.hasPassword }, + })) + .unwrapOr(undefined), + createdAt: Number(data.createdAt), + updatedAt: Number(data.updatedAt), + }) + } + return result +} + +function MetaFromJson(meta: string | undefined): Map { + if (!meta) return new Map() + const raw = decode(decodeArrayBuffer(meta)) + if (typeof raw !== 'object' || !raw) return new Map() + return new Map(Object.entries(raw)) +} +function MetaToJson(meta: ReadonlyMap) { + return encodeArrayBuffer(encode(Object.fromEntries(meta.entries()))) +} + +/** + * @see https://github.com/DimensionDev/Maskbook/issues/194 + */ +interface BackupJSONFileVersion2 { + _meta_: { + version: 2 + type: 'maskbook-backup' + maskbookVersion: string // e.g. "1.8.0" + createdAt: number // Unix timestamp + } + personas: Array<{ + // ? PersonaIdentifier can be infer from the publicKey + identifier: string // PersonaIdentifier.toText() + mnemonic?: { + words: string + parameter: { path: string; withPassword: boolean } + } + publicKey: JsonWebKey + privateKey?: JsonWebKey + localKey?: JsonWebKey + nickname?: string + linkedProfiles: [/** ProfileIdentifier.toText() */ string, LinkedProfileDetails][] + createdAt: number // Unix timestamp + updatedAt: number // Unix timestamp + }> + profiles: Array<{ + identifier: string // ProfileIdentifier.toText() + nickname?: string + localKey?: JsonWebKey + linkedPersona?: string // PersonaIdentifier.toText() + createdAt: number // Unix timestamp + updatedAt: number // Unix timestamp + }> + relations?: Array<{ + profile: string // ProfileIdentifier.toText() + persona: string // PersonaIdentifier.toText() + favor: RelationFavor + }> + /** @deprecated */ + userGroups: never[] + posts: Array<{ + postBy: string // ProfileIdentifier.toText() + identifier: string // PostIVIdentifier.toText() + postCryptoKey?: JsonWebKey + recipients: 'everyone' | [/** ProfileIdentifier.toText() */ string, { reason: RecipientReasonJSON[] }][] + /** @deprecated */ + recipientGroups: never[] // Array + foundAt: number // Unix timestamp + encryptBy?: string // PersonaIdentifier.toText() + url?: string + summary?: string + interestedMeta?: string // encoded by MessagePack + }> + wallets?: Array<{ + address: string + name: string + passphrase?: string + publicKey?: JsonWebKey + privateKey?: JsonWebKey + mnemonic?: { + words: string + parameter: { path: string; withPassword: boolean } + } + createdAt: number // Unix timestamp + updatedAt: number // Unix timestamp + }> + grantedHostPermissions: string[] + plugin?: Record +} + +interface LinkedProfileDetails { + connectionConfirmState: 'confirmed' | 'pending' | 'denied' +} + +type RecipientReasonJSON = ( + | { type: 'auto-share' } + | { type: 'direct' } + | { type: 'group'; /** GroupIdentifier */ group: string } +) & { + at: number +} diff --git a/packages/backup-format/src/v3-EncryptedJSON/index.ts b/packages/backup-format/src/version-3/index.ts similarity index 100% rename from packages/backup-format/src/v3-EncryptedJSON/index.ts rename to packages/backup-format/src/version-3/index.ts diff --git a/packages/backup-format/tsconfig.json b/packages/backup-format/tsconfig.json index 625eeee364ee..ecb7b18837fa 100644 --- a/packages/backup-format/tsconfig.json +++ b/packages/backup-format/tsconfig.json @@ -6,5 +6,5 @@ "tsBuildInfoFile": "./dist/.tsbuildinfo" }, "include": ["./src", "./src/**/*.json"], - "references": [] + "references": [{ "path": "../shared-base" }] } diff --git a/packages/dashboard/src/components/Restore/RestoreFromCloud.tsx b/packages/dashboard/src/components/Restore/RestoreFromCloud.tsx index 9cf72f394dc2..3f0c0841c53b 100644 --- a/packages/dashboard/src/components/Restore/RestoreFromCloud.tsx +++ b/packages/dashboard/src/components/Restore/RestoreFromCloud.tsx @@ -13,14 +13,13 @@ import { useNavigate } from 'react-router-dom' import { DashboardRoutes } from '@masknet/shared-base' import { Step, Stepper } from '../Stepper' import { LoadingCard } from './steps/LoadingCard' -import { decryptBackup } from '@masknet/backup-format' +import { BackupPreview, decryptBackup } from '@masknet/backup-format' import { decode, encode } from '@msgpack/msgpack' import { PersonaContext } from '../../pages/Personas/hooks/usePersonaContext' import { AccountType } from '../../pages/Settings/type' import { UserContext } from '../../pages/Settings/hooks/UserContext' import { ConfirmSynchronizePasswordDialog } from './ConfirmSynchronizePasswordDialog' import { LoadingButton } from '../LoadingButton' -import type { BackupPreview } from '../../../../mask/src/utils' export const RestoreFromCloud = memo(() => { const t = useDashboardI18N() @@ -58,27 +57,27 @@ export const RestoreFromCloud = memo(() => { const onValidated = useCallback( async (downloadLink: string, accountValue: string, password: string, type: AccountType) => { - const backupValue = await fetchBackupValueFn(downloadLink) - const backupText = await decryptBackupFn(accountValue, password, backupValue) + const backupEncrypted = await fetchBackupValueFn(downloadLink) + const backupDecrypted = await decryptBackupFn(accountValue, password, backupEncrypted) - if (!backupText) { + if (!backupDecrypted) { return t.sign_in_account_cloud_backup_decrypt_failed() } - const backupInfo = await Services.Welcome.parseBackupStr(backupText) - if (backupInfo) { - setBackupId(backupInfo.id) - setAccount({ type, value: accountValue, password }) - setStep({ - name: 'restore', - params: { - backupJson: backupInfo.info, - handleRestore: () => onRestore(backupInfo, { type, value: accountValue, password }), - }, - }) - return null - } - return t.sign_in_account_cloud_backup_decrypt_failed() + const backupNormalized = await Services.Welcome.addUnconfirmedBackup(backupDecrypted) + if (backupNormalized.err) return t.sign_in_account_cloud_backup_decrypt_failed() + + const { id, info } = backupNormalized.val + setBackupId(id) + setAccount({ type, value: accountValue, password }) + setStep({ + name: 'restore', + params: { + backupJson: info, + handleRestore: () => onRestore(backupNormalized.val), + }, + }) + return null }, [], ) @@ -102,14 +101,13 @@ export const RestoreFromCloud = memo(() => { }, [currentPersona, account, user, toggleSynchronizePasswordDialog]) const onRestore = useCallback( - async (backupInfo: { info: BackupPreview; id: string }, account: any) => { + async (backupInfo: { info: BackupPreview; id: string }) => { try { if (backupInfo.info?.wallets) { - await Services.Welcome.checkPermissionAndOpenWalletRecovery(backupInfo.id) + await Services.Welcome.restoreUnconfirmedBackup({ id: backupInfo.id, action: 'wallet' }) return } else { - await Services.Welcome.checkPermissionsAndRestore(backupInfo.id) - + await Services.Welcome.restoreUnconfirmedBackup({ id: backupInfo.id, action: 'confirm' }) await restoreCallback() } } catch { diff --git a/packages/dashboard/src/components/Restore/RestoreFromLocal.tsx b/packages/dashboard/src/components/Restore/RestoreFromLocal.tsx index 939aa79a6a95..a468d014fa96 100644 --- a/packages/dashboard/src/components/Restore/RestoreFromLocal.tsx +++ b/packages/dashboard/src/components/Restore/RestoreFromLocal.tsx @@ -1,7 +1,7 @@ import { memo, useCallback, useEffect, useState } from 'react' import { useAsync } from 'react-use' import { Box, Card } from '@mui/material' -import type { BackupPreview } from '@masknet/public-api' +import type { BackupPreview } from '@masknet/backup-format' import { useDashboardI18N } from '../../locales' import { Messages, Services } from '../../API' import BackupPreviewCard from '../../pages/Settings/components/BackupPreviewCard' @@ -62,18 +62,12 @@ export const RestoreFromLocal = memo(() => { if (!backupValue) return setRestoreStatus(RestoreStatus.Verifying) - try { - const backupInfo = await Services.Welcome.parseBackupStr(backupValue) - - if (backupInfo) { - setJSON(backupInfo.info) - setBackupId(backupInfo.id) - setRestoreStatus(RestoreStatus.Verified) - } else { - setRestoreStatus(RestoreStatus.WaitingInput) - setBackupValue('') - } - } catch { + const backupInfo = await Services.Welcome.addUnconfirmedBackup(backupValue) + if (backupInfo.ok) { + setJSON(backupInfo.val.info) + setBackupId(backupInfo.val.id) + setRestoreStatus(RestoreStatus.Verified) + } else { showSnackbar(t.sign_in_account_cloud_backup_not_support(), { variant: 'error' }) setRestoreStatus(RestoreStatus.WaitingInput) setBackupValue('') @@ -105,10 +99,10 @@ export const RestoreFromLocal = memo(() => { try { // If json has wallets, restore in popup. if (json?.wallets) { - await Services.Welcome.checkPermissionAndOpenWalletRecovery(backupId) + await Services.Welcome.restoreUnconfirmedBackup({ id: backupId, action: 'wallet' }) return } else { - await Services.Welcome.checkPermissionsAndRestore(backupId) + await Services.Welcome.restoreUnconfirmedBackup({ id: backupId, action: 'confirm' }) await restoreCallback() } diff --git a/packages/dashboard/src/pages/Settings/components/BackupContentSelector.tsx b/packages/dashboard/src/pages/Settings/components/BackupContentSelector.tsx index f12d884dd25d..5934190f057b 100644 --- a/packages/dashboard/src/pages/Settings/components/BackupContentSelector.tsx +++ b/packages/dashboard/src/pages/Settings/components/BackupContentSelector.tsx @@ -1,8 +1,8 @@ import { Box, Checkbox, Typography, experimentalStyled as styled } from '@mui/material' import { useDashboardI18N } from '../../../locales' -import type { BackupPreview } from './BackupPreviewCard' import { MaskColorVar } from '@masknet/theme' import { useState, useEffect } from 'react' +import type { BackupPreview } from '@masknet/backup-format' const SelectItem = styled('div')(({ theme }) => ({ borderRadius: 8, diff --git a/packages/dashboard/src/pages/Settings/components/BackupPreviewCard.tsx b/packages/dashboard/src/pages/Settings/components/BackupPreviewCard.tsx index a3b48f7cc126..2e2688b2319b 100644 --- a/packages/dashboard/src/pages/Settings/components/BackupPreviewCard.tsx +++ b/packages/dashboard/src/pages/Settings/components/BackupPreviewCard.tsx @@ -3,16 +3,7 @@ import { Typography } from '@mui/material' import classNames from 'classnames' import { useDashboardI18N } from '../../../locales' import formatDateTime from 'date-fns/format' -export interface BackupPreview { - email?: string - personas: number - accounts: number - posts: number - contacts: number - files: number - wallets: number - createdAt?: number -} +import type { BackupPreview } from '@masknet/backup-format' const useStyles = makeStyles()(() => ({ root: { diff --git a/packages/dashboard/src/pages/Settings/components/dialogs/BackupDialog.tsx b/packages/dashboard/src/pages/Settings/components/dialogs/BackupDialog.tsx index 3e3def1a2eb8..22e8e47d2045 100644 --- a/packages/dashboard/src/pages/Settings/components/dialogs/BackupDialog.tsx +++ b/packages/dashboard/src/pages/Settings/components/dialogs/BackupDialog.tsx @@ -54,26 +54,20 @@ export default function BackupDialog({ local = true, params, open, merged, onClo } } - const fileJson = await Services.Welcome.createBackupFile({ - noPosts: !showPassword.base, - noPersonas: !showPassword.base, - noProfiles: !showPassword.base, - noWallets: !showPassword.wallet, - download: false, - onlyBackupWhoAmI: false, + const { file, personaNickNames } = await Services.Welcome.createBackupFile({ + excludeBase: !showPassword, + excludeWallet: !showPassword.wallet, }) + // TODO: move this to background if (local) { // local backup, no account - const encrypted = await encryptBackup(encode(backupPassword), encode(fileJson)) + const encrypted = await encryptBackup(encode(backupPassword), encode(file)) await Services.Welcome.downloadBackupV2(encrypted) } else if (params) { - const abstract = fileJson.personas - .filter((x) => x.nickname) - .map((x) => x.nickname) - .join(', ') + const abstract = personaNickNames.join(', ') const uploadUrl = await fetchUploadLink({ ...params, abstract }) - const encrypted = await encryptBackup(encode(params.account + backupPassword), encode(fileJson)) + const encrypted = await encryptBackup(encode(params.account + backupPassword), encode(file)) uploadBackupValue(uploadUrl, encrypted).then(() => { showSnackbar(t.settings_alert_backup_success(), { variant: 'success' }) diff --git a/packages/dashboard/src/pages/Settings/components/dialogs/CloudBackupMergeDialog.tsx b/packages/dashboard/src/pages/Settings/components/dialogs/CloudBackupMergeDialog.tsx index bcb1a3740721..bac8198a5c90 100644 --- a/packages/dashboard/src/pages/Settings/components/dialogs/CloudBackupMergeDialog.tsx +++ b/packages/dashboard/src/pages/Settings/components/dialogs/CloudBackupMergeDialog.tsx @@ -57,15 +57,12 @@ export function CloudBackupMergeDialog({ account, info, open, onClose, onMerged const encrypted = await fetchBackupValue(info.downloadURL) const decrypted = await decryptBackup(encode(account + backupPassword), encrypted) const backupText = JSON.stringify(decode(decrypted)) - const data = await Services.Welcome.parseBackupStr(backupText) + const data = (await Services.Welcome.addUnconfirmedBackup(backupText)).unwrap() - if (data?.info.wallets) { - await Services.Welcome.checkPermissionAndOpenWalletRecovery(data.id) - return + if (data.info.wallets) { + await Services.Welcome.restoreUnconfirmedBackup({ id: data.id, action: 'wallet' }) } else { - if (data?.id) { - await Services.Welcome.checkPermissionsAndRestore(data.id) - } + await Services.Welcome.restoreUnconfirmedBackup({ id: data.id, action: 'confirm' }) restoreCallback() setBackupPassword('') diff --git a/packages/dashboard/src/pages/Settings/components/dialogs/RestoreDialog.tsx b/packages/dashboard/src/pages/Settings/components/dialogs/RestoreDialog.tsx index 65665c181e97..9a3759ba1e80 100644 --- a/packages/dashboard/src/pages/Settings/components/dialogs/RestoreDialog.tsx +++ b/packages/dashboard/src/pages/Settings/components/dialogs/RestoreDialog.tsx @@ -7,7 +7,7 @@ import ConfirmDialog from '../../../../components/ConfirmDialog' import FileUpload from '../../../../components/FileUpload' import { Services } from '../../../../API' import BackupPreviewCard from '../BackupPreviewCard' -import type { BackupPreview } from '../BackupPreviewCard' +import type { BackupPreview } from '@masknet/backup-format' const useStyles = makeStyles()(() => ({ container: { flex: 1 }, @@ -70,8 +70,7 @@ export default function RestoreDialog({ open, onClose }: RestoreDialogProps) { const [text, setText] = useState('') // file content const [content, setContent] = useState('') - // parsed json - const [json, setJSON] = useState(null) + const [preview, setPreview] = useState(null) // backup id const [id, setId] = useState('') @@ -80,25 +79,25 @@ export default function RestoreDialog({ open, onClose }: RestoreDialogProps) { setTab('file') setText('') setContent('') - setJSON(null) + setPreview(null) } const handleConfirm = async () => { - if (!json) return + if (!preview) return - await Services.Welcome.checkPermissionsAndRestore(id) + await Services.Welcome.restoreUnconfirmedBackup({ id, action: 'confirm' }) } useAsync(async () => { const str = tab === 'file' ? content : text if (str) { - const obj = await Services.Welcome.parseBackupStr(str) - if (obj) { - setJSON(obj.info) - setId(obj.id) + const obj = await Services.Welcome.addUnconfirmedBackup(str) + if (obj.ok) { + setPreview(obj.val.info) + setId(obj.val.id) } } else { - setJSON(null) + setPreview(null) setId('') } }, [tab, text, content]) @@ -108,7 +107,7 @@ export default function RestoreDialog({ open, onClose }: RestoreDialogProps) { title="Restore Backups" confirmText="Restore" open={open} - confirmDisabled={!json} + confirmDisabled={!preview} onClose={handleClose} onConfirm={handleConfirm}>
@@ -118,13 +117,13 @@ export default function RestoreDialog({ open, onClose }: RestoreDialogProps) { -
+
setContent(content || '')} />
- {json && content ? : null} + {preview && content ? : null} -
+