diff --git a/packages/dashboard/src/pages/Personas/hooks/useContacts.ts b/packages/dashboard/src/pages/Personas/hooks/useContacts.ts index b9417ea382f4..39fb5b23b611 100644 --- a/packages/dashboard/src/pages/Personas/hooks/useContacts.ts +++ b/packages/dashboard/src/pages/Personas/hooks/useContacts.ts @@ -21,6 +21,7 @@ export const useContacts = (network: string, page: number, size = 20) => { { network, after: lastValue, + pageOffset: page * size, }, size, ) diff --git a/packages/mask/background/database/persona/app.ts b/packages/mask/background/database/persona/app.ts new file mode 100644 index 000000000000..f3b8f60a8c47 --- /dev/null +++ b/packages/mask/background/database/persona/app.ts @@ -0,0 +1,455 @@ +import type { + PersonaRecord, + FullPersonaDBTransaction, + PersonasTransaction, + PersonaRecordWithPrivateKey, + ProfileRecord, + ProfileTransaction, + RelationRecord, + RelationTransaction, + LinkedProfileDetails, +} from './type' +import { + ProfileIdentifier, + PersonaIdentifier, + Identifier, + IdentifierMap, + ECKeyIdentifier, + EC_Public_JsonWebKey, + EC_Private_JsonWebKey, + AESJsonWebKey, +} from '@masknet/shared-base' +import { nativeAPI } from '../../../shared/native-rpc' +import type { + PersonaRecord as NativePersonaRecord, + ProfileRecord as NativeProfileRecord, + RelationRecord as NativeRelationRecord, + EC_Private_JsonWebKey as Native_EC_Private_JsonWebKey, + EC_Public_JsonWebKey as Native_EC_Public_JsonWebKey, + AESJsonWebKey as Native_AESJsonWebKey, +} from '@masknet/public-api' +import { MaskMessages } from '../../../shared' +export async function consistentPersonaDBWriteAccess(action: () => Promise) { + await action() +} + +export async function createPersonaDB(record: PersonaRecord): Promise { + await nativeAPI?.api.create_persona({ + persona: personaRecordToDB(record), + }) +} + +export async function queryPersonaByProfileDB( + query: ProfileIdentifier, + t?: FullPersonaDBTransaction<'readonly'>, +): Promise { + const x = await nativeAPI?.api.query_persona_by_profile({ + options: { + profileIdentifier: query.toText(), + }, + }) + + if (!x) return null + + return personaRecordOutDB(x) +} + +export async function queryPersonaDB( + query: PersonaIdentifier, + t?: PersonasTransaction<'readonly'>, + isIncludeLogout?: boolean, +): Promise { + const x = await nativeAPI?.api.query_persona({ + identifier: query.toText(), + includeLogout: isIncludeLogout, + }) + if (!x) return null + return personaRecordOutDB(x) +} + +export async function queryPersonasDB( + query: { + identifiers?: PersonaIdentifier[] + hasPrivateKey?: boolean + nameContains?: string + initialized?: boolean + }, + t?: PersonasTransaction<'readonly'>, + isIncludeLogout?: boolean, +): Promise { + const results = await nativeAPI?.api.query_personas({ + ...query, + includeLogout: isIncludeLogout, + }) + + if (!results) return [] + return results.map((x) => personaRecordOutDB(x)) +} + +export async function queryPersonasWithPrivateKey(): Promise { + const results = await nativeAPI?.api.query_personas({ + hasPrivateKey: true, + }) + + if (!results) return [] + return results.map((x) => personaRecordOutDB(x)) as PersonaRecordWithPrivateKey[] +} + +/** + * Update an existing Persona record. + * @param nextRecord The partial record to be merged + * @param howToMerge How to merge linkedProfiles and `field: undefined` + * @param t transaction + */ + +export async function updatePersonaDB( // Do a copy here. We need to delete keys from it. + { ...nextRecord }: Readonly & Pick>, + howToMerge: { + linkedProfiles: 'replace' | 'merge' + explicitUndefinedField: 'ignore' | 'delete field' + }, + t?: PersonasTransaction<'readwrite'>, +): Promise { + return nativeAPI?.api.update_persona({ + persona: partialPersonaRecordToDB(nextRecord), + options: { + linkedProfileMergePolicy: howToMerge.linkedProfiles === 'replace' ? 0 : 1, + deleteUndefinedFields: howToMerge.explicitUndefinedField !== 'ignore', + }, + }) +} + +export async function createOrUpdatePersonaDB( + record: Partial & Pick, + howToMerge: Parameters[1] & { protectPrivateKey?: boolean }, + t?: PersonasTransaction<'readwrite'>, +): Promise { + return nativeAPI?.api.update_persona({ + persona: partialPersonaRecordToDB(record), + options: { + linkedProfileMergePolicy: howToMerge.linkedProfiles === 'replace' ? 0 : 1, + deleteUndefinedFields: howToMerge.explicitUndefinedField !== 'ignore', + protectPrivateKey: howToMerge.protectPrivateKey, + createWhenNotExist: true, + }, + }) +} + +/** + * Delete a Persona + */ +export async function deletePersonaDB( + id: PersonaIdentifier, + confirm: 'delete even with private' | "don't delete if have private key", + t?: PersonasTransaction<'readwrite'>, +): Promise { + return nativeAPI?.api.delete_persona({ + identifier: id.toText(), + options: { + safeDelete: confirm === 'delete even with private', + }, + }) +} + +/** + * Delete a Persona + * @returns a boolean. true: the record no longer exists; false: the record is kept. + */ +export async function safeDeletePersonaDB( + id: PersonaIdentifier, + t?: FullPersonaDBTransaction<'readwrite'>, +): Promise { + return deletePersonaDB(id, "don't delete if have private key") +} + +/** + * Create a new profile. + */ +export async function createProfileDB(record: ProfileRecord, t?: ProfileTransaction<'readwrite'>): Promise { + await nativeAPI?.api.create_profile({ + profile: profileRecordToDB(record), + }) + MaskMessages.events.profilesChanged.sendToAll([{ of: record.identifier, reason: 'update' }]) +} + +/** + * Query a profile. + */ +export async function queryProfileDB( + id: ProfileIdentifier, + t?: ProfileTransaction<'readonly'>, +): Promise { + const x = await nativeAPI?.api.query_profile({ + options: { + identifier: id.toText(), + }, + }) + + if (x) return profileRecordOutDB(x) + + return null +} + +/** + * Query many profiles. + */ +export async function queryProfilesDB( + query: { + network?: string + identifiers?: ProfileIdentifier[] + hasLinkedPersona?: boolean + }, + t?: ProfileTransaction<'readonly'>, +): Promise { + const profiles = await nativeAPI?.api.query_profiles({ + identifiers: query.identifiers?.map((x) => x.toText()), + network: query.network, + hasLinkedPersona: query.hasLinkedPersona, + }) + + if (!profiles) return [] + return profiles.map((x) => profileRecordOutDB(x)) +} + +/** + * @deprecated + * query profiles with paged + * @param options + * @param count + */ +export async function queryProfilesPagedDB( + options: { + after?: ProfileIdentifier + query?: string + }, + count: number, +): Promise { + return [] +} + +/** + * Update a profile. + */ +export async function updateProfileDB( + updating: Partial & Pick, + t?: ProfileTransaction<'readwrite'>, +): Promise { + return nativeAPI?.api.update_profile({ + profile: partialProfileRecordToDB(updating), + }) +} + +export async function createOrUpdateProfileDB(rec: ProfileRecord, t?: ProfileTransaction<'readwrite'>): Promise { + return nativeAPI?.api.update_profile({ + profile: partialProfileRecordToDB(rec), + options: { + createWhenNotExist: true, + }, + }) +} + +/** + * Detach a profile from it's linking persona. + * @param identifier The profile want to detach + * @param t A living transaction + */ +export async function detachProfileDB( + identifier: ProfileIdentifier, + t?: FullPersonaDBTransaction<'readwrite'>, +): Promise { + return nativeAPI?.api.detach_profile({ + identifier: identifier.toText(), + }) +} + +/** + * attach a profile. + */ +export async function attachProfileDB( + identifier: ProfileIdentifier, + attachTo: PersonaIdentifier, + data: LinkedProfileDetails, +): Promise { + await nativeAPI?.api.attach_profile({ + profileIdentifier: identifier.toText(), + personaIdentifier: attachTo.toText(), + state: data, + }) + + const persona = await queryPersonaDB(attachTo) + + if (persona?.privateKey) MaskMessages.events.ownPersonaChanged.sendToAll(undefined) +} + +/** + * Delete a profile + */ +export async function deleteProfileDB(id: ProfileIdentifier, t?: ProfileTransaction<'readwrite'>): Promise { + await nativeAPI?.api.delete_profile({ + identifier: id.toText(), + }) + MaskMessages.events.profilesChanged.sendToAll([{ reason: 'delete', of: id }]) +} + +/** + * Create a new Relation + */ +export async function createRelationDB( + record: Omit, + t?: RelationTransaction<'readwrite'>, + silent = false, +): Promise { + await nativeAPI?.api.create_relation({ + relation: relationRecordToDB(record), + }) + + if (!silent) + MaskMessages.events.relationsChanged.sendToAll([{ of: record.profile, reason: 'update', favor: record.favor }]) +} + +export async function queryRelations( + query: (record: RelationRecord) => boolean, + t?: RelationTransaction<'readonly'>, +): Promise { + const results = await nativeAPI?.api.query_relations({}) + + if (!results?.length) return [] + return results.map((x) => relationRecordOutDB(x)) +} + +export async function queryRelationsPagedDB( + linked: PersonaIdentifier, + options: { + network: string + after?: RelationRecord + pageOffset?: number + }, + count: number, +): Promise { + const results = await nativeAPI?.api.query_relations({ + options: { + network: options.network, + pageOption: options.pageOffset + ? { + pageSize: count, + pageOffset: options.pageOffset, + } + : undefined, + }, + }) + + if (!results?.length) return [] + return results.map((x) => relationRecordOutDB(x)) +} + +/** + * Update a relation + * @param updating + * @param t + * @param silent + */ +export async function updateRelationDB( + updating: Omit, + t?: RelationTransaction<'readwrite'>, + silent = false, +): Promise { + await nativeAPI?.api.update_relation({ + relation: relationRecordToDB(updating), + }) + if (!silent) { + MaskMessages.events.relationsChanged.sendToAll([ + { of: updating.profile, favor: updating.favor, reason: 'update' }, + ]) + } +} + +// #region out db & to db +function personaRecordToDB(x: PersonaRecord): NativePersonaRecord { + return { + ...x, + publicKey: x.publicKey as JsonWebKey as unknown as Native_EC_Public_JsonWebKey, + privateKey: x.privateKey as JsonWebKey as unknown as Native_EC_Private_JsonWebKey, + localKey: x.localKey as JsonWebKey as unknown as Native_AESJsonWebKey, + identifier: x.identifier.toText(), + linkedProfiles: Object.fromEntries(x.linkedProfiles.__raw_map__), + createdAt: x.createdAt.getTime(), + updatedAt: x.createdAt.getTime(), + } +} + +function partialPersonaRecordToDB( + x: Readonly & Pick>, +): Partial { + return { + publicKey: x.publicKey as JsonWebKey as unknown as Native_EC_Public_JsonWebKey, + privateKey: x.privateKey as JsonWebKey as unknown as Native_EC_Private_JsonWebKey, + localKey: x.localKey as JsonWebKey as unknown as Native_AESJsonWebKey, + identifier: x.identifier.toText(), + linkedProfiles: x.linkedProfiles?.__raw_map__ ? Object.fromEntries(x.linkedProfiles.__raw_map__) : {}, + createdAt: x.createdAt?.getTime(), + updatedAt: x.createdAt?.getTime(), + } +} + +function personaRecordOutDB(x: NativePersonaRecord): PersonaRecord { + return { + publicKey: x.publicKey as JsonWebKey as unknown as EC_Public_JsonWebKey, + privateKey: x.privateKey as JsonWebKey as unknown as EC_Private_JsonWebKey, + localKey: x.localKey as JsonWebKey as unknown as AESJsonWebKey, + identifier: Identifier.fromString(x.identifier, ECKeyIdentifier).unwrap(), + linkedProfiles: new IdentifierMap(new Map(Object.entries(x.linkedProfiles)), ProfileIdentifier), + createdAt: new Date(x.createdAt), + updatedAt: new Date(x.updatedAt), + } +} + +function profileRecordToDB(x: ProfileRecord): NativeProfileRecord { + return { + ...x, + identifier: x.identifier.toText(), + localKey: x.localKey as JsonWebKey as unknown as Native_AESJsonWebKey, + linkedPersona: x.linkedPersona?.toText(), + createdAt: x.createdAt.getTime(), + updatedAt: x.updatedAt.getTime(), + } +} + +function profileRecordOutDB(x: NativeProfileRecord): ProfileRecord { + return { + localKey: x.localKey as JsonWebKey as unknown as AESJsonWebKey, + identifier: Identifier.fromString(x.identifier, ProfileIdentifier).unwrap(), + linkedPersona: x.linkedPersona ? Identifier.fromString(x.linkedPersona, ECKeyIdentifier).unwrap() : undefined, + createdAt: new Date(x.createdAt), + updatedAt: new Date(x.updatedAt), + } +} + +function partialProfileRecordToDB( + x: Readonly & Pick>, +): Partial { + return { + ...x, + identifier: x.identifier.toText(), + localKey: x.localKey as JsonWebKey as unknown as Native_AESJsonWebKey, + linkedPersona: x.linkedPersona?.toText(), + createdAt: x.createdAt?.getTime(), + updatedAt: x.updatedAt?.getTime(), + } +} + +function relationRecordToDB(x: Omit): Omit { + return { + ...x, + profile: x.profile.toText(), + linked: x.linked.toText(), + } +} + +function relationRecordOutDB(x: NativeRelationRecord): RelationRecord { + return { + ...x, + profile: Identifier.fromString(x.profile, ProfileIdentifier).unwrap(), + linked: Identifier.fromString(x.linked, ECKeyIdentifier).unwrap(), + } +} +// #endregion diff --git a/packages/mask/background/database/persona/consistency.ts b/packages/mask/background/database/persona/consistency.ts index e23525a8bfed..0f0200bcad10 100644 --- a/packages/mask/background/database/persona/consistency.ts +++ b/packages/mask/background/database/persona/consistency.ts @@ -7,7 +7,7 @@ import { ECKeyIdentifier, } from '@masknet/shared-base' import { restorePrototype } from '../../../utils-pure' -import type { FullPersonaDBTransaction } from './db' +import type { FullPersonaDBTransaction } from './type' type ReadwriteFullPersonaDBTransaction = FullPersonaDBTransaction<'readwrite'> diff --git a/packages/mask/background/database/persona/db.ts b/packages/mask/background/database/persona/db.ts index 5bca2fc71c94..fae1a9249f96 100644 --- a/packages/mask/background/database/persona/db.ts +++ b/packages/mask/background/database/persona/db.ts @@ -1,789 +1,38 @@ -import Fuse from 'fuse.js' -import { DBSchema, openDB } from 'idb/with-async-ittr' -import { CryptoKeyToJsonWebKey, PrototypeLess, restorePrototype } from '../../../utils-pure' -import { createDBAccessWithAsyncUpgrade, createTransaction, IDBPSafeTransaction } from '../utils/openDB' -import { assertPersonaDBConsistency } from './consistency' -import { - AESJsonWebKey, - ECKeyIdentifier, - EC_Private_JsonWebKey, - EC_Public_JsonWebKey, - Identifier, - IdentifierMap, - PersonaIdentifier, - ProfileIdentifier, - RelationFavor, -} from '@masknet/shared-base' -import { MaskMessages } from '../../../shared' - -/** - * Database structure: - * - * # ObjectStore `persona`: - * @description Store Personas. - * @type {PersonaRecordDB} - * @keys inline, {@link PersonaRecordDb.identifier} - * - * # ObjectStore `profiles`: - * @description Store profiles. - * @type {ProfileRecord} - * A persona links to 0 or more profiles. - * Each profile links to 0 or 1 persona. - * @keys inline, {@link ProfileRecord.identifier} - * - * # ObjectStore `relations`: - * @description Store relations. - * @type {RelationRecord} - * Save the relationship between persona and profile. - * @keys inline {@link RelationRecord.linked @link RelationRecord.profile} - */ - -const db = createDBAccessWithAsyncUpgrade( - 1, - 4, - (currentOpenVersion, knowledge) => { - return openDB('maskbook-persona', currentOpenVersion, { - upgrade(db, oldVersion, newVersion, transaction) { - function v0_v1() { - db.createObjectStore('personas', { keyPath: 'identifier' }) - db.createObjectStore('profiles', { keyPath: 'identifier' }) - transaction.objectStore('profiles').createIndex('network', 'network', { unique: false }) - transaction.objectStore('personas').createIndex('hasPrivateKey', 'hasPrivateKey', { unique: false }) - } - async function v1_v2() { - const persona = transaction.objectStore('personas') - const profile = transaction.objectStore('profiles') - await update(persona) - await update(profile) - async function update(q: typeof persona | typeof profile) { - for await (const rec of persona) { - if (!rec.value.localKey) continue - const jwk = knowledge?.data.get(rec.value.identifier) - if (!jwk) { - // !!! This should not happen - // !!! Remove it will implicitly drop user's localKey - delete rec.value.localKey - // !!! Keep it will leave a bug, broken data in the DB - // continue - // !!! DON'T throw cause it will break the database upgrade - } - rec.value.localKey = jwk - await rec.update(rec.value) - } - } - } - async function v2_v3() { - try { - db.createObjectStore('relations', { keyPath: ['linked', 'profile'] }) - transaction - .objectStore('relations') - .createIndex('linked, profile, favor', ['linked', 'profile', 'favor'], { unique: true }) - } catch {} - } - async function v3_v4() { - try { - transaction.objectStore('relations').deleteIndex('linked, profile, favor') - transaction - .objectStore('relations') - .createIndex('favor, profile, linked', ['favor', 'profile', 'linked'], { unique: true }) - const relation = transaction.objectStore('relations') - - await update(relation) - async function update(q: typeof relation) { - for await (const rec of relation) { - rec.value.favor = - rec.value.favor === RelationFavor.DEPRECATED - ? RelationFavor.UNCOLLECTED - : RelationFavor.COLLECTED - - await rec.update(rec.value) - } - } - } catch {} - } - if (oldVersion < 1) v0_v1() - if (oldVersion < 2) v1_v2() - if (oldVersion < 3) v2_v3() - if (oldVersion < 4) v3_v4() - }, - }) - }, - async (db) => { - if (db.version === 1) { - const map: V1To2 = { version: 2, data: new Map() } - const t = createTransaction(db, 'readonly')('personas', 'profiles') - const a = await t.objectStore('personas').getAll() - const b = await t.objectStore('profiles').getAll() - for (const rec of [...a, ...b]) { - if (!rec.localKey) continue - map.data.set(rec.identifier, (await CryptoKeyToJsonWebKey(rec.localKey as any)) as any) +import { hasNativeAPI } from '../../../shared/native-rpc' +export * from './type' + +export const { + queryProfilesDB, + queryProfileDB, + queryPersonaDB, + queryPersonasDB, + detachProfileDB, + deletePersonaDB, + safeDeletePersonaDB, + queryPersonaByProfileDB, + createPersonaDB, + attachProfileDB, + consistentPersonaDBWriteAccess, + updatePersonaDB, + createOrUpdatePersonaDB, + queryProfilesPagedDB, + createOrUpdateProfileDB, + createProfileDB, + createRelationDB, + createRelationsTransaction, + deleteProfileDB, + queryRelationsPagedDB, + updateRelationDB, + queryPersonasWithPrivateKey, + queryRelations, +} = new Proxy({} as any as typeof import('./web'), { + get(_, key) { + return async function (...args: any) { + if (hasNativeAPI) { + return import('./app').then((x) => (x as any)[key](...args)) } - return map - } - return undefined - }, -) -type V1To2 = { version: 2; data: Map } -type Knowledge = V1To2 -export type FullPersonaDBTransaction = IDBPSafeTransaction< - PersonaDB, - ['personas', 'profiles', 'relations'], - Mode -> -export type ProfileTransaction = IDBPSafeTransaction< - PersonaDB, - ['profiles'], - Mode -> -export type PersonasTransaction = IDBPSafeTransaction< - PersonaDB, - ['personas'], - Mode -> - -export type RelationTransaction = IDBPSafeTransaction< - PersonaDB, - ['relations'], - Mode -> - -export async function createRelationsTransaction() { - const database = await db() - return createTransaction(database, 'readwrite')('relations') -} - -// @deprecated Please create a transaction directly -export async function consistentPersonaDBWriteAccess( - action: (t: FullPersonaDBTransaction<'readwrite'>) => Promise, - tryToAutoFix = true, -) { - // TODO: collect all changes on this transaction then only perform consistency check on those records. - const database = await db() - let t = createTransaction(database, 'readwrite')('profiles', 'personas', 'relations') - let finished = false - const finish = () => (finished = true) - t.addEventListener('abort', finish) - t.addEventListener('complete', finish) - t.addEventListener('error', finish) - // Pause those events when patching write access - const resumeProfile = MaskMessages.events.profilesChanged.pause() - const resumePersona = MaskMessages.events.ownPersonaChanged.pause() - const resumeRelation = MaskMessages.events.relationsChanged.pause() - try { - await action(t) - } finally { - if (finished) { - console.warn('The transaction ends too early! There MUST be a bug in the program!') - console.trace() - // start a new transaction to check consistency - t = createTransaction(database, 'readwrite')('profiles', 'personas', 'relations') + return import('./web').then((x) => (x as any)[key](...args)) } - try { - await assertPersonaDBConsistency(tryToAutoFix ? 'fix' : 'throw', 'full check', t) - resumeProfile((data) => [data.flat()]) - resumePersona((data) => (data.length ? [undefined] : [])) - resumeRelation((data) => [data.flat()]) - } finally { - // If the consistency check throws, we drop all pending events - resumeProfile(() => []) - resumePersona(() => []) - resumeRelation(() => []) - } - } -} - -export async function createReadonlyPersonaTransaction() { - return createTransaction(await db(), 'readonly') -} - -// #region Plain methods -/** Create a new Persona. */ -export async function createPersonaDB(record: PersonaRecord, t: PersonasTransaction<'readwrite'>): Promise { - await t.objectStore('personas').add(personaRecordToDB(record)) - record.privateKey && MaskMessages.events.ownPersonaChanged.sendToAll(undefined) -} - -export async function queryPersonaByProfileDB( - query: ProfileIdentifier, - t?: FullPersonaDBTransaction<'readonly'>, -): Promise { - t = t || createTransaction(await db(), 'readonly')('personas', 'profiles', 'relations') - const x = await t.objectStore('profiles').get(query.toText()) - if (!x?.linkedPersona) return null - return queryPersonaDB(restorePrototype(x.linkedPersona, ECKeyIdentifier.prototype), t) -} - -/** - * Query a Persona. - */ -export async function queryPersonaDB( - query: PersonaIdentifier, - t?: PersonasTransaction<'readonly'>, - isIncludeLogout?: boolean, -): Promise { - t = t || createTransaction(await db(), 'readonly')('personas') - const x = await t.objectStore('personas').get(query.toText()) - if (x && (isIncludeLogout || !x.hasLogout)) return personaRecordOutDB(x) - return null -} - -/** - * Query many Personas. - */ -export async function queryPersonasDB( - query: (record: PersonaRecord) => boolean, - t?: PersonasTransaction<'readonly'>, - isIncludeLogout?: boolean, -): Promise { - t = t || createTransaction(await db(), 'readonly')('personas') - const records: PersonaRecord[] = [] - for await (const each of t.objectStore('personas')) { - const out = personaRecordOutDB(each.value) - if (query(out) && (isIncludeLogout || !out.hasLogout)) records.push(out) - } - return records -} - -export type PersonaRecordWithPrivateKey = PersonaRecord & Required> -/** - * Query many Personas. - */ -export async function queryPersonasWithPrivateKey( - t?: FullPersonaDBTransaction<'readonly'>, -): Promise { - t = t || createTransaction(await db(), 'readonly')('personas', 'profiles', 'relations') - const records: PersonaRecord[] = [] - records.push( - ...(await t.objectStore('personas').index('hasPrivateKey').getAll(IDBKeyRange.only('yes'))).map( - personaRecordOutDB, - ), - ) - return records as PersonaRecordWithPrivateKey[] -} - -/** - * Update an existing Persona record. - * @param nextRecord The partial record to be merged - * @param howToMerge How to merge linkedProfiles and `field: undefined` - * @param t transaction - */ -export async function updatePersonaDB( - // Do a copy here. We need to delete keys from it. - { ...nextRecord }: Readonly & Pick>, - howToMerge: { - linkedProfiles: 'replace' | 'merge' - explicitUndefinedField: 'ignore' | 'delete field' }, - t: PersonasTransaction<'readwrite'>, -): Promise { - const _old = await t.objectStore('personas').get(nextRecord.identifier.toText()) - if (!_old) throw new TypeError('Update an non-exist data') - const old = personaRecordOutDB(_old) - let nextLinkedProfiles = old.linkedProfiles - if (nextRecord.linkedProfiles) { - if (howToMerge.linkedProfiles === 'merge') - nextLinkedProfiles = new IdentifierMap( - new Map([...nextLinkedProfiles.__raw_map__, ...nextRecord.linkedProfiles.__raw_map__]), - ) - else nextLinkedProfiles = nextRecord.linkedProfiles - } - if (howToMerge.explicitUndefinedField === 'ignore') { - for (const _key in nextRecord) { - const key = _key as keyof typeof nextRecord - if (nextRecord[key] === undefined) { - delete nextRecord[key as keyof typeof nextRecord] - } - } - } - const next: PersonaRecordDB = personaRecordToDB({ - ...old, - ...nextRecord, - linkedProfiles: nextLinkedProfiles, - updatedAt: nextRecord.updatedAt ?? new Date(), - }) - await t.objectStore('personas').put(next) - ;(next.privateKey || old.privateKey) && MaskMessages.events.ownPersonaChanged.sendToAll(undefined) -} - -export async function createOrUpdatePersonaDB( - record: Partial & Pick, - howToMerge: Parameters[1] & { protectPrivateKey?: boolean }, - t: PersonasTransaction<'readwrite'>, -) { - const personaInDB = await t.objectStore('personas').get(record.identifier.toText()) - - if (howToMerge.protectPrivateKey && !!personaInDB?.privateKey && !record.privateKey) return - - if (howToMerge.protectPrivateKey && !!personaInDB?.privateKey) { - const nextRecord = personaRecordOutDB(personaInDB) - nextRecord.hasLogout = false - - return updatePersonaDB(nextRecord, howToMerge, t) - } - - if (personaInDB) return updatePersonaDB(record, howToMerge, t) - else - return createPersonaDB( - { - ...record, - createdAt: record.createdAt ?? new Date(), - updatedAt: record.updatedAt ?? new Date(), - linkedProfiles: new IdentifierMap(new Map()), - }, - t, - ) -} - -/** - * Delete a Persona - */ -export async function deletePersonaDB( - id: PersonaIdentifier, - confirm: 'delete even with private' | "don't delete if have private key", - t: PersonasTransaction<'readwrite'>, -): Promise { - const r = await t.objectStore('personas').get(id.toText()) - if (!r) return - if (confirm !== 'delete even with private' && r.privateKey) - throw new TypeError('Cannot delete a persona with a private key') - await t.objectStore('personas').delete(id.toText()) - r.privateKey && MaskMessages.events.ownPersonaChanged.sendToAll() -} -/** - * Delete a Persona - * @returns a boolean. true: the record no longer exists; false: the record is kept. - */ -export async function safeDeletePersonaDB( - id: PersonaIdentifier, - t?: FullPersonaDBTransaction<'readwrite'>, -): Promise { - t = t || createTransaction(await db(), 'readwrite')('personas', 'profiles', 'relations') - const r = await queryPersonaDB(id, t) - if (!r) return true - if (r.linkedProfiles.size !== 0) return false - if (r.privateKey) return false - await deletePersonaDB(id, "don't delete if have private key", t) - return true -} - -/** - * Create a new profile. - */ -export async function createProfileDB(record: ProfileRecord, t: ProfileTransaction<'readwrite'>): Promise { - await t.objectStore('profiles').add(profileToDB(record)) - MaskMessages.events.profilesChanged.sendToAll([{ of: record.identifier, reason: 'update' }]) -} - -/** - * Query a profile. - */ -export async function queryProfileDB( - id: ProfileIdentifier, - t?: ProfileTransaction<'readonly'>, -): Promise { - t = t || createTransaction(await db(), 'readonly')('profiles') - const result = await t.objectStore('profiles').get(id.toText()) - if (result) return profileOutDB(result) - return null -} - -/** - * Query many profiles. - */ -export async function queryProfilesDB( - network: string | ((record: ProfileRecord) => boolean), - t?: ProfileTransaction<'readonly'>, -): Promise { - t = t || createTransaction(await db(), 'readonly')('profiles') - const result: ProfileRecord[] = [] - if (typeof network === 'string') { - result.push( - ...(await t.objectStore('profiles').index('network').getAll(IDBKeyRange.only(network))).map(profileOutDB), - ) - } else { - for await (const each of t.objectStore('profiles').iterate()) { - const out = profileOutDB(each.value) - if (network(out)) result.push(out) - } - } - return result -} - -const fuse = new Fuse([] as ProfileRecord[], { - shouldSort: true, - threshold: 0.45, - minMatchCharLength: 1, - keys: [ - { name: 'nickname', weight: 0.8 }, - { name: 'identifier.network', weight: 0.2 }, - ], }) - -export async function queryProfilesPagedDB( - options: { - after?: ProfileIdentifier - query?: string - }, - count: number, -): Promise { - const t = createTransaction(await db(), 'readonly')('profiles') - const breakPoint = options.after?.toText() - let firstRecord = true - const data: ProfileRecord[] = [] - for await (const rec of t.objectStore('profiles').iterate()) { - if (firstRecord && breakPoint && rec.key !== breakPoint) { - rec.continue(breakPoint) - firstRecord = false - continue - } - firstRecord = false - // after this record - if (rec.key === breakPoint) continue - if (count <= 0) break - const outData = profileOutDB(rec.value) - if (typeof options.query === 'string') { - fuse.setCollection([outData]) - if (!fuse.search(options.query).length) continue - } - count -= 1 - data.push(outData) - } - return data -} - -/** - * Update a profile. - */ -export async function updateProfileDB( - updating: Partial & Pick, - t: ProfileTransaction<'readwrite'>, -): Promise { - const old = await t.objectStore('profiles').get(updating.identifier.toText()) - if (!old) throw new Error('Updating a non exists record') - - const nextRecord: ProfileRecordDB = profileToDB({ - ...profileOutDB(old), - ...updating, - }) - await t.objectStore('profiles').put(nextRecord) - MaskMessages.events.profilesChanged.sendToAll([{ reason: 'update', of: updating.identifier }]) -} -export async function createOrUpdateProfileDB(rec: ProfileRecord, t: ProfileTransaction<'readwrite'>) { - if (await queryProfileDB(rec.identifier, t)) return updateProfileDB(rec, t) - else return createProfileDB(rec, t) -} - -/** - * Detach a profile from it's linking persona. - * @param identifier The profile want to detach - * @param t A living transaction - */ -export async function detachProfileDB( - identifier: ProfileIdentifier, - t?: FullPersonaDBTransaction<'readwrite'>, -): Promise { - t = t || createTransaction(await db(), 'readwrite')('personas', 'profiles', 'relations') - const profile = await queryProfileDB(identifier, t) - if (!profile?.linkedPersona) return - - const linkedPersona = profile.linkedPersona - const persona = await queryPersonaDB(linkedPersona, t) - persona?.linkedProfiles.delete(identifier) - - if (persona) { - await updatePersonaDB(persona, { linkedProfiles: 'replace', explicitUndefinedField: 'delete field' }, t) - if (persona.privateKey) MaskMessages.events.ownPersonaChanged.sendToAll(undefined) - } - profile.linkedPersona = undefined - await updateProfileDB(profile, t) -} - -/** - * attach a profile. - */ -export async function attachProfileDB( - identifier: ProfileIdentifier, - attachTo: PersonaIdentifier, - data: LinkedProfileDetails, - t?: FullPersonaDBTransaction<'readwrite'>, -): Promise { - t = t || createTransaction(await db(), 'readwrite')('personas', 'profiles', 'relations') - const profile = - (await queryProfileDB(identifier, t)) || - (await createProfileDB({ identifier, createdAt: new Date(), updatedAt: new Date() }, t)) || - (await queryProfileDB(identifier, t)) - const persona = await queryPersonaDB(attachTo, t) - if (!persona || !profile) return - - if (profile.linkedPersona !== undefined && !profile.linkedPersona.equals(attachTo)) { - await detachProfileDB(identifier, t) - } - - profile.linkedPersona = attachTo - persona.linkedProfiles.set(identifier, data) - - await updatePersonaDB(persona, { linkedProfiles: 'merge', explicitUndefinedField: 'ignore' }, t) - await updateProfileDB(profile, t) - - if (persona.privateKey) MaskMessages.events.ownPersonaChanged.sendToAll(undefined) -} - -/** - * Delete a profile - */ -export async function deleteProfileDB(id: ProfileIdentifier, t: ProfileTransaction<'readwrite'>): Promise { - await t.objectStore('profiles').delete(id.toText()) - MaskMessages.events.profilesChanged.sendToAll([{ reason: 'delete', of: id }]) -} - -/** - * Create a new Relation - */ -export async function createRelationDB( - record: Omit, - t: RelationTransaction<'readwrite'>, - silent = false, -): Promise { - await t.objectStore('relations').add(relationRecordToDB(record)) - if (!silent) - MaskMessages.events.relationsChanged.sendToAll([{ of: record.profile, reason: 'update', favor: record.favor }]) -} - -export async function queryRelations(query: (record: RelationRecord) => boolean, t?: RelationTransaction<'readonly'>) { - t = t || createTransaction(await db(), 'readonly')('relations') - const records: RelationRecord[] = [] - - for await (const each of t.objectStore('relations')) { - const out = relationRecordOutDB(each.value) - if (query(out)) records.push(out) - } - - return records -} - -/** - * Query relations by paged - */ -export async function queryRelationsPagedDB( - linked: PersonaIdentifier, - options: { - network: string - after?: RelationRecord - }, - count: number, -) { - const t = createTransaction(await db(), 'readonly')('relations') - let firstRecord = true - - const data: RelationRecord[] = [] - - for await (const cursor of t.objectStore('relations').index('favor, profile, linked').iterate()) { - if (cursor.value.linked !== linked.toText()) continue - if (cursor.value.network !== options.network) continue - - if (firstRecord && options.after && options.after.profile.toText() !== cursor?.value.profile) { - cursor.continue([options.after.favor, options.after.profile.toText(), options.after.linked.toText()]) - firstRecord = false - continue - } - - firstRecord = false - - // after this record - if ( - options.after?.linked.toText() === cursor?.value.linked && - options.after?.profile.toText() === cursor?.value.profile - ) - continue - - if (count <= 0) break - const outData = relationRecordOutDB(cursor.value) - count -= 1 - data.push(outData) - } - return data -} - -/** - * Update a relation - * @param updating - * @param t - * @param silent - */ -export async function updateRelationDB( - updating: Omit, - t: RelationTransaction<'readwrite'>, - silent = false, -): Promise { - const old = await t - .objectStore('relations') - .get(IDBKeyRange.only([updating.linked.toText(), updating.profile.toText()])) - - if (!old) throw new Error('Updating a non exists record') - - const nextRecord: RelationRecordDB = relationRecordToDB({ - ...relationRecordOutDB(old), - ...updating, - }) - - await t.objectStore('relations').put(nextRecord) - if (!silent) { - MaskMessages.events.relationsChanged.sendToAll([ - { of: updating.profile, favor: updating.favor, reason: 'update' }, - ]) - } -} - -// #endregion - -// #region Type -export interface ProfileRecord { - identifier: ProfileIdentifier - nickname?: string - localKey?: AESJsonWebKey - linkedPersona?: PersonaIdentifier - createdAt: Date - updatedAt: Date -} - -export interface LinkedProfileDetails { - connectionConfirmState: 'confirmed' | 'pending' | 'denied' -} - -export interface PersonaRecord { - identifier: PersonaIdentifier - /** - * If this key is generated by the mnemonic word, this field should be set. - */ - mnemonic?: { - words: string - parameter: { path: string; withPassword: boolean } - } - publicKey: EC_Public_JsonWebKey - privateKey?: EC_Private_JsonWebKey - localKey?: AESJsonWebKey - nickname?: string - linkedProfiles: IdentifierMap - createdAt: Date - updatedAt: Date - hasLogout?: boolean - /** - * create a dummy persona which should hide to the user until - * connected at least one SNS identity - */ - uninitialized?: boolean -} - -export interface RelationRecord { - profile: ProfileIdentifier - linked: PersonaIdentifier - network: string - favor: RelationFavor -} - -type ProfileRecordDB = Omit & { - identifier: string - network: string - linkedPersona?: PrototypeLess -} - -type PersonaRecordDB = Omit & { - identifier: string - linkedProfiles: Map - /** - * This field is used as index of the db. - */ - hasPrivateKey: 'no' | 'yes' -} -type RelationRecordDB = Omit & { - network: string - profile: string - linked: string -} - -export interface PersonaDB extends DBSchema { - /** Use inline keys */ - personas: { - value: PersonaRecordDB - key: string - indexes: { - hasPrivateKey: string - } - } - /** Use inline keys */ - profiles: { - value: ProfileRecordDB - key: string - indexes: { - // Use `network` field as index - network: string - } - } - /** Use inline keys **/ - relations: { - key: IDBValidKey[] - value: RelationRecordDB - indexes: { - 'linked, profile, favor': [string, string, number] - 'favor, profile, linked': [number, string, string] - } - } -} -// #endregion - -// #region out db & to db -function profileToDB(x: ProfileRecord): ProfileRecordDB { - return { - ...x, - identifier: x.identifier.toText(), - network: x.identifier.network, - } -} -function profileOutDB({ network, ...x }: ProfileRecordDB): ProfileRecord { - if (x.linkedPersona) { - if (x.linkedPersona.type !== 'ec_key') throw new Error('Unknown type of linkedPersona') - } - return { - ...x, - identifier: Identifier.fromString(x.identifier, ProfileIdentifier).unwrap(), - linkedPersona: restorePrototype(x.linkedPersona, ECKeyIdentifier.prototype), - } -} -function personaRecordToDB(x: PersonaRecord): PersonaRecordDB { - return { - ...x, - identifier: x.identifier.toText(), - hasPrivateKey: x.privateKey ? 'yes' : 'no', - linkedProfiles: x.linkedProfiles.__raw_map__, - } -} -function personaRecordOutDB(x: PersonaRecordDB): PersonaRecord { - // @ts-ignore - delete x.hasPrivateKey - const obj: PersonaRecord = { - ...x, - identifier: Identifier.fromString(x.identifier, ECKeyIdentifier).unwrap(), - linkedProfiles: new IdentifierMap(x.linkedProfiles, ProfileIdentifier), - } - return obj -} - -function relationRecordToDB(x: Omit): RelationRecordDB { - return { - ...x, - network: x.profile.network, - profile: x.profile.toText(), - linked: x.linked.toText(), - } -} - -function relationRecordOutDB(x: RelationRecordDB): RelationRecord { - return { - ...x, - profile: Identifier.fromString(x.profile, ProfileIdentifier).unwrap(), - linked: Identifier.fromString(x.linked, ECKeyIdentifier).unwrap(), - } -} - -// #endregion diff --git a/packages/mask/background/database/persona/type.ts b/packages/mask/background/database/persona/type.ts new file mode 100644 index 000000000000..9f3b8a47ecce --- /dev/null +++ b/packages/mask/background/database/persona/type.ts @@ -0,0 +1,135 @@ +import type { IDBPSafeTransaction } from '../utils/openDB' +import type { DBSchema } from 'idb/with-async-ittr' +import type { PrototypeLess } from '../../../utils-pure' +import type { + PersonaIdentifier, + AESJsonWebKey, + EC_Private_JsonWebKey, + EC_Public_JsonWebKey, + IdentifierMap, + ProfileIdentifier, + RelationFavor, +} from '@masknet/shared-base' + +export type FullPersonaDBTransaction = IDBPSafeTransaction< + PersonaDB, + ['personas', 'profiles', 'relations'], + Mode +> + +export type ProfileTransaction = IDBPSafeTransaction< + PersonaDB, + ['profiles'], + Mode +> + +export type PersonasTransaction = IDBPSafeTransaction< + PersonaDB, + ['personas'], + Mode +> + +export type RelationTransaction = IDBPSafeTransaction< + PersonaDB, + ['relations'], + Mode +> + +// #region Type +export type PersonaRecordDB = Omit & { + identifier: string + linkedProfiles: Map + /** + * This field is used as index of the db. + */ + hasPrivateKey: 'no' | 'yes' +} + +export type ProfileRecordDB = Omit & { + identifier: string + network: string + linkedPersona?: PrototypeLess +} + +export type RelationRecordDB = Omit & { + network: string + profile: string + linked: string +} + +export interface PersonaDB extends DBSchema { + /** Use inline keys */ + personas: { + value: PersonaRecordDB + key: string + indexes: { + hasPrivateKey: string + } + } + /** Use inline keys */ + profiles: { + value: ProfileRecordDB + key: string + indexes: { + // Use `network` field as index + network: string + } + } + /** Use inline keys **/ + relations: { + key: IDBValidKey[] + value: RelationRecordDB + indexes: { + 'linked, profile, favor': [string, string, number] + 'favor, profile, linked': [number, string, string] + } + } +} + +export interface RelationRecord { + profile: ProfileIdentifier + linked: PersonaIdentifier + network: string + favor: RelationFavor +} + +export interface ProfileRecord { + identifier: ProfileIdentifier + nickname?: string + localKey?: AESJsonWebKey + linkedPersona?: PersonaIdentifier + createdAt: Date + updatedAt: Date +} + +export interface PersonaRecord { + identifier: PersonaIdentifier + /** + * If this key is generated by the mnemonic word, this field should be set. + */ + mnemonic?: { + words: string + parameter: { path: string; withPassword: boolean } + } + publicKey: EC_Public_JsonWebKey + privateKey?: EC_Private_JsonWebKey + localKey?: AESJsonWebKey + nickname?: string + linkedProfiles: IdentifierMap + createdAt: Date + updatedAt: Date + hasLogout?: boolean + /** + * create a dummy persona which should hide to the user until + * connected at least one SNS identity + */ + uninitialized?: boolean +} + +export interface LinkedProfileDetails { + connectionConfirmState: 'confirmed' | 'pending' | 'denied' +} + +export type PersonaRecordWithPrivateKey = PersonaRecord & Required> + +// #endregion diff --git a/packages/mask/background/database/persona/web.ts b/packages/mask/background/database/persona/web.ts new file mode 100644 index 000000000000..9c07de0168ca --- /dev/null +++ b/packages/mask/background/database/persona/web.ts @@ -0,0 +1,722 @@ +import Fuse from 'fuse.js' +import { openDB } from 'idb/with-async-ittr' +import { CryptoKeyToJsonWebKey, restorePrototype } from '../../../utils-pure' +import { createDBAccessWithAsyncUpgrade, createTransaction } from '../utils/openDB' +import { assertPersonaDBConsistency } from './consistency' +import { + AESJsonWebKey, + ECKeyIdentifier, + Identifier, + IdentifierMap, + PersonaIdentifier, + ProfileIdentifier, + RelationFavor, +} from '@masknet/shared-base' +import { MaskMessages } from '../../../shared' +import type { + FullPersonaDBTransaction, + ProfileTransaction, + PersonasTransaction, + RelationTransaction, + PersonaRecordWithPrivateKey, + PersonaDB, + PersonaRecordDB, + ProfileRecord, + ProfileRecordDB, + LinkedProfileDetails, + RelationRecord, + RelationRecordDB, + PersonaRecord, +} from './type' +import { isEmpty } from 'lodash-unified' +/** + * Database structure: + * + * # ObjectStore `persona`: + * @description Store Personas. + * @type {PersonaRecordDB} + * @keys inline, {@link PersonaRecordDb.identifier} + * + * # ObjectStore `profiles`: + * @description Store profiles. + * @type {ProfileRecord} + * A persona links to 0 or more profiles. + * Each profile links to 0 or 1 persona. + * @keys inline, {@link ProfileRecord.identifier} + * + * # ObjectStore `relations`: + * @description Store relations. + * @type {RelationRecord} + * Save the relationship between persona and profile. + * @keys inline {@link RelationRecord.linked @link RelationRecord.profile} + */ + +const db = createDBAccessWithAsyncUpgrade( + 1, + 4, + (currentOpenVersion, knowledge) => { + return openDB('maskbook-persona', currentOpenVersion, { + upgrade(db, oldVersion, newVersion, transaction) { + function v0_v1() { + db.createObjectStore('personas', { keyPath: 'identifier' }) + db.createObjectStore('profiles', { keyPath: 'identifier' }) + transaction.objectStore('profiles').createIndex('network', 'network', { unique: false }) + transaction.objectStore('personas').createIndex('hasPrivateKey', 'hasPrivateKey', { unique: false }) + } + async function v1_v2() { + const persona = transaction.objectStore('personas') + const profile = transaction.objectStore('profiles') + await update(persona) + await update(profile) + async function update(q: typeof persona | typeof profile) { + for await (const rec of persona) { + if (!rec.value.localKey) continue + const jwk = knowledge?.data.get(rec.value.identifier) + if (!jwk) { + // !!! This should not happen + // !!! Remove it will implicitly drop user's localKey + delete rec.value.localKey + // !!! Keep it will leave a bug, broken data in the DB + // continue + // !!! DON'T throw cause it will break the database upgrade + } + rec.value.localKey = jwk + await rec.update(rec.value) + } + } + } + async function v2_v3() { + try { + db.createObjectStore('relations', { keyPath: ['linked', 'profile'] }) + transaction + .objectStore('relations') + .createIndex('linked, profile, favor', ['linked', 'profile', 'favor'], { unique: true }) + } catch {} + } + async function v3_v4() { + try { + transaction.objectStore('relations').deleteIndex('linked, profile, favor') + transaction + .objectStore('relations') + .createIndex('favor, profile, linked', ['favor', 'profile', 'linked'], { unique: true }) + const relation = transaction.objectStore('relations') + + await update(relation) + async function update(q: typeof relation) { + for await (const rec of relation) { + rec.value.favor = + rec.value.favor === RelationFavor.DEPRECATED + ? RelationFavor.UNCOLLECTED + : RelationFavor.COLLECTED + + await rec.update(rec.value) + } + } + } catch {} + } + if (oldVersion < 1) v0_v1() + if (oldVersion < 2) v1_v2() + if (oldVersion < 3) v2_v3() + if (oldVersion < 4) v3_v4() + }, + }) + }, + async (db) => { + if (db.version === 1) { + const map: V1To2 = { version: 2, data: new Map() } + const t = createTransaction(db, 'readonly')('personas', 'profiles') + const a = await t.objectStore('personas').getAll() + const b = await t.objectStore('profiles').getAll() + for (const rec of [...a, ...b]) { + if (!rec.localKey) continue + map.data.set(rec.identifier, (await CryptoKeyToJsonWebKey(rec.localKey as any)) as any) + } + return map + } + return undefined + }, +) +type V1To2 = { version: 2; data: Map } +type Knowledge = V1To2 + +export async function createRelationsTransaction() { + const database = await db() + return createTransaction(database, 'readwrite')('relations') +} + +// @deprecated Please create a transaction directly +export async function consistentPersonaDBWriteAccess( + action: (t: FullPersonaDBTransaction<'readwrite'>) => Promise, + tryToAutoFix = true, +) { + // TODO: collect all changes on this transaction then only perform consistency check on those records. + const database = await db() + let t = createTransaction(database, 'readwrite')('profiles', 'personas', 'relations') + let finished = false + const finish = () => (finished = true) + t.addEventListener('abort', finish) + t.addEventListener('complete', finish) + t.addEventListener('error', finish) + + // Pause those events when patching write access + const resumeProfile = MaskMessages.events.profilesChanged.pause() + const resumePersona = MaskMessages.events.ownPersonaChanged.pause() + const resumeRelation = MaskMessages.events.relationsChanged.pause() + try { + await action(t) + } finally { + if (finished) { + console.warn('The transaction ends too early! There MUST be a bug in the program!') + console.trace() + // start a new transaction to check consistency + t = createTransaction(database, 'readwrite')('profiles', 'personas', 'relations') + } + try { + await assertPersonaDBConsistency(tryToAutoFix ? 'fix' : 'throw', 'full check', t) + resumeProfile((data) => [data.flat()]) + resumePersona((data) => (data.length ? [undefined] : [])) + resumeRelation((data) => [data.flat()]) + } finally { + // If the consistency check throws, we drop all pending events + resumeProfile(() => []) + resumePersona(() => []) + resumeRelation(() => []) + } + } +} + +export async function createReadonlyPersonaTransaction() { + return createTransaction(await db(), 'readonly') +} + +// #region Plain methods +/** Create a new Persona. */ +export async function createPersonaDB(record: PersonaRecord, t: PersonasTransaction<'readwrite'>): Promise { + await t.objectStore('personas').add(personaRecordToDB(record)) + record.privateKey && MaskMessages.events.ownPersonaChanged.sendToAll(undefined) +} + +export async function queryPersonaByProfileDB( + query: ProfileIdentifier, + t?: FullPersonaDBTransaction<'readonly'>, +): Promise { + t = t || createTransaction(await db(), 'readonly')('personas', 'profiles', 'relations') + const x = await t.objectStore('profiles').get(query.toText()) + if (!x?.linkedPersona) return null + return queryPersonaDB(restorePrototype(x.linkedPersona, ECKeyIdentifier.prototype), t) +} + +/** + * Query a Persona. + */ +export async function queryPersonaDB( + query: PersonaIdentifier, + t?: PersonasTransaction<'readonly'>, + isIncludeLogout?: boolean, +): Promise { + t = t || createTransaction(await db(), 'readonly')('personas') + const x = await t.objectStore('personas').get(query.toText()) + if (x && (isIncludeLogout || !x.hasLogout)) return personaRecordOutDB(x) + return null +} + +/** + * Query many Personas. + */ +export async function queryPersonasDB( + query?: { + identifiers?: PersonaIdentifier[] + hasPrivateKey?: boolean + nameContains?: string + initialized?: boolean + }, + t?: PersonasTransaction<'readonly'>, + isIncludeLogout?: boolean, +): Promise { + t = t || createTransaction(await db(), 'readonly')('personas') + const records: PersonaRecord[] = [] + for await (const each of t.objectStore('personas')) { + const out = personaRecordOutDB(each.value) + if ( + (query?.hasPrivateKey && !out.privateKey) || + (query?.nameContains && out.nickname !== query.nameContains) || + (query?.identifiers && !query.identifiers.some((x) => x.equals(out.identifier))) || + (query?.initialized && out.uninitialized) + ) + continue + + if (isIncludeLogout || !out.hasLogout) records.push(out) + } + return records +} + +/** + * Query many Personas. + */ +export async function queryPersonasWithPrivateKey( + t?: FullPersonaDBTransaction<'readonly'>, +): Promise { + t = t || createTransaction(await db(), 'readonly')('personas', 'profiles', 'relations') + const records: PersonaRecord[] = [] + records.push( + ...(await t.objectStore('personas').index('hasPrivateKey').getAll(IDBKeyRange.only('yes'))).map( + personaRecordOutDB, + ), + ) + return records as PersonaRecordWithPrivateKey[] +} + +/** + * Update an existing Persona record. + * @param nextRecord The partial record to be merged + * @param howToMerge How to merge linkedProfiles and `field: undefined` + * @param t transaction + */ +export async function updatePersonaDB( + // Do a copy here. We need to delete keys from it. + { ...nextRecord }: Readonly & Pick>, + howToMerge: { + linkedProfiles: 'replace' | 'merge' + explicitUndefinedField: 'ignore' | 'delete field' + }, + t: PersonasTransaction<'readwrite'>, +): Promise { + const _old = await t.objectStore('personas').get(nextRecord.identifier.toText()) + if (!_old) throw new TypeError('Update an non-exist data') + const old = personaRecordOutDB(_old) + let nextLinkedProfiles = old.linkedProfiles + if (nextRecord.linkedProfiles) { + if (howToMerge.linkedProfiles === 'merge') + nextLinkedProfiles = new IdentifierMap( + new Map([...nextLinkedProfiles.__raw_map__, ...nextRecord.linkedProfiles.__raw_map__]), + ) + else nextLinkedProfiles = nextRecord.linkedProfiles + } + if (howToMerge.explicitUndefinedField === 'ignore') { + for (const _key in nextRecord) { + const key = _key as keyof typeof nextRecord + if (nextRecord[key] === undefined) { + delete nextRecord[key as keyof typeof nextRecord] + } + } + } + const next: PersonaRecordDB = personaRecordToDB({ + ...old, + ...nextRecord, + linkedProfiles: nextLinkedProfiles, + updatedAt: nextRecord.updatedAt ?? new Date(), + }) + await t.objectStore('personas').put(next) + ;(next.privateKey || old.privateKey) && MaskMessages.events.ownPersonaChanged.sendToAll(undefined) +} + +export async function createOrUpdatePersonaDB( + record: Partial & Pick, + howToMerge: Parameters[1] & { protectPrivateKey?: boolean }, + t: PersonasTransaction<'readwrite'>, +) { + const personaInDB = await t.objectStore('personas').get(record.identifier.toText()) + + if (howToMerge.protectPrivateKey && !!personaInDB?.privateKey && !record.privateKey) return + + if (howToMerge.protectPrivateKey && !!personaInDB?.privateKey) { + const nextRecord = personaRecordOutDB(personaInDB) + nextRecord.hasLogout = false + + return updatePersonaDB(nextRecord, howToMerge, t) + } + + if (personaInDB) return updatePersonaDB(record, howToMerge, t) + else + return createPersonaDB( + { + ...record, + createdAt: record.createdAt ?? new Date(), + updatedAt: record.updatedAt ?? new Date(), + linkedProfiles: new IdentifierMap(new Map()), + }, + t, + ) +} + +/** + * Delete a Persona + */ +export async function deletePersonaDB( + id: PersonaIdentifier, + confirm: 'delete even with private' | "don't delete if have private key", + t: PersonasTransaction<'readwrite'>, +): Promise { + const r = await t.objectStore('personas').get(id.toText()) + if (!r) return + if (confirm !== 'delete even with private' && r.privateKey) + throw new TypeError('Cannot delete a persona with a private key') + await t.objectStore('personas').delete(id.toText()) + r.privateKey && MaskMessages.events.ownPersonaChanged.sendToAll() +} +/** + * Delete a Persona + * @returns a boolean. true: the record no longer exists; false: the record is kept. + */ +export async function safeDeletePersonaDB( + id: PersonaIdentifier, + t?: FullPersonaDBTransaction<'readwrite'>, +): Promise { + t = t || createTransaction(await db(), 'readwrite')('personas', 'profiles', 'relations') + const r = await queryPersonaDB(id, t) + if (!r) return true + if (r.linkedProfiles.size !== 0) return false + if (r.privateKey) return false + await deletePersonaDB(id, "don't delete if have private key", t) + return true +} + +/** + * Create a new profile. + */ +export async function createProfileDB(record: ProfileRecord, t: ProfileTransaction<'readwrite'>): Promise { + await t.objectStore('profiles').add(profileToDB(record)) + MaskMessages.events.profilesChanged.sendToAll([{ of: record.identifier, reason: 'update' }]) +} + +/** + * Query a profile. + */ +export async function queryProfileDB( + id: ProfileIdentifier, + t?: ProfileTransaction<'readonly'>, +): Promise { + t = t || createTransaction(await db(), 'readonly')('profiles') + const result = await t.objectStore('profiles').get(id.toText()) + if (result) return profileOutDB(result) + return null +} + +/** + * Query many profiles. + */ +export async function queryProfilesDB( + query: { + network?: string + identifiers?: ProfileIdentifier[] + hasLinkedPersona?: boolean + }, + t?: ProfileTransaction<'readonly'>, +): Promise { + t = t || createTransaction(await db(), 'readonly')('profiles') + const result: ProfileRecord[] = [] + + if (isEmpty(query)) { + const results = await t.objectStore('profiles').getAll() + results.forEach((each) => { + const out = profileOutDB(each) + result.push(out) + }) + } + + if (query.network) { + const results = await t.objectStore('profiles').index('network').getAll(IDBKeyRange.only(query.network)) + + results.forEach((each) => { + const out = profileOutDB(each) + if (query.hasLinkedPersona && !out.linkedPersona) return + result.push(out) + }) + } else if (query.identifiers?.length) { + for await (const each of t.objectStore('profiles').iterate()) { + const out = profileOutDB(each.value) + if (query.hasLinkedPersona && !out.linkedPersona) continue + if (query.identifiers.some((x) => out.identifier.equals(x))) result.push(out) + } + } + return result +} + +const fuse = new Fuse([] as ProfileRecord[], { + shouldSort: true, + threshold: 0.45, + minMatchCharLength: 1, + keys: [ + { name: 'nickname', weight: 0.8 }, + { name: 'identifier.network', weight: 0.2 }, + ], +}) + +/** + * @deprecated + * @param options + * @param count + */ +export async function queryProfilesPagedDB( + options: { + after?: ProfileIdentifier + query?: string + }, + count: number, +): Promise { + const t = createTransaction(await db(), 'readonly')('profiles') + const breakPoint = options.after?.toText() + let firstRecord = true + const data: ProfileRecord[] = [] + for await (const rec of t.objectStore('profiles').iterate()) { + if (firstRecord && breakPoint && rec.key !== breakPoint) { + rec.continue(breakPoint) + firstRecord = false + continue + } + firstRecord = false + // after this record + if (rec.key === breakPoint) continue + if (count <= 0) break + const outData = profileOutDB(rec.value) + if (typeof options.query === 'string') { + fuse.setCollection([outData]) + if (!fuse.search(options.query).length) continue + } + count -= 1 + data.push(outData) + } + return data +} + +/** + * Update a profile. + */ +export async function updateProfileDB( + updating: Partial & Pick, + t: ProfileTransaction<'readwrite'>, +): Promise { + const old = await t.objectStore('profiles').get(updating.identifier.toText()) + if (!old) throw new Error('Updating a non exists record') + + const nextRecord: ProfileRecordDB = profileToDB({ + ...profileOutDB(old), + ...updating, + }) + await t.objectStore('profiles').put(nextRecord) + MaskMessages.events.profilesChanged.sendToAll([{ reason: 'update', of: updating.identifier }]) +} +export async function createOrUpdateProfileDB(rec: ProfileRecord, t: ProfileTransaction<'readwrite'>) { + if (await queryProfileDB(rec.identifier, t)) return updateProfileDB(rec, t) + else return createProfileDB(rec, t) +} + +/** + * Detach a profile from it's linking persona. + * @param identifier The profile want to detach + * @param t A living transaction + */ +export async function detachProfileDB( + identifier: ProfileIdentifier, + t?: FullPersonaDBTransaction<'readwrite'>, +): Promise { + t = t || createTransaction(await db(), 'readwrite')('personas', 'profiles', 'relations') + const profile = await queryProfileDB(identifier, t) + if (!profile?.linkedPersona) return + + const linkedPersona = profile.linkedPersona + const persona = await queryPersonaDB(linkedPersona, t) + persona?.linkedProfiles.delete(identifier) + + if (persona) { + await updatePersonaDB(persona, { linkedProfiles: 'replace', explicitUndefinedField: 'delete field' }, t) + if (persona.privateKey) MaskMessages.events.ownPersonaChanged.sendToAll(undefined) + } + profile.linkedPersona = undefined + await updateProfileDB(profile, t) +} + +/** + * attach a profile. + */ +export async function attachProfileDB( + identifier: ProfileIdentifier, + attachTo: PersonaIdentifier, + data: LinkedProfileDetails, + t?: FullPersonaDBTransaction<'readwrite'>, +): Promise { + t = t || createTransaction(await db(), 'readwrite')('personas', 'profiles', 'relations') + const profile = + (await queryProfileDB(identifier, t)) || + (await createProfileDB({ identifier, createdAt: new Date(), updatedAt: new Date() }, t)) || + (await queryProfileDB(identifier, t)) + const persona = await queryPersonaDB(attachTo, t) + if (!persona || !profile) return + + if (profile.linkedPersona !== undefined && !profile.linkedPersona.equals(attachTo)) { + await detachProfileDB(identifier, t) + } + + profile.linkedPersona = attachTo + persona.linkedProfiles.set(identifier, data) + + await updatePersonaDB(persona, { linkedProfiles: 'merge', explicitUndefinedField: 'ignore' }, t) + await updateProfileDB(profile, t) + + if (persona.privateKey) MaskMessages.events.ownPersonaChanged.sendToAll(undefined) +} + +/** + * Delete a profile + */ +export async function deleteProfileDB(id: ProfileIdentifier, t: ProfileTransaction<'readwrite'>): Promise { + await t.objectStore('profiles').delete(id.toText()) + MaskMessages.events.profilesChanged.sendToAll([{ reason: 'delete', of: id }]) +} + +/** + * Create a new Relation + */ +export async function createRelationDB( + record: Omit, + t: RelationTransaction<'readwrite'>, + silent = false, +): Promise { + await t.objectStore('relations').add(relationRecordToDB(record)) + if (!silent) + MaskMessages.events.relationsChanged.sendToAll([{ of: record.profile, reason: 'update', favor: record.favor }]) +} + +export async function queryRelations(query: (record: RelationRecord) => boolean, t?: RelationTransaction<'readonly'>) { + t = t || createTransaction(await db(), 'readonly')('relations') + const records: RelationRecord[] = [] + + for await (const each of t.objectStore('relations')) { + const out = relationRecordOutDB(each.value) + if (query(out)) records.push(out) + } + + return records +} + +/** + * Query relations by paged + */ +export async function queryRelationsPagedDB( + linked: PersonaIdentifier, + options: { + network: string + after?: RelationRecord + pageOffset?: number + }, + count: number, +) { + const t = createTransaction(await db(), 'readonly')('relations') + let firstRecord = true + + const data: RelationRecord[] = [] + + for await (const cursor of t.objectStore('relations').index('favor, profile, linked').iterate()) { + if (cursor.value.linked !== linked.toText()) continue + if (cursor.value.network !== options.network) continue + + if (firstRecord && options.after && options.after.profile.toText() !== cursor?.value.profile) { + cursor.continue([options.after.favor, options.after.profile.toText(), options.after.linked.toText()]) + firstRecord = false + continue + } + + firstRecord = false + + // after this record + if ( + options.after?.linked.toText() === cursor?.value.linked && + options.after?.profile.toText() === cursor?.value.profile + ) + continue + + if (count <= 0) break + const outData = relationRecordOutDB(cursor.value) + count -= 1 + data.push(outData) + } + return data +} + +/** + * Update a relation + * @param updating + * @param t + * @param silent + */ +export async function updateRelationDB( + updating: Omit, + t: RelationTransaction<'readwrite'>, + silent = false, +): Promise { + const old = await t + .objectStore('relations') + .get(IDBKeyRange.only([updating.linked.toText(), updating.profile.toText()])) + + if (!old) throw new Error('Updating a non exists record') + + const nextRecord: RelationRecordDB = relationRecordToDB({ + ...relationRecordOutDB(old), + ...updating, + }) + + await t.objectStore('relations').put(nextRecord) + if (!silent) { + MaskMessages.events.relationsChanged.sendToAll([ + { of: updating.profile, favor: updating.favor, reason: 'update' }, + ]) + } +} + +// #endregion + +// #region out db & to db +function profileToDB(x: ProfileRecord): ProfileRecordDB { + return { + ...x, + identifier: x.identifier.toText(), + network: x.identifier.network, + } +} +function profileOutDB({ network, ...x }: ProfileRecordDB): ProfileRecord { + if (x.linkedPersona) { + if (x.linkedPersona.type !== 'ec_key') throw new Error('Unknown type of linkedPersona') + } + return { + ...x, + identifier: Identifier.fromString(x.identifier, ProfileIdentifier).unwrap(), + linkedPersona: restorePrototype(x.linkedPersona, ECKeyIdentifier.prototype), + } +} +function personaRecordToDB(x: PersonaRecord): PersonaRecordDB { + return { + ...x, + identifier: x.identifier.toText(), + hasPrivateKey: x.privateKey ? 'yes' : 'no', + linkedProfiles: x.linkedProfiles.__raw_map__, + } +} +function personaRecordOutDB(x: PersonaRecordDB): PersonaRecord { + // @ts-ignore + delete x.hasPrivateKey + const obj: PersonaRecord = { + ...x, + identifier: Identifier.fromString(x.identifier, ECKeyIdentifier).unwrap(), + linkedProfiles: new IdentifierMap(x.linkedProfiles, ProfileIdentifier), + } + return obj +} + +function relationRecordToDB(x: Omit): RelationRecordDB { + return { + ...x, + network: x.profile.network, + profile: x.profile.toText(), + linked: x.linked.toText(), + } +} + +function relationRecordOutDB(x: RelationRecordDB): RelationRecord { + return { + ...x, + profile: Identifier.fromString(x.profile, ProfileIdentifier).unwrap(), + linked: Identifier.fromString(x.linked, ECKeyIdentifier).unwrap(), + } +} + +// #endregion diff --git a/packages/mask/src/utils/native-rpc/Android.channel.ts b/packages/mask/shared/native-rpc/Android.channel.ts similarity index 100% rename from packages/mask/src/utils/native-rpc/Android.channel.ts rename to packages/mask/shared/native-rpc/Android.channel.ts diff --git a/packages/mask/src/utils/native-rpc/iOS.channel.ts b/packages/mask/shared/native-rpc/iOS.channel.ts similarity index 100% rename from packages/mask/src/utils/native-rpc/iOS.channel.ts rename to packages/mask/shared/native-rpc/iOS.channel.ts diff --git a/packages/mask/src/utils/native-rpc/index.ts b/packages/mask/shared/native-rpc/index.ts similarity index 62% rename from packages/mask/src/utils/native-rpc/index.ts rename to packages/mask/shared/native-rpc/index.ts index bb7f349ca9b6..49ea2ed8dac4 100644 --- a/packages/mask/src/utils/native-rpc/index.ts +++ b/packages/mask/shared/native-rpc/index.ts @@ -1,7 +1,6 @@ import { AsyncCall, AsyncCallOptions, _AsyncVersionOf } from 'async-call-rpc/full' import { AndroidGeckoViewChannel } from './Android.channel' import { iOSWebkitChannel } from './iOS.channel' -import { MaskNetworkAPI } from './Web' import type { AndroidNativeAPIs, iOSNativeAPIs } from '@masknet/public-api' // This module won't be used in Web. Let it not effecting HMR. @@ -19,16 +18,30 @@ if (process.env.architecture === 'app') { parameterStructures: 'by-name', } if (process.env.engine === 'safari') { - const api = (sharedNativeAPI = AsyncCall(MaskNetworkAPI, { - ...options, - channel: new iOSWebkitChannel(), - })) + const api = (sharedNativeAPI = AsyncCall( + /** + * Typescript will not add the file to the project dependency tree + * but webpack will do constant folding + */ + // @ts-ignore + // eslint-disable-next-line no-useless-concat + import('../../src/utils/native-rpc/' + 'Web.ts').then((x) => x.MaskNetworkAPI), + { + ...options, + channel: new iOSWebkitChannel(), + }, + )) nativeAPI = { type: 'iOS', api } } else if (process.env.engine === 'firefox') { - const api = (sharedNativeAPI = AsyncCall(MaskNetworkAPI, { - ...options, - channel: new AndroidGeckoViewChannel(), - })) + const api = (sharedNativeAPI = AsyncCall( + // @ts-ignore + // eslint-disable-next-line no-useless-concat + import('../../src/utils/native-rpc/' + 'Web.ts').then((x) => x.MaskNetworkAPI), + { + ...options, + channel: new AndroidGeckoViewChannel(), + }, + )) nativeAPI = { type: 'Android', api } } } diff --git a/packages/mask/src/background-service.ts b/packages/mask/src/background-service.ts index 550db7e23a56..a43d24e207ea 100644 --- a/packages/mask/src/background-service.ts +++ b/packages/mask/src/background-service.ts @@ -1,6 +1,6 @@ import '../background/setup' import './extension/service' // setup Services.* -import './utils/native-rpc' // setup Android and iOS API server +import '../shared/native-rpc' // setup Android and iOS API server import './social-network-adaptor' // setup social network providers import './extension/background-script/Jobs' // start jobs import './utils/debug/general' diff --git a/packages/mask/src/components/InjectedComponents/ToolboxUnstyled.tsx b/packages/mask/src/components/InjectedComponents/ToolboxUnstyled.tsx index 1d6f5681bc5e..44429dc29879 100644 --- a/packages/mask/src/components/InjectedComponents/ToolboxUnstyled.tsx +++ b/packages/mask/src/components/InjectedComponents/ToolboxUnstyled.tsx @@ -25,7 +25,8 @@ import { import { useCallback, useMemo } from 'react' import { useRemoteControlledDialog, WalletIcon } from '@masknet/shared' import { WalletMessages } from '../../plugins/Wallet/messages' -import { hasNativeAPI, nativeAPI, useI18N } from '../../utils' +import { useI18N } from '../../utils' +import { hasNativeAPI, nativeAPI } from '../../../shared/native-rpc' import { useRecentTransactions } from '../../plugins/Wallet/hooks/useRecentTransactions' import GuideStep from '../GuideStep' import { MaskFilledIcon } from '../../resources/MaskIcon' diff --git a/packages/mask/src/components/Welcomes/Banner.tsx b/packages/mask/src/components/Welcomes/Banner.tsx index d1b97f52067c..f12b227f2939 100644 --- a/packages/mask/src/components/Welcomes/Banner.tsx +++ b/packages/mask/src/components/Welcomes/Banner.tsx @@ -7,8 +7,8 @@ import { activatedSocialNetworkUI } from '../../social-network' import { DashboardRoutes } from '@masknet/shared-base' import { MaskSharpIcon } from '../../resources/MaskIcon' import { useMount } from 'react-use' -import { hasNativeAPI, nativeAPI } from '../../utils' import { usePersonaConnectStatus } from '../DataSource/usePersonaConnectStatus' +import { hasNativeAPI, nativeAPI } from '../../../shared/native-rpc' interface BannerUIProps extends withClasses { description?: string diff --git a/packages/mask/src/database/Persona/helpers.ts b/packages/mask/src/database/Persona/helpers.ts index b6a6565ca503..cca56b4c76b7 100644 --- a/packages/mask/src/database/Persona/helpers.ts +++ b/packages/mask/src/database/Persona/helpers.ts @@ -102,8 +102,8 @@ export async function queryPersona(identifier: PersonaIdentifier): Promise[0]): Promise { - const _ = await queryProfilesDB(query || ((_) => true)) +export async function queryProfilesWithQuery(query: Parameters[0]): Promise { + const _ = await queryProfilesDB(query) return Promise.all(_.map(profileRecordToProfile)) } @@ -111,7 +111,7 @@ export async function queryProfilesWithQuery(query?: Parameters[0]): Promise { - const _ = await queryPersonasDB(query || ((_) => true)) + const _ = await queryPersonasDB(query) return _.map(personaRecordToPersona) } @@ -148,7 +148,7 @@ export async function logoutPersona(identifier: PersonaIdentifier) { } export async function renamePersona(identifier: PersonaIdentifier, nickname: string) { - const personas = await queryPersonasWithQuery(({ nickname: name }) => name === nickname) + const personas = await queryPersonasWithQuery({ nameContains: nickname }) if (personas.length > 0) { throw new Error('Nickname already exists') } @@ -210,7 +210,7 @@ export async function createPersonaByMnemonic( } export async function createPersonaByMnemonicV2(mnemonicWord: string, nickname: string | undefined, password: string) { - const personas = await queryPersonasWithQuery(({ nickname: name }) => name === nickname) + const personas = await queryPersonasWithQuery({ nameContains: nickname }) if (personas.length > 0) throw new Error('Nickname already exists') const verify = validateMnemonic(mnemonicWord) diff --git a/packages/mask/src/extension/background-script/CryptoServices/debugShowAllPossibleHashForPost.ts b/packages/mask/src/extension/background-script/CryptoServices/debugShowAllPossibleHashForPost.ts index 1684afe3a79c..93fdb4b2c5c3 100644 --- a/packages/mask/src/extension/background-script/CryptoServices/debugShowAllPossibleHashForPost.ts +++ b/packages/mask/src/extension/background-script/CryptoServices/debugShowAllPossibleHashForPost.ts @@ -4,7 +4,7 @@ import { queryProfilesWithQuery, queryPublicKey } from '../../../database' import { getNetworkWorkerUninitialized } from '../../../social-network/worker' export async function debugShowAllPossibleHashForPost(post: PostIVIdentifier, payloadVersion: -38 | -39 | -40) { - const friends = await queryProfilesWithQuery((x) => x.identifier.network === post.network) + const friends = await queryProfilesWithQuery({ network: post.network }) const gunHint = getNetworkWorkerUninitialized(post.network)?.gunNetworkHint return Promise.all( friends diff --git a/packages/mask/src/extension/background-script/EthereumServices/request.ts b/packages/mask/src/extension/background-script/EthereumServices/request.ts index 397928066fe6..377db87ebaf0 100644 --- a/packages/mask/src/extension/background-script/EthereumServices/request.ts +++ b/packages/mask/src/extension/background-script/EthereumServices/request.ts @@ -15,7 +15,7 @@ import { import { WalletRPC } from '../../../plugins/Wallet/messages' import { INTERNAL_nativeSend, INTERNAL_send } from './send' import { defer } from '@masknet/shared-base' -import { hasNativeAPI, nativeAPI } from '../../../utils/native-rpc' +import { hasNativeAPI, nativeAPI } from '../../../../shared/native-rpc' import { openPopupWindow } from '../HelperService' import Services from '../../service' diff --git a/packages/mask/src/extension/background-script/EthereumServices/send.ts b/packages/mask/src/extension/background-script/EthereumServices/send.ts index 7b5ceecfcbe4..8c0b228bbc9d 100644 --- a/packages/mask/src/extension/background-script/EthereumServices/send.ts +++ b/packages/mask/src/extension/background-script/EthereumServices/send.ts @@ -36,7 +36,7 @@ import { } from '../../../plugins/Wallet/settings' import { debugModeSetting } from '../../../settings/settings' import { Flags } from '../../../../shared' -import { nativeAPI } from '../../../utils/native-rpc' +import { nativeAPI } from '../../../../shared/native-rpc' import { WalletRPC } from '../../../plugins/Wallet/messages' import { getSendTransactionComputedPayload } from './rpc' import { getError, hasError } from './error' diff --git a/packages/mask/src/extension/background-script/IdentityService.ts b/packages/mask/src/extension/background-script/IdentityService.ts index 6bbbd595a39d..928a83c61f0e 100644 --- a/packages/mask/src/extension/background-script/IdentityService.ts +++ b/packages/mask/src/extension/background-script/IdentityService.ts @@ -36,15 +36,20 @@ import { createRelationDB, createRelationsTransaction, deleteProfileDB, - LinkedProfileDetails, - ProfileRecord, queryPersonaDB, queryPersonasDB, queryProfilesDB, queryRelationsPagedDB, - RelationRecord, updateRelationDB, + ProfileRecord, + LinkedProfileDetails, + RelationRecord, } from '../../../background/database/persona/db' +import { + queryPersonasDB as queryPersonasFromIndexedDB, + queryProfilesDB as queryProfilesFromIndexedDB, + queryRelations as queryRelationsFromIndexedDB, +} from '../../../background/database/persona/web' import { BackupJSONFileLatest, UpgradeBackupJSONFile } from '../../utils/type-transform/BackupFormat/JSON/latest' import { restoreBackup } from './WelcomeServices/restoreBackup' import { restoreNewIdentityWithMnemonicWord } from './WelcomeService' @@ -65,11 +70,15 @@ export { validateMnemonic } from '../../utils/mnemonic-code' export { queryProfile, queryProfilePaged } from '../../database' export function queryProfiles(network?: string): Promise { - return queryProfilesWithQuery(network) + return queryProfilesWithQuery({ network }) +} + +export async function queryProfileRecordFromIndexedDB() { + return queryProfilesFromIndexedDB({}) } export function queryProfilesWithIdentifiers(identifiers: ProfileIdentifier[]) { - return queryProfilesWithQuery((record) => identifiers.some((x) => record.identifier.equals(x))) + return queryProfilesWithQuery({ identifiers }) } export async function queryMyProfiles(network?: string): Promise { const myPersonas = (await queryMyPersonas(network)).filter((x) => !x.uninitialized) @@ -130,12 +139,16 @@ export async function queryPersonaByMnemonic(mnemonic: string, password: '') { } export async function queryPersonas(identifier?: PersonaIdentifier, requirePrivateKey = false): Promise { if (typeof identifier === 'undefined') - return (await queryPersonasDB((k) => (requirePrivateKey ? !!k.privateKey : true))).map(personaRecordToPersona) + return (await queryPersonasDB({ hasPrivateKey: requirePrivateKey })).map(personaRecordToPersona) const x = await queryPersonaDB(identifier) if (!x || (!x.privateKey && requirePrivateKey)) return [] return [personaRecordToPersona(x)] } +export async function queryPersonaRecordsFromIndexedDB() { + return queryPersonasFromIndexedDB() +} + export function queryMyPersonas(network?: string): Promise { return queryPersonas(undefined, true).then((x) => typeof network === 'string' @@ -304,10 +317,15 @@ export async function createNewRelation( await createRelationDB({ profile, linked, favor }, t) } +export async function queryRelationsRecordFromIndexedDB(): Promise { + return queryRelationsFromIndexedDB(() => true) +} + export async function queryRelationPaged( options: { network: string after?: RelationRecord + pageOffset?: number }, count: number, ): Promise { @@ -332,7 +350,7 @@ export async function resolveIdentity(identifier: ProfileIdentifier): Promise x.identifier.equals(unknown) || x.identifier.equals(self)) + const r = await queryProfilesDB({ identifiers: [unknown, self] }) if (!r.length) return const final = { ...r.reduce((p, c) => ({ ...p, ...c })), diff --git a/packages/mask/src/extension/background-script/WelcomeServices/generateBackupJSON.ts b/packages/mask/src/extension/background-script/WelcomeServices/generateBackupJSON.ts index a36f91430a81..95c1dc98a789 100644 --- a/packages/mask/src/extension/background-script/WelcomeServices/generateBackupJSON.ts +++ b/packages/mask/src/extension/background-script/WelcomeServices/generateBackupJSON.ts @@ -84,11 +84,9 @@ export async function generateBackupJSON(opts: Partial = {}): Pro async function backProfiles(of?: ProfileIdentifier[]) { const data = ( - await queryProfilesDB((p) => { - if (of === undefined) return true - if (!of.some((x) => x.equals(p.identifier))) return false - if (!p.linkedPersona) return false - return true + await queryProfilesDB({ + identifiers: of, + hasLinkedPersona: true, }) ).map(ProfileRecordToJSONFormat) profiles.push(...data) @@ -97,12 +95,10 @@ export async function generateBackupJSON(opts: Partial = {}): Pro async function backupPersonas(of?: PersonaIdentifier[]) { const data = ( await queryPersonasDB( - (p) => { - if (p.uninitialized) return false - if (opts.hasPrivateKeyOnly && !p.privateKey) return false - if (of === undefined) return true - if (!of.some((x) => x.equals(p.identifier))) return false - return true + { + initialized: true, + hasPrivateKey: opts.hasPrivateKeyOnly, + identifiers: of, }, undefined, true, diff --git a/packages/mask/src/plugins/Wallet/SNSAdaptor/SelectProviderDialog/index.tsx b/packages/mask/src/plugins/Wallet/SNSAdaptor/SelectProviderDialog/index.tsx index bf4e894256ef..c7a4af5841bd 100644 --- a/packages/mask/src/plugins/Wallet/SNSAdaptor/SelectProviderDialog/index.tsx +++ b/packages/mask/src/plugins/Wallet/SNSAdaptor/SelectProviderDialog/index.tsx @@ -14,7 +14,7 @@ import { isDashboardPage } from '@masknet/shared-base' import { useI18N } from '../../../../utils/i18n-next-ui' import { WalletMessages } from '../../messages' import { InjectedDialog } from '../../../../components/shared/InjectedDialog' -import { hasNativeAPI, nativeAPI } from '../../../../utils' +import { hasNativeAPI, nativeAPI } from '../../../../../shared/native-rpc' import { PluginProviderRender } from './PluginProviderRender' import { pluginIDSettings } from '../../../../settings/settings' diff --git a/packages/mask/src/plugins/Wallet/services/account.ts b/packages/mask/src/plugins/Wallet/services/account.ts index fd926daa1f66..2b3759fc6beb 100644 --- a/packages/mask/src/plugins/Wallet/services/account.ts +++ b/packages/mask/src/plugins/Wallet/services/account.ts @@ -17,7 +17,7 @@ import { currentProviderSettings, } from '../settings' import { getWallets, hasWallet, updateWallet } from './wallet' -import { hasNativeAPI, nativeAPI } from '../../../utils' +import { hasNativeAPI, nativeAPI } from '../../../../shared/native-rpc' import { Flags } from '../../../../shared' export async function updateAccount( diff --git a/packages/mask/src/plugins/Wallet/services/legacyWallet.ts b/packages/mask/src/plugins/Wallet/services/legacyWallet.ts index fc1b1637e4de..accc5f0b8911 100644 --- a/packages/mask/src/plugins/Wallet/services/legacyWallet.ts +++ b/packages/mask/src/plugins/Wallet/services/legacyWallet.ts @@ -11,7 +11,7 @@ import { currySameAddress, isSameAddress, ProviderType } from '@masknet/web3-sha import { LegacyWalletRecordOutDB } from './helpers' import { currentAccountSettings, currentProviderSettings } from '../settings' import { HD_PATH_WITHOUT_INDEX_ETHEREUM } from '../constants' -import { hasNativeAPI } from '../../../utils/native-rpc' +import { hasNativeAPI } from '../../../../shared/native-rpc' import { getAccounts } from '../../../extension/background-script/EthereumService' function sortWallet(a: LegacyWalletRecord, b: LegacyWalletRecord) { diff --git a/packages/mask/src/plugins/Wallet/services/wallet/index.ts b/packages/mask/src/plugins/Wallet/services/wallet/index.ts index 5a3a381e5b01..29ef45a18c76 100644 --- a/packages/mask/src/plugins/Wallet/services/wallet/index.ts +++ b/packages/mask/src/plugins/Wallet/services/wallet/index.ts @@ -10,7 +10,7 @@ import * as sdk from './maskwallet' import * as database from './database' import * as password from './password' import * as EthereumServices from '../../../../extension/background-script/EthereumService' -import { hasNativeAPI } from '../../../../utils' +import { hasNativeAPI } from '../../../../../shared/native-rpc' import type { WalletRecord } from './type' function bumpDerivationPath(path = `${HD_PATH_WITHOUT_INDEX_ETHEREUM}/0`) { diff --git a/packages/mask/src/utils/index.ts b/packages/mask/src/utils/index.ts index c7f0facb60e6..8336323e4d8a 100644 --- a/packages/mask/src/utils/index.ts +++ b/packages/mask/src/utils/index.ts @@ -1,7 +1,6 @@ export * from './components/' export * from './debug' export * from './hooks' -export * from './native-rpc' export * from './shadow-root' export * from './suspends' export * from './type-transform' diff --git a/packages/mask/src/utils/native-rpc/Web.ts b/packages/mask/src/utils/native-rpc/Web.ts index 4569d34daabf..f329f51c3ccc 100644 --- a/packages/mask/src/utils/native-rpc/Web.ts +++ b/packages/mask/src/utils/native-rpc/Web.ts @@ -12,6 +12,11 @@ import { WalletRPC } from '../../plugins/Wallet/messages' import { ProviderType } from '@masknet/web3-shared-evm' import { MaskMessages } from '../messages' import type { PersonaInformation } from '@masknet/shared-base' +import type { + EC_Private_JsonWebKey as Native_EC_Private_JsonWebKey, + EC_Public_JsonWebKey as Native_EC_Public_JsonWebKey, + AESJsonWebKey as Native_AESJsonWebKey, +} from '@masknet/public-api' const stringToPersonaIdentifier = (str: string) => Identifier.fromString(str, ECKeyIdentifier).unwrap() const stringToProfileIdentifier = (str: string) => Identifier.fromString(str, ProfileIdentifier).unwrap() @@ -344,6 +349,40 @@ export const MaskNetworkAPI: MaskNetworkAPIs = { const { activatedSocialNetworkUI } = await import('../../social-network') return activatedSocialNetworkUI.collecting.identityProvider?.recognized.value.identifier.toText() }, + get_all_indexedDB_records: async () => { + const personas = await Services.Identity.queryPersonaRecordsFromIndexedDB() + const profiles = await Services.Identity.queryProfileRecordFromIndexedDB() + const relations = await Services.Identity.queryRelationsRecordFromIndexedDB() + return { + personas: personas.map((x) => ({ + mnemonic: x.mnemonic, + nickname: x.nickname, + publicKey: x.publicKey as JsonWebKey as unknown as Native_EC_Public_JsonWebKey, + privateKey: x.privateKey as JsonWebKey as unknown as Native_EC_Private_JsonWebKey, + localKey: x.localKey as JsonWebKey as unknown as Native_AESJsonWebKey, + identifier: x.identifier.toText(), + linkedProfiles: Object.fromEntries(x.linkedProfiles.__raw_map__), + createdAt: x.createdAt.getTime(), + updatedAt: x.createdAt.getTime(), + hasLogout: x.hasLogout, + uninitialized: x.uninitialized, + })), + profiles: profiles.map((x) => ({ + identifier: x.identifier.toText(), + nickname: x.nickname, + localKey: x.localKey as JsonWebKey as unknown as Native_AESJsonWebKey, + linkedPersona: x.linkedPersona?.toText(), + createdAt: x.createdAt.getTime(), + updatedAt: x.updatedAt.getTime(), + })), + relations: relations.map((x) => ({ + profile: x.profile.toText(), + linked: x.linked.toText(), + network: x.network, + favor: x.favor, + })), + } + }, } function wrapWithAssert(env: Environment, f: Function) { diff --git a/packages/public-api/src/index.ts b/packages/public-api/src/index.ts index e624ef74cd42..54f2acdad9ff 100644 --- a/packages/public-api/src/index.ts +++ b/packages/public-api/src/index.ts @@ -2,3 +2,4 @@ export * from './web' // Following is the API that implemented by the native side. export * from './mobile' +export * from './types' diff --git a/packages/public-api/src/mobile.ts b/packages/public-api/src/mobile.ts index cc0288ffadc6..c9751d0b2778 100644 --- a/packages/public-api/src/mobile.ts +++ b/packages/public-api/src/mobile.ts @@ -1,4 +1,12 @@ -import type { JsonRpcPayload, JsonRpcResponse } from './types' +import type { + JsonRpcPayload, + JsonRpcResponse, + PersonaRecord, + PageOption, + ProfileRecord, + LinkedProfileDetails, + RelationRecord, +} from './types' /** * APIs that both Android and iOS implements and have the same API signature @@ -11,6 +19,87 @@ export interface SharedNativeAPIs { wallet_switchBlockChain(payload: { coinId?: number; networkId: number }): Promise misc_openCreateWalletView(): Promise misc_openDashboardView(): Promise + /** + * DB JSON RPC + */ + create_persona(params: { persona: PersonaRecord }): Promise + query_persona(params: { + identifier?: string + hasPrivateKey?: boolean + includeLogout?: boolean + nameContains?: string + initialized?: boolean + pageOption?: PageOption + }): Promise + query_personas(params: { + identifier?: string + hasPrivateKey?: boolean + includeLogout?: boolean + nameContains?: string + initialized?: boolean + pageOption?: PageOption + }): Promise + query_persona_by_profile(params: { + options?: { + profileIdentifier: string + hasPrivateKey?: boolean + includeLogout?: boolean + nameContains?: string + initialized?: boolean + pageOption?: PageOption + } + }): Promise + update_persona(params: { + persona: Partial + options?: { + linkedProfileMergePolicy?: 0 | 1 + deleteUndefinedFields?: boolean + protectPrivateKey?: boolean + createWhenNotExist?: boolean + } + }): Promise + delete_persona(params: { identifier: string; options?: { safeDelete?: boolean } }): Promise + create_profile(params: { profile: ProfileRecord }): Promise + query_profile(params: { + options?: { + identifier: string + network?: string + nameContains?: string + pageOption?: PageOption + } + }): Promise + query_profiles(params: { + identifiers?: string[] + network?: string + nameContains?: string + hasLinkedPersona?: boolean + pageOption?: PageOption + }): Promise + update_profile(params: { + profile: Partial + options?: { + createWhenNotExist?: boolean + } + }): Promise + delete_profile(params: { identifier: string }): Promise + attach_profile(params: { + profileIdentifier: string + personaIdentifier: string + state: LinkedProfileDetails + }): Promise + detach_profile(params: { identifier: string }): Promise + create_relation(params: { relation: Omit }): Promise + query_relations(params: { + options?: { + personaIdentifier?: string + network?: string + nameContains?: string + favor?: string + pageOption?: PageOption + } + }): Promise + update_relation(params: { relation: Omit }): Promise + delete_relation(params: { personaIdentifier: string; profileIdentifier: string }): Promise } /** * APIs that only implemented by iOS Mask Network diff --git a/packages/public-api/src/types/index.ts b/packages/public-api/src/types/index.ts index ad439d7d6fb6..4210601ae269 100644 --- a/packages/public-api/src/types/index.ts +++ b/packages/public-api/src/types/index.ts @@ -11,3 +11,61 @@ export interface JsonRpcResponse { result?: any error?: string } + +export interface LinkedProfileDetails { + connectionConfirmState: 'confirmed' | 'pending' | 'denied' +} + +// These type MUST be sync with packages/shared-base/src/crypto/JWKType +export interface EC_Public_JsonWebKey extends JsonWebKey, Nominal<'EC public'> {} +export interface EC_Private_JsonWebKey extends JsonWebKey, Nominal<'EC private'> {} +export interface AESJsonWebKey extends JsonWebKey, Nominal<'AES'> {} + +declare class Nominal { + /** Ghost property, don't use it! */ + private __brand: T +} + +export interface PersonaRecord { + identifier: string + mnemonic?: { + words: string + parameter: { path: string; withPassword: boolean } + } + publicKey: EC_Public_JsonWebKey + privateKey?: EC_Private_JsonWebKey + localKey?: AESJsonWebKey + nickname?: string + linkedProfiles: Record + createdAt: number + updatedAt: number + hasLogout?: boolean + uninitialized?: boolean +} + +export interface ProfileRecord { + identifier: string + nickname?: string + localKey?: AESJsonWebKey + linkedPersona?: string + createdAt: number + updatedAt: number +} + +export enum RelationFavor { + COLLECTED = -1, + UNCOLLECTED = 1, + DEPRECATED = 0, +} + +export interface RelationRecord { + profile: string + linked: string + network: string + favor: RelationFavor +} + +export type PageOption = { + pageSize: number + pageOffset: number +} diff --git a/packages/public-api/src/web.ts b/packages/public-api/src/web.ts index 1193e0e88dd8..69626ee75597 100644 --- a/packages/public-api/src/web.ts +++ b/packages/public-api/src/web.ts @@ -1,4 +1,7 @@ +import type { PersonaRecord, ProfileRecord, RelationFavor, RelationRecord } from './types' + // This interface uses by-name style JSON RPC. + /** * Methods starts with "SNSAdaptor_" can only be called in SNS Adaptor. * Other methods can only be called in the background page. @@ -78,19 +81,11 @@ export interface MaskNetworkAPIs { wallet_updateEthereumChainId(params: { chainId: number }): Promise wallet_getLegacyWalletInfo(): Promise SNSAdaptor_getCurrentDetectedProfile(): Promise -} - -export interface RelationRecord { - profile: ProfileIdentifier_string - linked: PersonaIdentifier_string - network: string - favor: RelationFavor -} - -export enum RelationFavor { - COLLECTED = -1, - UNCOLLECTED = 1, - DEPRECATED = 0, + get_all_indexedDB_records(): Promise<{ + personas: PersonaRecord[] + profiles: ProfileRecord[] + relations: RelationRecord[] + }> } export interface WalletInfo {