diff --git a/packages/dashboard/src/pages/Personas/hooks/usePostHistory.ts b/packages/dashboard/src/pages/Personas/hooks/usePostHistory.ts index 799ae570f17f..13435fd44c3b 100644 --- a/packages/dashboard/src/pages/Personas/hooks/usePostHistory.ts +++ b/packages/dashboard/src/pages/Personas/hooks/usePostHistory.ts @@ -20,6 +20,7 @@ export const usePostHistory = (network: string, page: number, size = 20) => { network, userIds: currentPersona?.linkedProfiles.map((profile) => profile.identifier.userId) ?? [], after: lastValue?.identifier, + pageOffset: page, }, size, ) diff --git a/packages/mask/background/database/avatar-cache/avatar.ts b/packages/mask/background/database/avatar-cache/avatar.ts index c9d534f8b1f6..0886c0e4de37 100644 --- a/packages/mask/background/database/avatar-cache/avatar.ts +++ b/packages/mask/background/database/avatar-cache/avatar.ts @@ -1,22 +1,31 @@ import { ProfileIdentifier } from '@masknet/shared-base' import { queryAvatarDB, isAvatarOutdatedDB, storeAvatarDB, IdentifierWithAvatar, createAvatarDBAccess } from './db' import { MaskMessages } from '../../../shared' +import { hasNativeAPI, nativeAPI } from '../../../shared/native-rpc' import { blobToDataURL, memoizePromise } from '@dimensiondev/kit' import { createTransaction } from '../utils/openDB' /** - * Get a (cached) blob url for an identifier. + * Get a (cached) blob url for an identifier. No cache for native api. * ? Because of cross-origin restrictions, we cannot use blob url here. sad :( */ -export const queryAvatarDataURL = memoizePromise( - async function (identifier: IdentifierWithAvatar): Promise { - const t = createTransaction(await createAvatarDBAccess(), 'readonly')('avatars') - const buffer = await queryAvatarDB(t, identifier) - if (!buffer) throw new Error('Avatar not found') - return blobToDataURL(new Blob([buffer], { type: 'image/png' })) - }, - (id) => id.toText(), -) +export const queryAvatarDataURL = ( + hasNativeAPI + ? async function (identifier: IdentifierWithAvatar): Promise { + return nativeAPI?.api.query_avatar({ identifier: identifier.toText() }) + } + : memoizePromise( + async function (identifier: IdentifierWithAvatar): Promise { + const t = createTransaction(await createAvatarDBAccess(), 'readonly')('avatars') + const buffer = await queryAvatarDB(t, identifier) + if (!buffer) throw new Error('Avatar not found') + return blobToDataURL(new Blob([buffer], { type: 'image/png' })) + }, + (id) => id.toText(), + ) +) as ((identifier: IdentifierWithAvatar) => Promise) & { + cache?: Map +} /** * Store an avatar with a url for an identifier. @@ -28,6 +37,12 @@ export const queryAvatarDataURL = memoizePromise( export async function storeAvatar(identifier: IdentifierWithAvatar, avatar: ArrayBuffer | string): Promise { if (identifier instanceof ProfileIdentifier && identifier.isUnknown) return try { + if (hasNativeAPI) { + // ArrayBuffer is unreachable on Native side. + if (typeof avatar !== 'string') return + await nativeAPI?.api.store_avatar({ identifier: identifier.toText(), avatar: avatar as string }) + return + } if (typeof avatar === 'string') { if (avatar.startsWith('https') === false) return const isOutdated = await isAvatarOutdatedDB( @@ -51,7 +66,7 @@ export async function storeAvatar(identifier: IdentifierWithAvatar, avatar: Arra } catch (error) { console.error('[AvatarDB] Store avatar failed', error) } finally { - queryAvatarDataURL.cache.delete(identifier.toText()) + queryAvatarDataURL.cache?.delete(identifier.toText()) if (identifier instanceof ProfileIdentifier) { MaskMessages.events.profilesChanged.sendToAll([{ of: identifier, reason: 'update' }]) } diff --git a/packages/mask/background/database/post/app.ts b/packages/mask/background/database/post/app.ts new file mode 100644 index 000000000000..f23575109190 --- /dev/null +++ b/packages/mask/background/database/post/app.ts @@ -0,0 +1,138 @@ +import type { PostRecord as NativePostRecord } from '@masknet/public-api' +import type { + PostRecord, + PostReadWriteTransaction, + PostReadOnlyTransaction, + RecipientDetail, + RecipientReason, +} from './type' +import { + PostIVIdentifier, + Identifier, + AESJsonWebKey, + IdentifierMap, + PersonaIdentifier, + ProfileIdentifier, + ECKeyIdentifier, +} from '@masknet/shared-base' +import { nativeAPI } from '../../../shared/native-rpc' +import { unreachable } from '@dimensiondev/kit' + +export async function createPostDB(record: PostRecord, t?: PostReadWriteTransaction) { + await nativeAPI?.api.create_post({ post: postInNative(record) as NativePostRecord }) +} + +export async function updatePostDB( + updateRecord: Partial & Pick, + mode: 'append' | 'override', + t?: PostReadWriteTransaction, +): Promise { + await nativeAPI?.api.update_post({ + post: postInNative(updateRecord), + options: { + mode: mode === 'append' ? 0 : mode === 'override' ? 1 : 0, + }, + }) + return +} + +export async function queryPostDB(record: PostIVIdentifier, t?: PostReadOnlyTransaction): Promise { + const result = await nativeAPI?.api.query_post({ identifier: record.toText() }) + return result ? postOutNative(result) : null +} + +export async function queryPostPagedDB( + linked: PersonaIdentifier, + options: { + network: string + userIds: string[] + after?: PostIVIdentifier + pageOffset?: number + }, + count: number, +): Promise { + const results = await nativeAPI?.api.query_posts({ + userIds: options.userIds, + network: options.network, + encryptBy: linked.toText(), + pageOption: options.pageOffset + ? { + pageSize: count, + pageOffset: options.pageOffset, + } + : undefined, + }) + if (!results?.length) return [] + return results.map((r) => postOutNative(r)) +} + +function postInNative(record: Partial & Pick): Partial { + return { + postBy: record.postBy ? record.postBy.toText() : undefined, + identifier: record.identifier.toText(), + postCryptoKey: record.postCryptoKey, + recipients: + record.recipients === 'everyone' + ? Object.fromEntries(new Map()) + : record.recipients + ? Object.fromEntries( + Array.from(record.recipients).map(([identifier, detail]) => [ + identifier.toText(), + { + reason: Array.from(detail.reason).map(RecipientReasonToJSON), + }, + ]), + ) + : undefined, + foundAt: record.foundAt?.getTime(), + encryptBy: record.encryptBy?.toText(), + url: record.url, + summary: record.summary, + interestedMeta: record.interestedMeta, + } +} + +function postOutNative(record: NativePostRecord): PostRecord { + return { + postBy: Identifier.fromString(record.postBy).unwrap() as unknown as ProfileIdentifier, + identifier: Identifier.fromString(record.identifier).unwrap() as unknown as PostIVIdentifier, + postCryptoKey: record.postCryptoKey as unknown as AESJsonWebKey, + recipients: new IdentifierMap(new Map(Object.entries(record.recipients))) as unknown as IdentifierMap< + ProfileIdentifier, + RecipientDetail + >, + foundAt: new Date(record.foundAt), + encryptBy: record.encryptBy + ? (Identifier.fromString(record.encryptBy).unwrap() as unknown as ECKeyIdentifier) + : undefined, + url: record.url, + summary: record.summary, + interestedMeta: record.interestedMeta, + } +} + +// #region Not available on native. +export async function queryPostsDB( + query: string | ((data: PostRecord, id: PostIVIdentifier) => boolean), + t?: PostReadOnlyTransaction, +): Promise { + return [] +} + +export const PostDBAccess = () => undefined +// #endregion + +type RecipientReasonJSON = ( + | { type: 'auto-share' } + | { type: 'direct' } + | { type: 'group'; group: string /** GroupIdentifier */ } +) & { + at: number +} + +function RecipientReasonToJSON(y: RecipientReason): RecipientReasonJSON { + if (y.type === 'direct' || y.type === 'auto-share') + return { at: y.at.getTime(), type: y.type } as RecipientReasonJSON + else if (y.type === 'group') return { at: y.at.getTime(), group: y.group.toText(), type: y.type } + return unreachable(y) +} diff --git a/packages/mask/background/database/post/helper.ts b/packages/mask/background/database/post/helper.ts deleted file mode 100644 index c7bad98079cc..000000000000 --- a/packages/mask/background/database/post/helper.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { AESCryptoKey, PostIVIdentifier } from '@masknet/shared-base' -import { CryptoKeyToJsonWebKey } from '../../../utils-pure' -import { createTransaction } from '../utils/openDB' -import { PostDBAccess, queryPostDB, createPostDB, updatePostDB, PostRecord } from './index' - -export async function savePostKeyToDB( - id: PostIVIdentifier, - key: AESCryptoKey, - extraInfo: Omit, -): Promise { - const jwk = await CryptoKeyToJsonWebKey(key) - { - const t = createTransaction(await PostDBAccess(), 'readwrite')('post') - const post = await queryPostDB(id, t) - if (!post) { - await createPostDB( - { - identifier: id, - postCryptoKey: jwk, - foundAt: new Date(), - ...extraInfo, - }, - t, - ) - } else { - await updatePostDB({ ...post, postCryptoKey: jwk }, 'override', t) - } - } -} diff --git a/packages/mask/background/database/post/index.ts b/packages/mask/background/database/post/index.ts index 988f6f1fc1fa..6a38a836b041 100644 --- a/packages/mask/background/database/post/index.ts +++ b/packages/mask/background/database/post/index.ts @@ -1,403 +1,17 @@ -import { - AESCryptoKey, - AESJsonWebKey, - ECKeyIdentifier, - GroupIdentifier, - Identifier, - IdentifierMap, - PersonaIdentifier, - PostIdentifier, - PostIVIdentifier, - ProfileIdentifier, -} from '@masknet/shared-base' -import { DBSchema, openDB } from 'idb/with-async-ittr' -import { CryptoKeyToJsonWebKey, PrototypeLess, restorePrototype, restorePrototypeArray } from '../../../utils-pure' -import { createDBAccessWithAsyncUpgrade, createTransaction, IDBPSafeTransaction } from '../utils/openDB' +export * from './type' +import { hasNativeAPI } from '../../../shared/native-rpc' -type UpgradeKnowledge = { version: 4; data: Map } | undefined -const db = createDBAccessWithAsyncUpgrade( - 4, - 7, - (currentTryOpen, knowledge) => - openDB('maskbook-post-v2', currentTryOpen, { - async upgrade(db, oldVersion, _newVersion, transaction): Promise { - type Version2PostRecord = { - postBy: PrototypeLess - identifier: string - recipientGroups?: unknown - recipients?: ProfileIdentifier[] - foundAt: Date - postCryptoKey?: CryptoKey - } - type Version3RecipientDetail = { - /** Why they're able to receive this message? */ - reason: RecipientReason[] - } - type Version3PostRecord = Omit & { - recipients: Record - } - type Version4PostRecord = Omit & { - recipients: Map - } - type Version5PostRecord = Omit & { - postCryptoKey?: AESJsonWebKey - encryptBy?: PrototypeLess - url?: string - summary?: string - interestedMeta?: ReadonlyMap - recipients: true | Map - } - type Version6PostRecord = Omit & { - encryptBy?: string - } - /** - * A type assert that make sure a and b are the same type - * @param a The latest version PostRecord - */ - function _assert(a: Version6PostRecord, b: PostDBRecord) { - a = b - b = a - } - // Prevent unused code removal - // eslint-disable-next-line no-constant-condition - if (1 + 1 === 3) _assert({} as any, {} as any) - if (oldVersion < 1) { - // inline keys - return void db.createObjectStore('post', { keyPath: 'identifier' }) - } - /** - * In the version 1 we use PostIdentifier to store post that identified by post iv - * After upgrade to version 2, we use PostIVIdentifier to store it. - * So we transform all old data into new data. - */ - if (oldVersion <= 1) { - const store = transaction.objectStore('post') - const old = await store.getAll() - await store.clear() - for (const each of old) { - const id = Identifier.fromString(each.identifier, PostIdentifier) - if (id.ok) { - const { postId, identifier } = id.val - each.identifier = new PostIVIdentifier( - (identifier as ProfileIdentifier).network, - postId, - ).toText() - await store.add(each) - } - } +export const { createPostDB, updatePostDB, queryPostDB, queryPostsDB, queryPostPagedDB, PostDBAccess } = 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)) } - /** - * In the version 2 we use `recipients?: ProfileIdentifier[]` - * After upgrade to version 3, we use `recipients: Record` - */ - if (oldVersion <= 2) { - const store = transaction.objectStore('post') - for await (const cursor of store) { - const v2record: Version2PostRecord = cursor.value as any - const oldType = v2record.recipients - oldType && restorePrototypeArray(oldType, ProfileIdentifier.prototype) - const newType: Version3PostRecord['recipients'] = {} - if (oldType !== undefined) - for (const each of oldType) { - newType[each.toText()] = { reason: [{ type: 'direct', at: new Date(0) }] } - } - const next: Version3PostRecord = { - ...v2record, - recipients: newType, - postBy: ProfileIdentifier.unknown, - foundAt: new Date(0), - recipientGroups: [], - } - await cursor.update(next as Version3PostRecord as any) - } - } - - /** - * In the version 3 we use `recipients?: Record` - * After upgrade to version 4, we use `recipients: IdentifierMap` - */ - if (oldVersion <= 3) { - const store = transaction.objectStore('post') - for await (const cursor of store) { - const v3Record: Version3PostRecord = cursor.value as any - const newType: Version4PostRecord['recipients'] = new Map() - for (const [key, value] of Object.entries(v3Record.recipients)) { - newType.set(key, value) - } - const v4Record: Version4PostRecord = { - ...v3Record, - recipients: newType, - } - await cursor.update(v4Record as any) - } - } - /** - * In version 4 we use CryptoKey, in version 5 we use JsonWebKey - */ - if (oldVersion <= 4) { - const store = transaction.objectStore('post') - for await (const cursor of store) { - const v4Record: Version4PostRecord = cursor.value as any - const data = knowledge?.data! - if (!v4Record.postCryptoKey) continue - const v5Record: Version5PostRecord = { - ...v4Record, - postCryptoKey: data.get(v4Record.identifier)!, - } - if (!v5Record.postCryptoKey) delete v5Record.postCryptoKey - await cursor.update(v5Record as any) - } - } - - // version 6 ships a wrong db migration. - // therefore need to upgrade again to fix it. - if (oldVersion <= 6) { - const store = transaction.objectStore('post') - for await (const cursor of store) { - const v5Record: Version5PostRecord = cursor.value as any - const by = v5Record.encryptBy - // This is the correct data type - if (typeof by === 'string') continue - if (!by) continue - cursor.value.encryptBy = restorePrototype(by, ECKeyIdentifier.prototype).toText() - cursor.update(cursor.value) - } - store.createIndex('persona, date', ['encryptBy', 'foundAt'], { unique: false }) - } - }, - }), - async (db): Promise => { - if (db.version === 4) { - const map = new Map() - const knowledge: UpgradeKnowledge = { version: 4, data: map } - const records = await createTransaction(db, 'readonly')('post').objectStore('post').getAll() - for (const r of records) { - const x = r.postCryptoKey - if (!x) continue - try { - const key = await CryptoKeyToJsonWebKey(x as any as AESCryptoKey) - map.set(r.identifier, key) - } catch { - continue - } + return import('./web').then((x) => (x as any)[key](...args)) } - return knowledge - } - return undefined + }, }, ) -export const PostDBAccess = db - -type PostReadOnlyTransaction = IDBPSafeTransaction -type PostReadWriteTransaction = IDBPSafeTransaction - -export async function createPostDB(record: PostRecord, t?: PostReadWriteTransaction) { - t ||= createTransaction(await db(), 'readwrite')('post') - const toSave = postToDB(record) - await t.objectStore('post').add(toSave) -} -export async function updatePostDB( - updateRecord: Partial & Pick, - mode: 'append' | 'override', - t?: PostReadWriteTransaction, -): Promise { - t ||= createTransaction(await db(), 'readwrite')('post') - const emptyRecord: PostRecord = { - identifier: updateRecord.identifier, - recipients: new IdentifierMap(new Map()), - postBy: ProfileIdentifier.unknown, - foundAt: new Date(), - } - const currentRecord = (await queryPostDB(updateRecord.identifier, t)) || emptyRecord - const nextRecord: PostRecord = { ...currentRecord, ...updateRecord } - const nextRecipients: PostDBRecord['recipients'] = - mode === 'override' ? postToDB(nextRecord).recipients : postToDB(currentRecord).recipients - if (mode === 'append') { - if (updateRecord.recipients) { - if (typeof updateRecord.recipients === 'object' && typeof nextRecipients === 'object') { - for (const [id, patchDetail] of updateRecord.recipients) { - const idText = id.toText() - if (nextRecipients.has(idText)) { - const { reason, ...rest } = patchDetail - const nextDetail = nextRecipients.get(idText)! - Object.assign(nextDetail, rest) - nextDetail.reason = [...nextDetail.reason, ...patchDetail.reason] - } else { - nextRecipients.set(idText, patchDetail) - } - } - } else { - nextRecord.recipients = 'everyone' - } - } - } - const nextRecordInDBType = postToDB(nextRecord) - await t.objectStore('post').put(nextRecordInDBType) -} - -export async function queryPostDB(record: PostIVIdentifier, t?: PostReadOnlyTransaction): Promise { - t ||= createTransaction(await db(), 'readonly')('post') - const result = await t.objectStore('post').get(record.toText()) - if (result) return postOutDB(result) - return null -} -export async function queryPostsDB( - query: string | ((data: PostRecord, id: PostIVIdentifier) => boolean), - t?: PostReadOnlyTransaction, -): Promise { - t ||= createTransaction(await db(), 'readonly')('post') - const selected: PostRecord[] = [] - for await (const { value } of t.objectStore('post')) { - const idResult = Identifier.fromString(value.identifier, PostIVIdentifier) - if (idResult.err) { - console.warn(idResult.val.message) - continue - } - const id = idResult.val - if (typeof query === 'string') { - if (id.network === query) selected.push(postOutDB(value)) - } else { - const v = postOutDB(value) - if (query(v, id)) selected.push(v) - } - } - return selected -} - -/** - * Query posts by paged - */ -export async function queryPostPagedDB( - linked: PersonaIdentifier, - options: { - network: string - userIds: string[] - after?: PostIVIdentifier - }, - count: number, -) { - const t = createTransaction(await db(), 'readonly')('post') - - const data: PostRecord[] = [] - let firstRecord = true - - for await (const cursor of t.objectStore('post').iterate()) { - if (cursor.value.encryptBy !== linked.toText()) continue - if (!options.userIds.includes(cursor.value.postBy.userId)) continue - - const postIdentifier = Identifier.fromString(cursor.value.identifier, PostIVIdentifier).unwrap() - if (postIdentifier.network !== options.network) continue - - if (firstRecord && options.after) { - cursor.continue(options.after.toText()) - firstRecord = false - continue - } - - if (postIdentifier === options.after) continue - - if (count <= 0) break - const outData = postOutDB(cursor.value) - count -= 1 - data.push(outData) - } - return data -} - -// #region db in and out -function postOutDB(db: PostDBRecord): PostRecord { - const { identifier, foundAt, postBy, recipients, postCryptoKey, encryptBy, interestedMeta, summary, url } = db - if (typeof recipients === 'object') { - for (const detail of recipients.values()) { - detail.reason.forEach((x) => x.type === 'group' && restorePrototype(x.group, GroupIdentifier.prototype)) - } - } - return { - identifier: Identifier.fromString(identifier, PostIVIdentifier).unwrap(), - postBy: restorePrototype(postBy, ProfileIdentifier.prototype), - recipients: recipients === true ? 'everyone' : new IdentifierMap(recipients, ProfileIdentifier), - foundAt: foundAt, - postCryptoKey: postCryptoKey, - encryptBy: encryptBy ? Identifier.fromString(encryptBy, ECKeyIdentifier).unwrapOr(undefined) : undefined, - interestedMeta, - summary, - url, - } -} -function postToDB(out: PostRecord): PostDBRecord { - return { - ...out, - identifier: out.identifier.toText(), - recipients: out.recipients === 'everyone' ? true : out.recipients.__raw_map__, - encryptBy: out.encryptBy?.toText(), - } -} -// #endregion - -// #region types -/** - * When you change this, change RecipientReasonJSON as well! - */ -export type RecipientReason = ( - | { type: 'auto-share' } - | { type: 'direct' } - | { type: 'group'; group: GroupIdentifier } -) & { - /** - * When we send the key to them by this reason? - * If the unix timestamp of this Date is 0, - * should display it as "unknown" or "before Nov 2019" - */ - at: Date -} -export interface RecipientDetail { - /** Why they're able to receive this message? */ - reason: RecipientReason[] -} -export interface PostRecord { - /** - * For old data stored before version 3, this identifier may be ProfileIdentifier.unknown - */ - postBy: ProfileIdentifier - identifier: PostIVIdentifier - postCryptoKey?: AESJsonWebKey - /** - * Receivers - */ - recipients: 'everyone' | IdentifierMap - /** @deprecated */ - recipientGroups?: unknown - /** - * When does Mask find this post. - * For your own post, it is when Mask created this post. - * For others post, it is when you see it first time. - */ - foundAt: Date - encryptBy?: PersonaIdentifier - /** The URL of this post */ - url?: string - /** Summary of this post (maybe front 20 chars). */ - summary?: string - /** Interested metadata contained in this post. */ - interestedMeta?: ReadonlyMap -} - -interface PostDBRecord extends Omit { - postBy: PrototypeLess - identifier: string - recipients: true | Map - encryptBy?: string -} - -interface PostDB extends DBSchema { - /** Use inline keys */ - post: { - value: PostDBRecord - key: string - indexes: { - 'persona, date': [string, Date] - } - } -} -// #endregion diff --git a/packages/mask/background/database/post/type.ts b/packages/mask/background/database/post/type.ts new file mode 100644 index 000000000000..6ed8978dfca3 --- /dev/null +++ b/packages/mask/background/database/post/type.ts @@ -0,0 +1,76 @@ +import type { + AESJsonWebKey, + GroupIdentifier, + IdentifierMap, + PersonaIdentifier, + PostIVIdentifier, + ProfileIdentifier, +} from '@masknet/shared-base' +import type { DBSchema } from 'idb/with-async-ittr' +import type { PrototypeLess } from '../../../utils-pure' +import type { IDBPSafeTransaction } from '../utils/openDB' + +export type RecipientReason = ( + | { type: 'auto-share' } + | { type: 'direct' } + | { type: 'group'; group: GroupIdentifier } +) & { + /** + * When we send the key to them by this reason? + * If the unix timestamp of this Date is 0, + * should display it as "unknown" or "before Nov 2019" + */ + at: Date +} +export interface RecipientDetail { + /** Why they're able to receive this message? */ + reason: RecipientReason[] +} +export interface PostRecord { + /** + * For old data stored before version 3, this identifier may be ProfileIdentifier.unknown + */ + postBy: ProfileIdentifier + identifier: PostIVIdentifier + postCryptoKey?: AESJsonWebKey + /** + * Receivers + */ + recipients: 'everyone' | IdentifierMap + /** @deprecated */ + recipientGroups?: unknown + /** + * When does Mask find this post. + * For your own post, it is when Mask created this post. + * For others post, it is when you see it first time. + */ + foundAt: Date + encryptBy?: PersonaIdentifier + /** The URL of this post */ + url?: string + /** Summary of this post (maybe front 20 chars). */ + summary?: string + /** Interested metadata contained in this post. */ + interestedMeta?: ReadonlyMap +} + +export interface PostDBRecord extends Omit { + postBy: PrototypeLess + identifier: string + recipients: true | Map + encryptBy?: string +} + +export interface PostDB extends DBSchema { + /** Use inline keys */ + post: { + value: PostDBRecord + key: string + indexes: { + 'persona, date': [string, Date] + } + } +} + +export type PostReadOnlyTransaction = IDBPSafeTransaction +export type PostReadWriteTransaction = IDBPSafeTransaction diff --git a/packages/mask/background/database/post/web.ts b/packages/mask/background/database/post/web.ts new file mode 100644 index 000000000000..bab65056bee4 --- /dev/null +++ b/packages/mask/background/database/post/web.ts @@ -0,0 +1,342 @@ +import { + AESCryptoKey, + AESJsonWebKey, + ECKeyIdentifier, + GroupIdentifier, + Identifier, + IdentifierMap, + PersonaIdentifier, + PostIdentifier, + PostIVIdentifier, + ProfileIdentifier, +} from '@masknet/shared-base' +import { openDB } from 'idb/with-async-ittr' +import { CryptoKeyToJsonWebKey, PrototypeLess, restorePrototype, restorePrototypeArray } from '../../../utils-pure' +import { createDBAccessWithAsyncUpgrade, createTransaction } from '../utils/openDB' +import type { + RecipientReason, + PostRecord, + PostDB, + PostDBRecord, + PostReadOnlyTransaction, + PostReadWriteTransaction, +} from './type' + +type UpgradeKnowledge = { version: 4; data: Map } | undefined +const db = createDBAccessWithAsyncUpgrade( + 4, + 7, + (currentTryOpen, knowledge) => + openDB('maskbook-post-v2', currentTryOpen, { + async upgrade(db, oldVersion, _newVersion, transaction): Promise { + type Version2PostRecord = { + postBy: PrototypeLess + identifier: string + recipientGroups?: unknown + recipients?: ProfileIdentifier[] + foundAt: Date + postCryptoKey?: CryptoKey + } + type Version3RecipientDetail = { + /** Why they're able to receive this message? */ + reason: RecipientReason[] + } + type Version3PostRecord = Omit & { + recipients: Record + } + type Version4PostRecord = Omit & { + recipients: Map + } + type Version5PostRecord = Omit & { + postCryptoKey?: AESJsonWebKey + encryptBy?: PrototypeLess + url?: string + summary?: string + interestedMeta?: ReadonlyMap + recipients: true | Map + } + type Version6PostRecord = Omit & { + encryptBy?: string + } + /** + * A type assert that make sure a and b are the same type + * @param a The latest version PostRecord + */ + function _assert(a: Version6PostRecord, b: PostDBRecord) { + a = b + b = a + } + // Prevent unused code removal + // eslint-disable-next-line no-constant-condition + if (1 + 1 === 3) _assert({} as any, {} as any) + if (oldVersion < 1) { + // inline keys + return void db.createObjectStore('post', { keyPath: 'identifier' }) + } + /** + * In the version 1 we use PostIdentifier to store post that identified by post iv + * After upgrade to version 2, we use PostIVIdentifier to store it. + * So we transform all old data into new data. + */ + if (oldVersion <= 1) { + const store = transaction.objectStore('post') + const old = await store.getAll() + await store.clear() + for (const each of old) { + const id = Identifier.fromString(each.identifier, PostIdentifier) + if (id.ok) { + const { postId, identifier } = id.val + each.identifier = new PostIVIdentifier( + (identifier as ProfileIdentifier).network, + postId, + ).toText() + await store.add(each) + } + } + } + + /** + * In the version 2 we use `recipients?: ProfileIdentifier[]` + * After upgrade to version 3, we use `recipients: Record` + */ + if (oldVersion <= 2) { + const store = transaction.objectStore('post') + for await (const cursor of store) { + const v2record: Version2PostRecord = cursor.value as any + const oldType = v2record.recipients + oldType && restorePrototypeArray(oldType, ProfileIdentifier.prototype) + const newType: Version3PostRecord['recipients'] = {} + if (oldType !== undefined) + for (const each of oldType) { + newType[each.toText()] = { reason: [{ type: 'direct', at: new Date(0) }] } + } + const next: Version3PostRecord = { + ...v2record, + recipients: newType, + postBy: ProfileIdentifier.unknown, + foundAt: new Date(0), + recipientGroups: [], + } + await cursor.update(next as Version3PostRecord as any) + } + } + + /** + * In the version 3 we use `recipients?: Record` + * After upgrade to version 4, we use `recipients: IdentifierMap` + */ + if (oldVersion <= 3) { + const store = transaction.objectStore('post') + for await (const cursor of store) { + const v3Record: Version3PostRecord = cursor.value as any + const newType: Version4PostRecord['recipients'] = new Map() + for (const [key, value] of Object.entries(v3Record.recipients)) { + newType.set(key, value) + } + const v4Record: Version4PostRecord = { + ...v3Record, + recipients: newType, + } + await cursor.update(v4Record as any) + } + } + /** + * In version 4 we use CryptoKey, in version 5 we use JsonWebKey + */ + if (oldVersion <= 4) { + const store = transaction.objectStore('post') + for await (const cursor of store) { + const v4Record: Version4PostRecord = cursor.value as any + const data = knowledge?.data! + if (!v4Record.postCryptoKey) continue + const v5Record: Version5PostRecord = { + ...v4Record, + postCryptoKey: data.get(v4Record.identifier)!, + } + if (!v5Record.postCryptoKey) delete v5Record.postCryptoKey + await cursor.update(v5Record as any) + } + } + + // version 6 ships a wrong db migration. + // therefore need to upgrade again to fix it. + if (oldVersion <= 6) { + const store = transaction.objectStore('post') + for await (const cursor of store) { + const v5Record: Version5PostRecord = cursor.value as any + const by = v5Record.encryptBy + // This is the correct data type + if (typeof by === 'string') continue + if (!by) continue + cursor.value.encryptBy = restorePrototype(by, ECKeyIdentifier.prototype).toText() + cursor.update(cursor.value) + } + store.createIndex('persona, date', ['encryptBy', 'foundAt'], { unique: false }) + } + }, + }), + async (db): Promise => { + if (db.version === 4) { + const map = new Map() + const knowledge: UpgradeKnowledge = { version: 4, data: map } + const records = await createTransaction(db, 'readonly')('post').objectStore('post').getAll() + for (const r of records) { + const x = r.postCryptoKey + if (!x) continue + try { + const key = await CryptoKeyToJsonWebKey(x as any as AESCryptoKey) + map.set(r.identifier, key) + } catch { + continue + } + } + return knowledge + } + return undefined + }, +) +export const PostDBAccess = db + +export async function createPostDB(record: PostRecord, t?: PostReadWriteTransaction) { + t ||= createTransaction(await db(), 'readwrite')('post') + const toSave = postToDB(record) + await t.objectStore('post').add(toSave) +} +export async function updatePostDB( + updateRecord: Partial & Pick, + mode: 'append' | 'override', + t?: PostReadWriteTransaction, +): Promise { + t ||= createTransaction(await db(), 'readwrite')('post') + const emptyRecord: PostRecord = { + identifier: updateRecord.identifier, + recipients: new IdentifierMap(new Map()), + postBy: ProfileIdentifier.unknown, + foundAt: new Date(), + } + const currentRecord = (await queryPostDB(updateRecord.identifier, t)) || emptyRecord + const nextRecord: PostRecord = { ...currentRecord, ...updateRecord } + const nextRecipients: PostDBRecord['recipients'] = + mode === 'override' ? postToDB(nextRecord).recipients : postToDB(currentRecord).recipients + if (mode === 'append') { + if (updateRecord.recipients) { + if (typeof updateRecord.recipients === 'object' && typeof nextRecipients === 'object') { + for (const [id, patchDetail] of updateRecord.recipients) { + const idText = id.toText() + if (nextRecipients.has(idText)) { + const { reason, ...rest } = patchDetail + const nextDetail = nextRecipients.get(idText)! + Object.assign(nextDetail, rest) + nextDetail.reason = [...nextDetail.reason, ...patchDetail.reason] + } else { + nextRecipients.set(idText, patchDetail) + } + } + } else { + nextRecord.recipients = 'everyone' + } + } + } + const nextRecordInDBType = postToDB(nextRecord) + await t.objectStore('post').put(nextRecordInDBType) +} + +export async function queryPostDB(record: PostIVIdentifier, t?: PostReadOnlyTransaction): Promise { + t ||= createTransaction(await db(), 'readonly')('post') + const result = await t.objectStore('post').get(record.toText()) + if (result) return postOutDB(result) + return null +} +export async function queryPostsDB( + query: string | ((data: PostRecord, id: PostIVIdentifier) => boolean), + t?: PostReadOnlyTransaction, +): Promise { + t ||= createTransaction(await db(), 'readonly')('post') + const selected: PostRecord[] = [] + for await (const { value } of t.objectStore('post')) { + const idResult = Identifier.fromString(value.identifier, PostIVIdentifier) + if (idResult.err) { + console.warn(idResult.val.message) + continue + } + const id = idResult.val + if (typeof query === 'string') { + if (id.network === query) selected.push(postOutDB(value)) + } else { + const v = postOutDB(value) + if (query(v, id)) selected.push(v) + } + } + return selected +} + +/** + * Query posts by paged + */ +export async function queryPostPagedDB( + linked: PersonaIdentifier, + options: { + network: string + userIds: string[] + after?: PostIVIdentifier + page?: number + }, + count: number, +) { + const t = createTransaction(await db(), 'readonly')('post') + + const data: PostRecord[] = [] + let firstRecord = true + + for await (const cursor of t.objectStore('post').iterate()) { + if (cursor.value.encryptBy !== linked.toText()) continue + if (!options.userIds.includes(cursor.value.postBy.userId)) continue + + const postIdentifier = Identifier.fromString(cursor.value.identifier, PostIVIdentifier).unwrap() + if (postIdentifier.network !== options.network) continue + + if (firstRecord && options.after) { + cursor.continue(options.after.toText()) + firstRecord = false + continue + } + + if (postIdentifier === options.after) continue + + if (count <= 0) break + const outData = postOutDB(cursor.value) + count -= 1 + data.push(outData) + } + return data +} + +// #region db in and out +function postOutDB(db: PostDBRecord): PostRecord { + const { identifier, foundAt, postBy, recipients, postCryptoKey, encryptBy, interestedMeta, summary, url } = db + if (typeof recipients === 'object') { + for (const detail of recipients.values()) { + detail.reason.forEach((x) => x.type === 'group' && restorePrototype(x.group, GroupIdentifier.prototype)) + } + } + return { + identifier: Identifier.fromString(identifier, PostIVIdentifier).unwrap(), + postBy: restorePrototype(postBy, ProfileIdentifier.prototype), + recipients: recipients === true ? 'everyone' : new IdentifierMap(recipients, ProfileIdentifier), + foundAt: foundAt, + postCryptoKey: postCryptoKey, + encryptBy: encryptBy ? Identifier.fromString(encryptBy, ECKeyIdentifier).unwrapOr(undefined) : undefined, + interestedMeta, + summary, + url, + } +} +function postToDB(out: PostRecord): PostDBRecord { + return { + ...out, + identifier: out.identifier.toText(), + recipients: out.recipients === 'everyone' ? true : out.recipients.__raw_map__, + encryptBy: out.encryptBy?.toText(), + } +} +// #endregion diff --git a/packages/mask/src/extension/background-script/IdentityService.ts b/packages/mask/src/extension/background-script/IdentityService.ts index a4318c880273..094d1b104c1b 100644 --- a/packages/mask/src/extension/background-script/IdentityService.ts +++ b/packages/mask/src/extension/background-script/IdentityService.ts @@ -248,6 +248,7 @@ export async function queryPagedPostHistory( network: string userIds: string[] after?: PostIVIdentifier + pageOffset?: number }, count: number, ) { diff --git a/packages/public-api/src/mobile.ts b/packages/public-api/src/mobile.ts index c9751d0b2778..4336502538c9 100644 --- a/packages/public-api/src/mobile.ts +++ b/packages/public-api/src/mobile.ts @@ -6,6 +6,7 @@ import type { ProfileRecord, LinkedProfileDetails, RelationRecord, + PostRecord, } from './types' /** @@ -100,6 +101,17 @@ export interface SharedNativeAPIs { }): Promise update_relation(params: { relation: Omit }): Promise delete_relation(params: { personaIdentifier: string; profileIdentifier: string }): Promise + query_avatar(params: { identifier: string }): Promise + store_avatar(params: { identifier: string; avatar: string }): Promise + create_post(params: { post: PostRecord }): Promise + query_post(params: { identifier: string }): Promise + query_posts(params: { + encryptBy?: string + userIds: string[] + network?: string + pageOption?: PageOption + }): Promise + update_post(params: { post: Partial; options: { mode: 0 | 1 } }): 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 4210601ae269..6eeb8c317c3d 100644 --- a/packages/public-api/src/types/index.ts +++ b/packages/public-api/src/types/index.ts @@ -43,6 +43,42 @@ export interface PersonaRecord { uninitialized?: boolean } +type RecipientReason = ({ type: 'auto-share' } | { type: 'direct' } | { type: 'group'; group: any }) & { + at: number +} + +interface RecipientDetail { + reason: RecipientReason[] +} + +export interface PostRecord { + /** + * For old data stored before version 3, this identifier may be ProfileIdentifier.unknown + */ + postBy: string + identifier: string + postCryptoKey?: object + /** + * Receivers + */ + recipients: Record + /** @deprecated */ + recipientGroups?: unknown + /** + * When does Mask find this post. + * For your own post, it is when Mask created this post. + * For others post, it is when you see it first time. + */ + foundAt: number + encryptBy?: string + /** The URL of this post */ + url?: string + /** Summary of this post (maybe front 20 chars). */ + summary?: string + /** Interested metadata contained in this post. */ + interestedMeta?: ReadonlyMap +} + export interface ProfileRecord { identifier: string nickname?: string