|  |  |  | import { difference, isNumber, omit, pick } from 'lodash'; | 
					
						
							|  |  |  | import * as BetterSqlite3 from '@signalapp/better-sqlite3'; | 
					
						
							|  |  |  | import { | 
					
						
							|  |  |  |   ConversationAttributes, | 
					
						
							|  |  |  |   ConversationAttributesWithNotSavedOnes, | 
					
						
							|  |  |  |   CONVERSATION_PRIORITIES, | 
					
						
							|  |  |  | } from '../models/conversationAttributes'; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | export const CONVERSATIONS_TABLE = 'conversations'; | 
					
						
							|  |  |  | export const MESSAGES_TABLE = 'messages'; | 
					
						
							|  |  |  | export const MESSAGES_FTS_TABLE = 'messages_fts'; | 
					
						
							|  |  |  | export const NODES_FOR_PUBKEY_TABLE = 'nodesForPubkey'; | 
					
						
							|  |  |  | export const OPEN_GROUP_ROOMS_V2_TABLE = 'openGroupRoomsV2'; | 
					
						
							|  |  |  | export const IDENTITY_KEYS_TABLE = 'identityKeys'; | 
					
						
							|  |  |  | export const GUARD_NODE_TABLE = 'guardNodes'; | 
					
						
							|  |  |  | export const ITEMS_TABLE = 'items'; | 
					
						
							|  |  |  | export const ATTACHMENT_DOWNLOADS_TABLE = 'attachment_downloads'; | 
					
						
							|  |  |  | export const CLOSED_GROUP_V2_KEY_PAIRS_TABLE = 'encryptionKeyPairsForClosedGroupV2'; | 
					
						
							|  |  |  | export const LAST_HASHES_TABLE = 'lastHashes'; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | export const HEX_KEY = /[^0-9A-Fa-f]/; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | export function objectToJSON(data: Record<any, any>) { | 
					
						
							|  |  |  |   return JSON.stringify(data); | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | export function jsonToObject(json: string): Record<string, any> { | 
					
						
							|  |  |  |   return JSON.parse(json); | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | function jsonToArray(json: string): Array<string> { | 
					
						
							|  |  |  |   try { | 
					
						
							|  |  |  |     return JSON.parse(json); | 
					
						
							|  |  |  |   } catch (e) { | 
					
						
							|  |  |  |     console.error('jsontoarray failed:', e.message); | 
					
						
							|  |  |  |     return []; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | export function arrayStrToJson(arr: Array<string>): string { | 
					
						
							|  |  |  |   return JSON.stringify(arr); | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | export function toSqliteBoolean(val: boolean): number { | 
					
						
							|  |  |  |   return val ? 1 : 0; | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | // this is used to make sure when storing something in the database you remember to add the wrapping for it in formatRowOfConversation
 | 
					
						
							|  |  |  | const allowedKeysFormatRowOfConversation = [ | 
					
						
							|  |  |  |   'groupAdmins', | 
					
						
							|  |  |  |   'members', | 
					
						
							|  |  |  |   'zombies', | 
					
						
							|  |  |  |   'isTrustedForAttachmentDownload', | 
					
						
							|  |  |  |   'isApproved', | 
					
						
							|  |  |  |   'didApproveMe', | 
					
						
							|  |  |  |   'mentionedUs', | 
					
						
							|  |  |  |   'isKickedFromGroup', | 
					
						
							|  |  |  |   'left', | 
					
						
							|  |  |  |   'lastMessage', | 
					
						
							|  |  |  |   'lastMessageStatus', | 
					
						
							|  |  |  |   'triggerNotificationsFor', | 
					
						
							|  |  |  |   'unreadCount', | 
					
						
							|  |  |  |   'lastJoinedTimestamp', | 
					
						
							|  |  |  |   'expireTimer', | 
					
						
							|  |  |  |   'active_at', | 
					
						
							|  |  |  |   'id', | 
					
						
							|  |  |  |   'type', | 
					
						
							|  |  |  |   'avatarPointer', | 
					
						
							|  |  |  |   'avatarImageId', | 
					
						
							|  |  |  |   'nickname', | 
					
						
							|  |  |  |   'profileKey', | 
					
						
							|  |  |  |   'avatarInProfile', | 
					
						
							|  |  |  |   'displayNameInProfile', | 
					
						
							|  |  |  |   'conversationIdOrigin', | 
					
						
							|  |  |  |   'markedAsUnread', | 
					
						
							|  |  |  |   'blocksSogsMsgReqsTimestamp', | 
					
						
							|  |  |  |   'priority', | 
					
						
							|  |  |  | ]; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | export function formatRowOfConversation( | 
					
						
							|  |  |  |   row: Record<string, any>, | 
					
						
							|  |  |  |   from: string, | 
					
						
							|  |  |  |   unreadCount: number, | 
					
						
							|  |  |  |   mentionedUs: boolean | 
					
						
							|  |  |  | ): ConversationAttributesWithNotSavedOnes | null { | 
					
						
							|  |  |  |   if (!row) { | 
					
						
							|  |  |  |     return null; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   const foundInRowButNotInAllowed = difference( | 
					
						
							|  |  |  |     Object.keys(row), | 
					
						
							|  |  |  |     allowedKeysFormatRowOfConversation | 
					
						
							|  |  |  |   ); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   if (foundInRowButNotInAllowed?.length) { | 
					
						
							|  |  |  |     console.error( | 
					
						
							|  |  |  |       `formatRowOfConversation: "from:${from}" foundInRowButNotInAllowed: `, | 
					
						
							|  |  |  |       foundInRowButNotInAllowed | 
					
						
							|  |  |  |     ); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     throw new Error( | 
					
						
							|  |  |  |       `formatRowOfConversation: an invalid key was given in the record: ${foundInRowButNotInAllowed[0]}` | 
					
						
							|  |  |  |     ); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   const convo: ConversationAttributes = omit(row, 'json') as ConversationAttributes; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   // if the stringified array of admins/moderators/members/zombies length is less than 5,
 | 
					
						
							|  |  |  |   // we consider there is nothing to parse and just return []
 | 
					
						
							|  |  |  |   const minLengthNoParsing = 5; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   convo.groupAdmins = | 
					
						
							|  |  |  |     row.groupAdmins?.length && row.groupAdmins.length > minLengthNoParsing | 
					
						
							|  |  |  |       ? jsonToArray(row.groupAdmins) | 
					
						
							|  |  |  |       : []; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   convo.members = | 
					
						
							|  |  |  |     row.members?.length && row.members.length > minLengthNoParsing ? jsonToArray(row.members) : []; | 
					
						
							|  |  |  |   convo.zombies = | 
					
						
							|  |  |  |     row.zombies?.length && row.zombies.length > minLengthNoParsing ? jsonToArray(row.zombies) : []; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   // sqlite stores boolean as integer. to clean thing up we force the expected boolean fields to be boolean
 | 
					
						
							|  |  |  |   convo.isTrustedForAttachmentDownload = Boolean(convo.isTrustedForAttachmentDownload); | 
					
						
							|  |  |  |   convo.isApproved = Boolean(convo.isApproved); | 
					
						
							|  |  |  |   convo.didApproveMe = Boolean(convo.didApproveMe); | 
					
						
							|  |  |  |   convo.isKickedFromGroup = Boolean(convo.isKickedFromGroup); | 
					
						
							|  |  |  |   convo.left = Boolean(convo.left); | 
					
						
							|  |  |  |   convo.markedAsUnread = Boolean(convo.markedAsUnread); | 
					
						
							|  |  |  |   convo.priority = convo.priority || CONVERSATION_PRIORITIES.default; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   if (!convo.conversationIdOrigin) { | 
					
						
							|  |  |  |     convo.conversationIdOrigin = undefined; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   if (!convo.lastMessage) { | 
					
						
							|  |  |  |     convo.lastMessage = null; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   if (!convo.lastMessageStatus) { | 
					
						
							|  |  |  |     convo.lastMessageStatus = undefined; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   if (!isNumber(convo.blocksSogsMsgReqsTimestamp)) { | 
					
						
							|  |  |  |     convo.blocksSogsMsgReqsTimestamp = 0; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   if (!convo.triggerNotificationsFor) { | 
					
						
							|  |  |  |     convo.triggerNotificationsFor = 'all'; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   if (!convo.lastJoinedTimestamp) { | 
					
						
							|  |  |  |     convo.lastJoinedTimestamp = 0; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   if (!convo.expireTimer) { | 
					
						
							|  |  |  |     convo.expireTimer = 0; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   if (!convo.active_at) { | 
					
						
							|  |  |  |     convo.active_at = 0; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   return { | 
					
						
							|  |  |  |     ...convo, | 
					
						
							|  |  |  |     mentionedUs, | 
					
						
							|  |  |  |     unreadCount, | 
					
						
							|  |  |  |   }; | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | /** | 
					
						
							|  |  |  |  * Those attributes are the one we are sending to the sql call as we want to save them when saving a conversation row. | 
					
						
							|  |  |  |  */ | 
					
						
							|  |  |  | const allowedKeysOfConversationAttributes = [ | 
					
						
							|  |  |  |   'groupAdmins', | 
					
						
							|  |  |  |   'members', | 
					
						
							|  |  |  |   'zombies', | 
					
						
							|  |  |  |   'isTrustedForAttachmentDownload', | 
					
						
							|  |  |  |   'isApproved', | 
					
						
							|  |  |  |   'didApproveMe', | 
					
						
							|  |  |  |   'isKickedFromGroup', | 
					
						
							|  |  |  |   'left', | 
					
						
							|  |  |  |   'lastMessage', | 
					
						
							|  |  |  |   'lastMessageStatus', | 
					
						
							|  |  |  |   'triggerNotificationsFor', | 
					
						
							|  |  |  |   'lastJoinedTimestamp', | 
					
						
							|  |  |  |   'expireTimer', | 
					
						
							|  |  |  |   'active_at', | 
					
						
							|  |  |  |   'id', | 
					
						
							|  |  |  |   'type', | 
					
						
							|  |  |  |   'avatarPointer', | 
					
						
							|  |  |  |   'avatarImageId', | 
					
						
							|  |  |  |   'nickname', | 
					
						
							|  |  |  |   'profileKey', | 
					
						
							|  |  |  |   'avatarInProfile', | 
					
						
							|  |  |  |   'displayNameInProfile', | 
					
						
							|  |  |  |   'conversationIdOrigin', | 
					
						
							|  |  |  |   'markedAsUnread', | 
					
						
							|  |  |  |   'blocksSogsMsgReqsTimestamp', | 
					
						
							|  |  |  |   'priority', | 
					
						
							|  |  |  | ]; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | /** | 
					
						
							|  |  |  |  * Those attributes are the one we know the renderer is sending back but which we do not want to save to the database. | 
					
						
							|  |  |  |  * They are fetched when getting the conversation from the DB and in anything returning a SaveConversationReturn | 
					
						
							|  |  |  |  */ | 
					
						
							|  |  |  | const allowedKeysButNotSavedToDb = ['mentionedUs', 'unreadCount']; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | /** | 
					
						
							|  |  |  |  * This one merges each list together, and must be used for the log statement only. | 
					
						
							|  |  |  |  */ | 
					
						
							|  |  |  | const allowedKeysTogether = [...allowedKeysOfConversationAttributes, ...allowedKeysButNotSavedToDb]; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | /** | 
					
						
							|  |  |  |  * assertValidConversationAttributes is used to make sure that only the keys stored in the database are sent from the renderer. | 
					
						
							|  |  |  |  * We could also add some type checking here to make sure what is sent by the renderer matches what we expect to store in the DB | 
					
						
							|  |  |  |  */ | 
					
						
							|  |  |  | export function assertValidConversationAttributes( | 
					
						
							|  |  |  |   data: ConversationAttributes | 
					
						
							|  |  |  | ): ConversationAttributes { | 
					
						
							|  |  |  |   // first make sure all keys of the object data are expected to be there, or expected to not be saved to the DB
 | 
					
						
							|  |  |  |   const foundInAttributesButNotInAllowed = difference(Object.keys(data), allowedKeysTogether); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   if (foundInAttributesButNotInAllowed?.length) { | 
					
						
							|  |  |  |     console.error( | 
					
						
							|  |  |  |       `assertValidConversationAttributes: an invalid key was given in the record: ${foundInAttributesButNotInAllowed}` | 
					
						
							|  |  |  |     ); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   // we only ever want to save the allowedKeysOfConversationAttributes here, not the one part of allowedKeysButNotSavedToDb
 | 
					
						
							|  |  |  |   return pick(data, allowedKeysOfConversationAttributes) as ConversationAttributes; | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | export function dropFtsAndTriggers(db: BetterSqlite3.Database) { | 
					
						
							|  |  |  |   console.info('dropping fts5 table'); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   db.exec(`
 | 
					
						
							|  |  |  |         DROP TRIGGER IF EXISTS messages_on_insert; | 
					
						
							|  |  |  |         DROP TRIGGER IF EXISTS messages_on_delete; | 
					
						
							|  |  |  |         DROP TRIGGER IF EXISTS messages_on_update; | 
					
						
							|  |  |  |         DROP TABLE IF EXISTS ${MESSAGES_FTS_TABLE}; | 
					
						
							|  |  |  |       `);
 | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | export function rebuildFtsTable(db: BetterSqlite3.Database) { | 
					
						
							|  |  |  |   console.info('rebuildFtsTable'); | 
					
						
							|  |  |  |   db.exec(`
 | 
					
						
							|  |  |  |           -- Then we create our full-text search table and populate it | 
					
						
							|  |  |  |           CREATE VIRTUAL TABLE ${MESSAGES_FTS_TABLE} | 
					
						
							|  |  |  |             USING fts5(body); | 
					
						
							|  |  |  |           INSERT INTO ${MESSAGES_FTS_TABLE}(rowid, body) | 
					
						
							|  |  |  |             SELECT rowid, body FROM ${MESSAGES_TABLE}; | 
					
						
							|  |  |  |           -- Then we set up triggers to keep the full-text search table up to date | 
					
						
							|  |  |  |           CREATE TRIGGER messages_on_insert AFTER INSERT ON ${MESSAGES_TABLE} BEGIN | 
					
						
							|  |  |  |             INSERT INTO ${MESSAGES_FTS_TABLE} ( | 
					
						
							|  |  |  |               rowid, | 
					
						
							|  |  |  |               body | 
					
						
							|  |  |  |             ) VALUES ( | 
					
						
							|  |  |  |               new.rowid, | 
					
						
							|  |  |  |               new.body | 
					
						
							|  |  |  |             ); | 
					
						
							|  |  |  |           END; | 
					
						
							|  |  |  |           CREATE TRIGGER messages_on_delete AFTER DELETE ON ${MESSAGES_TABLE} BEGIN | 
					
						
							|  |  |  |             DELETE FROM ${MESSAGES_FTS_TABLE} WHERE rowid = old.rowid; | 
					
						
							|  |  |  |           END; | 
					
						
							|  |  |  |           CREATE TRIGGER messages_on_update AFTER UPDATE ON ${MESSAGES_TABLE} WHEN new.body <> old.body BEGIN | 
					
						
							|  |  |  |             DELETE FROM ${MESSAGES_FTS_TABLE} WHERE rowid = old.rowid; | 
					
						
							|  |  |  |             INSERT INTO ${MESSAGES_FTS_TABLE}( | 
					
						
							|  |  |  |               rowid, | 
					
						
							|  |  |  |               body | 
					
						
							|  |  |  |             ) VALUES ( | 
					
						
							|  |  |  |               new.rowid, | 
					
						
							|  |  |  |               new.body | 
					
						
							|  |  |  |             ); | 
					
						
							|  |  |  |           END; | 
					
						
							|  |  |  |           `);
 | 
					
						
							|  |  |  |   console.info('rebuildFtsTable built'); | 
					
						
							|  |  |  | } |