You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			2894 lines
		
	
	
		
			67 KiB
		
	
	
	
		
			JavaScript
		
	
			
		
		
	
	
			2894 lines
		
	
	
		
			67 KiB
		
	
	
	
		
			JavaScript
		
	
| const path = require('path');
 | |
| const mkdirp = require('mkdirp');
 | |
| const rimraf = require('rimraf');
 | |
| const SQL = require('better-sqlite3');
 | |
| const { app, dialog, clipboard } = require('electron');
 | |
| const { redactAll } = require('../js/modules/privacy');
 | |
| const { remove: removeUserConfig } = require('./user_config');
 | |
| 
 | |
| const { map, isString, fromPairs, forEach, last, isEmpty, isObject, isNumber } = require('lodash');
 | |
| 
 | |
| /* eslint-disable camelcase */
 | |
| 
 | |
| module.exports = {
 | |
|   initialize,
 | |
|   close,
 | |
|   removeDB,
 | |
|   setSQLPassword,
 | |
| 
 | |
|   getPasswordHash,
 | |
|   savePasswordHash,
 | |
|   removePasswordHash,
 | |
| 
 | |
|   getIdentityKeyById,
 | |
| 
 | |
|   createOrUpdateItem,
 | |
|   getItemById,
 | |
|   getAllItems,
 | |
|   removeItemById,
 | |
| 
 | |
|   getSwarmNodesForPubkey,
 | |
|   updateSwarmNodesForPubkey,
 | |
|   getGuardNodes,
 | |
|   updateGuardNodes,
 | |
| 
 | |
|   getConversationCount,
 | |
|   saveConversation,
 | |
|   getConversationById,
 | |
|   updateConversation,
 | |
|   removeConversation,
 | |
|   getAllConversations,
 | |
|   getAllOpenGroupV1Conversations,
 | |
|   getAllOpenGroupV2Conversations,
 | |
|   getPubkeysInPublicConversation,
 | |
|   getAllGroupsInvolvingId,
 | |
|   removeAllConversations,
 | |
| 
 | |
|   searchConversations,
 | |
|   searchMessages,
 | |
|   searchMessagesInConversation,
 | |
| 
 | |
|   getMessageCount,
 | |
|   saveMessage,
 | |
|   cleanSeenMessages,
 | |
|   cleanLastHashes,
 | |
|   saveSeenMessageHashes,
 | |
|   saveSeenMessageHash,
 | |
|   updateLastHash,
 | |
|   saveMessages,
 | |
|   removeMessage,
 | |
|   getUnreadByConversation,
 | |
|   getUnreadCountByConversation,
 | |
|   getMessageBySender,
 | |
|   getMessageBySenderAndServerId,
 | |
|   getMessageBySenderAndServerTimestamp,
 | |
|   getMessageBySenderAndTimestamp,
 | |
|   getMessageIdsFromServerIds,
 | |
|   getMessageById,
 | |
|   getMessagesBySentAt,
 | |
|   getSeenMessagesByHashList,
 | |
|   getLastHashBySnode,
 | |
|   getExpiredMessages,
 | |
|   getOutgoingWithoutExpiresAt,
 | |
|   getNextExpiringMessage,
 | |
|   getMessagesByConversation,
 | |
|   getFirstUnreadMessageIdInConversation,
 | |
| 
 | |
|   getUnprocessedCount,
 | |
|   getAllUnprocessed,
 | |
|   saveUnprocessed,
 | |
|   updateUnprocessedAttempts,
 | |
|   updateUnprocessedWithData,
 | |
|   getUnprocessedById,
 | |
|   removeUnprocessed,
 | |
|   removeAllUnprocessed,
 | |
| 
 | |
|   getNextAttachmentDownloadJobs,
 | |
|   saveAttachmentDownloadJob,
 | |
|   setAttachmentDownloadJobPending,
 | |
|   resetAttachmentDownloadPending,
 | |
|   removeAttachmentDownloadJob,
 | |
|   removeAllAttachmentDownloadJobs,
 | |
| 
 | |
|   removeAll,
 | |
| 
 | |
|   getMessagesWithVisualMediaAttachments,
 | |
|   getMessagesWithFileAttachments,
 | |
| 
 | |
|   removeKnownAttachments,
 | |
| 
 | |
|   getAllEncryptionKeyPairsForGroup,
 | |
|   getLatestClosedGroupEncryptionKeyPair,
 | |
|   addClosedGroupEncryptionKeyPair,
 | |
|   removeAllClosedGroupEncryptionKeyPairs,
 | |
| 
 | |
|   // open group v2
 | |
|   getV2OpenGroupRoom,
 | |
|   saveV2OpenGroupRoom,
 | |
|   getAllV2OpenGroupRooms,
 | |
|   getV2OpenGroupRoomByRoomId,
 | |
|   removeV2OpenGroupRoom,
 | |
|   removeOneOpenGroupV1Message,
 | |
| };
 | |
| 
 | |
| const CONVERSATIONS_TABLE = 'conversations';
 | |
| const MESSAGES_TABLE = 'messages';
 | |
| const MESSAGES_FTS_TABLE = 'messages_fts';
 | |
| const NODES_FOR_PUBKEY_TABLE = 'nodesForPubkey';
 | |
| const OPEN_GROUP_ROOMS_V2_TABLE = 'openGroupRoomsV2';
 | |
| const IDENTITY_KEYS_TABLE = 'identityKeys';
 | |
| const GUARD_NODE_TABLE = 'guardNodes';
 | |
| const ITEMS_TABLE = 'items';
 | |
| const ATTACHMENT_DOWNLOADS_TABLE = 'attachment_downloads';
 | |
| const CLOSED_GROUP_V2_KEY_PAIRS_TABLE = 'encryptionKeyPairsForClosedGroupV2';
 | |
| 
 | |
| const MAX_PUBKEYS_MEMBERS = 300;
 | |
| 
 | |
| function objectToJSON(data) {
 | |
|   return JSON.stringify(data);
 | |
| }
 | |
| function jsonToObject(json) {
 | |
|   return JSON.parse(json);
 | |
| }
 | |
| 
 | |
| function getSQLiteVersion(db) {
 | |
|   const { sqlite_version } = db.prepare('select sqlite_version() as sqlite_version').get();
 | |
|   return sqlite_version;
 | |
| }
 | |
| 
 | |
| function getSchemaVersion(db) {
 | |
|   return db.pragma('schema_version', { simple: true });
 | |
| }
 | |
| 
 | |
| function getSQLCipherVersion(db) {
 | |
|   return db.pragma('cipher_version', { simple: true });
 | |
| }
 | |
| 
 | |
| function getSQLCipherIntegrityCheck(db) {
 | |
|   const rows = db.pragma('cipher_integrity_check');
 | |
|   if (rows.length === 0) {
 | |
|     return undefined;
 | |
|   }
 | |
|   return rows.map(row => row.cipher_integrity_check);
 | |
| }
 | |
| 
 | |
| function keyDatabase(db, key) {
 | |
|   // https://www.zetetic.net/sqlcipher/sqlcipher-api/#key
 | |
|   // If the password isn't hex then we need to derive a key from it
 | |
| 
 | |
|   const deriveKey = HEX_KEY.test(key);
 | |
| 
 | |
|   const value = deriveKey ? `'${key}'` : `"x'${key}'"`;
 | |
| 
 | |
|   const pragramToRun = `key = ${value}`;
 | |
| 
 | |
|   db.pragma(pragramToRun);
 | |
| }
 | |
| 
 | |
| function switchToWAL(db) {
 | |
|   // https://sqlite.org/wal.html
 | |
|   db.pragma('journal_mode = WAL');
 | |
|   db.pragma('synchronous = FULL');
 | |
| }
 | |
| 
 | |
| function getSQLIntegrityCheck(db) {
 | |
|   const checkResult = db.pragma('quick_check', { simple: true });
 | |
|   if (checkResult !== 'ok') {
 | |
|     return checkResult;
 | |
|   }
 | |
| 
 | |
|   return undefined;
 | |
| }
 | |
| 
 | |
| const HEX_KEY = /[^0-9A-Fa-f]/;
 | |
| 
 | |
| function migrateSchemaVersion(db) {
 | |
|   const userVersion = getUserVersion(db);
 | |
|   if (userVersion > 0) {
 | |
|     return;
 | |
|   }
 | |
|   const schemaVersion = getSchemaVersion(db);
 | |
| 
 | |
|   const newUserVersion = schemaVersion > 18 ? 16 : schemaVersion;
 | |
|   console.log(
 | |
|     'migrateSchemaVersion: Migrating from schema_version ' +
 | |
|       `${schemaVersion} to user_version ${newUserVersion}`
 | |
|   );
 | |
| 
 | |
|   setUserVersion(db, newUserVersion);
 | |
| }
 | |
| 
 | |
| function getUserVersion(db) {
 | |
|   try {
 | |
|     return db.pragma('user_version', { simple: true });
 | |
|   } catch (e) {
 | |
|     console.warn('getUserVersion error', e);
 | |
|     return 0;
 | |
|   }
 | |
| }
 | |
| 
 | |
| function setUserVersion(db, version) {
 | |
|   if (!isNumber(version)) {
 | |
|     throw new Error(`setUserVersion: version ${version} is not a number`);
 | |
|   }
 | |
| 
 | |
|   db.pragma(`user_version = ${version}`);
 | |
| }
 | |
| 
 | |
| function openAndMigrateDatabase(filePath, key) {
 | |
|   let db;
 | |
| 
 | |
|   // First, we try to open the database without any cipher changes
 | |
|   try {
 | |
|     db = new SQL(filePath, { verbose: null });
 | |
| 
 | |
|     keyDatabase(db, key);
 | |
|     switchToWAL(db);
 | |
|     migrateSchemaVersion(db);
 | |
|     db.pragma('secure_delete = ON');
 | |
| 
 | |
|     return db;
 | |
|   } catch (error) {
 | |
|     if (db) {
 | |
|       db.close();
 | |
|     }
 | |
|     console.log('migrateDatabase: Migration without cipher change failed', error);
 | |
|   }
 | |
| 
 | |
|   // If that fails, we try to open the database with 3.x compatibility to extract the
 | |
|   //   user_version (previously stored in schema_version, blown away by cipher_migrate).
 | |
| 
 | |
|   let db1;
 | |
|   try {
 | |
|     db1 = new SQL(filePath, { verbose: null });
 | |
|     keyDatabase(db1, key);
 | |
| 
 | |
|     // https://www.zetetic.net/blog/2018/11/30/sqlcipher-400-release/#compatability-sqlcipher-4-0-0
 | |
|     db1.pragma('cipher_compatibility = 3');
 | |
|     migrateSchemaVersion(db1);
 | |
|     db1.close();
 | |
|   } catch (error) {
 | |
|     if (db1) {
 | |
|       db1.close();
 | |
|     }
 | |
|     console.log('migrateDatabase: migrateSchemaVersion failed', error);
 | |
|     return null;
 | |
|   }
 | |
|   // After migrating user_version -> schema_version, we reopen database, because we can't
 | |
|   //   migrate to the latest ciphers after we've modified the defaults.
 | |
|   let db2;
 | |
|   try {
 | |
|     db2 = new SQL(filePath, { verbose: null });
 | |
|     keyDatabase(db2, key);
 | |
| 
 | |
|     db2.pragma('cipher_migrate');
 | |
|     switchToWAL(db2);
 | |
| 
 | |
|     // Because foreign key support is not enabled by default!
 | |
|     db2.pragma('foreign_keys = OFF');
 | |
| 
 | |
|     return db2;
 | |
|   } catch (error) {
 | |
|     if (db2) {
 | |
|       db2.close();
 | |
|     }
 | |
|     console.log('migrateDatabase: switchToWAL failed');
 | |
|     return null;
 | |
|   }
 | |
| }
 | |
| 
 | |
| function openAndSetUpSQLCipher(filePath, { key }) {
 | |
|   return openAndMigrateDatabase(filePath, key);
 | |
| }
 | |
| 
 | |
| function setSQLPassword(password) {
 | |
|   if (!globalInstance) {
 | |
|     throw new Error('setSQLPassword: db is not initialized');
 | |
|   }
 | |
| 
 | |
|   // If the password isn't hex then we need to derive a key from it
 | |
|   const deriveKey = HEX_KEY.test(password);
 | |
|   const value = deriveKey ? `'${password}'` : `"x'${password}'"`;
 | |
|   globalInstance.pragma(`rekey = ${value}`);
 | |
| }
 | |
| 
 | |
| function vacuumDatabase(db) {
 | |
|   if (!db) {
 | |
|     throw new Error('vacuum: db is not initialized');
 | |
|   }
 | |
|   console.time('vaccumming db');
 | |
|   console.warn('Vacuuming DB. This might take a while.');
 | |
|   db.exec('VACUUM;');
 | |
|   console.warn('Vacuuming DB Finished');
 | |
|   console.timeEnd('vaccumming db');
 | |
| }
 | |
| 
 | |
| function updateToSchemaVersion1(currentVersion, db) {
 | |
|   if (currentVersion >= 1) {
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   console.log('updateToSchemaVersion1: starting...');
 | |
| 
 | |
|   db.transaction(() => {
 | |
|     db.exec(
 | |
|       `CREATE TABLE ${MESSAGES_TABLE}(
 | |
|       id STRING PRIMARY KEY ASC,
 | |
|       json TEXT,
 | |
| 
 | |
|       unread INTEGER,
 | |
|       expires_at INTEGER,
 | |
|       sent BOOLEAN,
 | |
|       sent_at INTEGER,
 | |
|       schemaVersion INTEGER,
 | |
|       conversationId STRING,
 | |
|       received_at INTEGER,
 | |
|       source STRING,
 | |
|       sourceDevice STRING,
 | |
|       hasAttachments INTEGER,
 | |
|       hasFileAttachments INTEGER,
 | |
|       hasVisualMediaAttachments INTEGER
 | |
|     );
 | |
| 
 | |
|     CREATE INDEX messages_unread ON ${MESSAGES_TABLE} (
 | |
|       unread
 | |
|     );
 | |
| 
 | |
|     CREATE INDEX messages_expires_at ON ${MESSAGES_TABLE} (
 | |
|       expires_at
 | |
|     );
 | |
| 
 | |
|     CREATE INDEX messages_receipt ON ${MESSAGES_TABLE} (
 | |
|       sent_at
 | |
|     );
 | |
| 
 | |
|     CREATE INDEX messages_schemaVersion ON ${MESSAGES_TABLE} (
 | |
|       schemaVersion
 | |
|     );
 | |
| 
 | |
|     CREATE INDEX messages_conversation ON ${MESSAGES_TABLE} (
 | |
|       conversationId,
 | |
|       received_at
 | |
|     );
 | |
| 
 | |
|     CREATE INDEX messages_duplicate_check ON ${MESSAGES_TABLE} (
 | |
|       source,
 | |
|       sourceDevice,
 | |
|       sent_at
 | |
|     );
 | |
| 
 | |
|     CREATE INDEX messages_hasAttachments ON ${MESSAGES_TABLE} (
 | |
|       conversationId,
 | |
|       hasAttachments,
 | |
|       received_at
 | |
|     );
 | |
| 
 | |
|     CREATE INDEX messages_hasFileAttachments ON ${MESSAGES_TABLE} (
 | |
|       conversationId,
 | |
|       hasFileAttachments,
 | |
|       received_at
 | |
|     );
 | |
| 
 | |
|     CREATE INDEX messages_hasVisualMediaAttachments ON ${MESSAGES_TABLE} (
 | |
|       conversationId,
 | |
|       hasVisualMediaAttachments,
 | |
|       received_at
 | |
|     );
 | |
| 
 | |
|     CREATE TABLE unprocessed(
 | |
|       id STRING,
 | |
|       timestamp INTEGER,
 | |
|       json TEXT
 | |
|     );
 | |
| 
 | |
|     CREATE INDEX unprocessed_id ON unprocessed (
 | |
|       id
 | |
|     );
 | |
| 
 | |
|     CREATE INDEX unprocessed_timestamp ON unprocessed (
 | |
|       timestamp
 | |
|     );
 | |
| 
 | |
| 
 | |
|     `
 | |
|     );
 | |
|     db.pragma('user_version = 1');
 | |
|   })();
 | |
| 
 | |
|   console.log('updateToSchemaVersion1: success!');
 | |
| }
 | |
| 
 | |
| function updateToSchemaVersion2(currentVersion, db) {
 | |
|   if (currentVersion >= 2) {
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   console.log('updateToSchemaVersion2: starting...');
 | |
| 
 | |
|   db.transaction(() => {
 | |
|     db.exec(`ALTER TABLE ${MESSAGES_TABLE}
 | |
|      ADD COLUMN expireTimer INTEGER;
 | |
| 
 | |
|      ALTER TABLE ${MESSAGES_TABLE}
 | |
|      ADD COLUMN expirationStartTimestamp INTEGER;
 | |
| 
 | |
|      ALTER TABLE ${MESSAGES_TABLE}
 | |
|      ADD COLUMN type STRING;
 | |
| 
 | |
|      CREATE INDEX messages_expiring ON ${MESSAGES_TABLE} (
 | |
|       expireTimer,
 | |
|       expirationStartTimestamp,
 | |
|       expires_at
 | |
|     );
 | |
| 
 | |
|     UPDATE ${MESSAGES_TABLE} SET
 | |
|       expirationStartTimestamp = json_extract(json, '$.expirationStartTimestamp'),
 | |
|       expireTimer = json_extract(json, '$.expireTimer'),
 | |
|       type = json_extract(json, '$.type');
 | |
| 
 | |
| 
 | |
|      `);
 | |
|     db.pragma('user_version = 2');
 | |
|   })();
 | |
| 
 | |
|   console.log('updateToSchemaVersion2: success!');
 | |
| }
 | |
| 
 | |
| function updateToSchemaVersion3(currentVersion, db) {
 | |
|   if (currentVersion >= 3) {
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   console.log('updateToSchemaVersion3: starting...');
 | |
| 
 | |
|   db.transaction(() => {
 | |
|     db.exec(`
 | |
|     DROP INDEX messages_expiring;
 | |
|     DROP INDEX messages_unread;
 | |
| 
 | |
|     CREATE INDEX messages_without_timer ON ${MESSAGES_TABLE} (
 | |
|       expireTimer,
 | |
|       expires_at,
 | |
|       type
 | |
|     ) WHERE expires_at IS NULL AND expireTimer IS NOT NULL;
 | |
| 
 | |
|     CREATE INDEX messages_unread ON ${MESSAGES_TABLE} (
 | |
|       conversationId,
 | |
|       unread
 | |
|     ) WHERE unread IS NOT NULL;
 | |
| 
 | |
|     ANALYZE;
 | |
| 
 | |
|     `);
 | |
|     db.pragma('user_version = 3');
 | |
|   })();
 | |
| 
 | |
|   console.log('updateToSchemaVersion3: success!');
 | |
| }
 | |
| 
 | |
| function updateToSchemaVersion4(currentVersion, db) {
 | |
|   if (currentVersion >= 4) {
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   console.log('updateToSchemaVersion4: starting...');
 | |
| 
 | |
|   db.transaction(() => {
 | |
|     db.exec(`
 | |
| 
 | |
|     CREATE TABLE ${CONVERSATIONS_TABLE}(
 | |
|       id STRING PRIMARY KEY ASC,
 | |
|       json TEXT,
 | |
| 
 | |
|       active_at INTEGER,
 | |
|       type STRING,
 | |
|       members TEXT,
 | |
|       name TEXT,
 | |
|       profileName TEXT
 | |
|     );
 | |
| 
 | |
|     CREATE INDEX conversations_active ON ${CONVERSATIONS_TABLE} (
 | |
|       active_at
 | |
|     ) WHERE active_at IS NOT NULL;
 | |
|     CREATE INDEX conversations_type ON ${CONVERSATIONS_TABLE} (
 | |
|       type
 | |
|     ) WHERE type IS NOT NULL;
 | |
| 
 | |
|     `);
 | |
| 
 | |
|     db.pragma('user_version = 4');
 | |
|   })();
 | |
| 
 | |
|   console.log('updateToSchemaVersion4: success!');
 | |
| }
 | |
| 
 | |
| function updateToSchemaVersion6(currentVersion, db) {
 | |
|   if (currentVersion >= 6) {
 | |
|     return;
 | |
|   }
 | |
|   console.log('updateToSchemaVersion6: starting...');
 | |
|   db.transaction(() => {
 | |
|     db.exec(`
 | |
|     CREATE TABLE lastHashes(
 | |
|       snode TEXT PRIMARY KEY,
 | |
|       hash TEXT,
 | |
|       expiresAt INTEGER
 | |
|     );
 | |
| 
 | |
|     CREATE TABLE seenMessages(
 | |
|       hash TEXT PRIMARY KEY,
 | |
|       expiresAt INTEGER
 | |
|     );
 | |
| 
 | |
| 
 | |
|     CREATE TABLE sessions(
 | |
|       id STRING PRIMARY KEY ASC,
 | |
|       number STRING,
 | |
|       json TEXT
 | |
|     );
 | |
| 
 | |
|     CREATE INDEX sessions_number ON sessions (
 | |
|       number
 | |
|     ) WHERE number IS NOT NULL;
 | |
| 
 | |
|     CREATE TABLE groups(
 | |
|       id STRING PRIMARY KEY ASC,
 | |
|       json TEXT
 | |
|     );
 | |
| 
 | |
| 
 | |
|     CREATE TABLE ${IDENTITY_KEYS_TABLE}(
 | |
|       id STRING PRIMARY KEY ASC,
 | |
|       json TEXT
 | |
|     );
 | |
| 
 | |
|     CREATE TABLE ${ITEMS_TABLE}(
 | |
|       id STRING PRIMARY KEY ASC,
 | |
|       json TEXT
 | |
|     );
 | |
| 
 | |
| 
 | |
|     CREATE TABLE preKeys(
 | |
|       id INTEGER PRIMARY KEY ASC,
 | |
|       recipient STRING,
 | |
|       json TEXT
 | |
|     );
 | |
| 
 | |
| 
 | |
|     CREATE TABLE signedPreKeys(
 | |
|       id INTEGER PRIMARY KEY ASC,
 | |
|       json TEXT
 | |
|     );
 | |
| 
 | |
|     CREATE TABLE contactPreKeys(
 | |
|       id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
 | |
|       identityKeyString VARCHAR(255),
 | |
|       keyId INTEGER,
 | |
|       json TEXT
 | |
|     );
 | |
| 
 | |
|     CREATE UNIQUE INDEX contact_prekey_identity_key_string_keyid ON contactPreKeys (
 | |
|       identityKeyString,
 | |
|       keyId
 | |
|     );
 | |
| 
 | |
|     CREATE TABLE contactSignedPreKeys(
 | |
|       id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
 | |
|       identityKeyString VARCHAR(255),
 | |
|       keyId INTEGER,
 | |
|       json TEXT
 | |
|     );
 | |
| 
 | |
|     CREATE UNIQUE INDEX contact_signed_prekey_identity_key_string_keyid ON contactSignedPreKeys (
 | |
|       identityKeyString,
 | |
|       keyId
 | |
|     );
 | |
| 
 | |
|     `);
 | |
|     db.pragma('user_version = 6');
 | |
|   })();
 | |
| 
 | |
|   console.log('updateToSchemaVersion6: success!');
 | |
| }
 | |
| 
 | |
| function updateToSchemaVersion7(currentVersion, db) {
 | |
|   if (currentVersion >= 7) {
 | |
|     return;
 | |
|   }
 | |
|   console.log('updateToSchemaVersion7: starting...');
 | |
| 
 | |
|   db.transaction(() => {
 | |
|     db.exec(`
 | |
|       -- SQLite has been coercing our STRINGs into numbers, so we force it with TEXT
 | |
|       -- We create a new table then copy the data into it, since we can't modify columns
 | |
|       DROP INDEX sessions_number;
 | |
|       ALTER TABLE sessions RENAME TO sessions_old;
 | |
| 
 | |
|       CREATE TABLE sessions(
 | |
|         id TEXT PRIMARY KEY,
 | |
|         number TEXT,
 | |
|         json TEXT
 | |
|       );
 | |
|       CREATE INDEX sessions_number ON sessions (
 | |
|         number
 | |
|       ) WHERE number IS NOT NULL;
 | |
|       INSERT INTO sessions(id, number, json)
 | |
|     SELECT "+" || id, number, json FROM sessions_old;
 | |
|       DROP TABLE sessions_old;
 | |
|     `);
 | |
| 
 | |
|     db.pragma('user_version = 7');
 | |
|   })();
 | |
| 
 | |
|   console.log('updateToSchemaVersion7: success!');
 | |
| }
 | |
| 
 | |
| function updateToSchemaVersion8(currentVersion, db) {
 | |
|   if (currentVersion >= 8) {
 | |
|     return;
 | |
|   }
 | |
|   console.log('updateToSchemaVersion8: starting...');
 | |
| 
 | |
|   db.transaction(() => {
 | |
|     db.exec(`
 | |
|     -- First, we pull a new body field out of the message table's json blob
 | |
|     ALTER TABLE ${MESSAGES_TABLE}
 | |
|       ADD COLUMN body TEXT;
 | |
|     UPDATE ${MESSAGES_TABLE} SET body = json_extract(json, '$.body');
 | |
| 
 | |
|     -- Then we create our full-text search table and populate it
 | |
|     CREATE VIRTUAL TABLE ${MESSAGES_FTS_TABLE}
 | |
|       USING fts5(id UNINDEXED, body);
 | |
| 
 | |
|     INSERT INTO ${MESSAGES_FTS_TABLE}(id, body)
 | |
|       SELECT id, 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} (
 | |
|         id,
 | |
|         body
 | |
|       ) VALUES (
 | |
|         new.id,
 | |
|         new.body
 | |
|       );
 | |
|     END;
 | |
|     CREATE TRIGGER messages_on_delete AFTER DELETE ON ${MESSAGES_TABLE} BEGIN
 | |
|       DELETE FROM ${MESSAGES_FTS_TABLE} WHERE id = old.id;
 | |
|     END;
 | |
|     CREATE TRIGGER messages_on_update AFTER UPDATE ON ${MESSAGES_TABLE} BEGIN
 | |
|       DELETE FROM ${MESSAGES_FTS_TABLE} WHERE id = old.id;
 | |
|       INSERT INTO ${MESSAGES_FTS_TABLE}(
 | |
|         id,
 | |
|         body
 | |
|       ) VALUES (
 | |
|         new.id,
 | |
|         new.body
 | |
|       );
 | |
|     END;
 | |
| 
 | |
|     `);
 | |
|     // For formatting search results:
 | |
|     //   https://sqlite.org/fts5.html#the_highlight_function
 | |
|     //   https://sqlite.org/fts5.html#the_snippet_function
 | |
|     db.pragma('user_version = 8');
 | |
|   })();
 | |
| 
 | |
|   console.log('updateToSchemaVersion8: success!');
 | |
| }
 | |
| 
 | |
| function updateToSchemaVersion9(currentVersion, db) {
 | |
|   if (currentVersion >= 9) {
 | |
|     return;
 | |
|   }
 | |
|   console.log('updateToSchemaVersion9: starting...');
 | |
|   db.transaction(() => {
 | |
|     db.exec(`
 | |
|       CREATE TABLE ${ATTACHMENT_DOWNLOADS_TABLE}(
 | |
|         id STRING primary key,
 | |
|         timestamp INTEGER,
 | |
|         pending INTEGER,
 | |
|         json TEXT
 | |
|       );
 | |
| 
 | |
|       CREATE INDEX attachment_downloads_timestamp
 | |
|         ON ${ATTACHMENT_DOWNLOADS_TABLE} (
 | |
|           timestamp
 | |
|       ) WHERE pending = 0;
 | |
|       CREATE INDEX attachment_downloads_pending
 | |
|         ON ${ATTACHMENT_DOWNLOADS_TABLE} (
 | |
|           pending
 | |
|       ) WHERE pending != 0;
 | |
|     `);
 | |
| 
 | |
|     db.pragma('user_version = 9');
 | |
|   })();
 | |
| 
 | |
|   console.log('updateToSchemaVersion9: success!');
 | |
| }
 | |
| 
 | |
| function updateToSchemaVersion10(currentVersion, db) {
 | |
|   if (currentVersion >= 10) {
 | |
|     return;
 | |
|   }
 | |
|   console.log('updateToSchemaVersion10: starting...');
 | |
| 
 | |
|   db.transaction(() => {
 | |
|     db.exec(`
 | |
|       DROP INDEX unprocessed_id;
 | |
|       DROP INDEX unprocessed_timestamp;
 | |
|       ALTER TABLE unprocessed RENAME TO unprocessed_old;
 | |
| 
 | |
|       CREATE TABLE unprocessed(
 | |
|         id STRING,
 | |
|         timestamp INTEGER,
 | |
|         version INTEGER,
 | |
|         attempts INTEGER,
 | |
|         envelope TEXT,
 | |
|         decrypted TEXT,
 | |
|         source TEXT,
 | |
|         sourceDevice TEXT,
 | |
|         serverTimestamp INTEGER
 | |
|       );
 | |
| 
 | |
|       CREATE INDEX unprocessed_id ON unprocessed (
 | |
|         id
 | |
|       );
 | |
|       CREATE INDEX unprocessed_timestamp ON unprocessed (
 | |
|         timestamp
 | |
|       );
 | |
| 
 | |
|       INSERT INTO unprocessed (
 | |
|         id,
 | |
|         timestamp,
 | |
|         version,
 | |
|         attempts,
 | |
|         envelope,
 | |
|         decrypted,
 | |
|         source,
 | |
|         sourceDevice,
 | |
|         serverTimestamp
 | |
|       ) SELECT
 | |
|         id,
 | |
|         timestamp,
 | |
|         json_extract(json, '$.version'),
 | |
|         json_extract(json, '$.attempts'),
 | |
|         json_extract(json, '$.envelope'),
 | |
|         json_extract(json, '$.decrypted'),
 | |
|         json_extract(json, '$.source'),
 | |
|         json_extract(json, '$.sourceDevice'),
 | |
|         json_extract(json, '$.serverTimestamp')
 | |
|       FROM unprocessed_old;
 | |
| 
 | |
|       DROP TABLE unprocessed_old;
 | |
|     `);
 | |
| 
 | |
|     db.pragma('user_version = 10');
 | |
|   })();
 | |
|   console.log('updateToSchemaVersion10: success!');
 | |
| }
 | |
| 
 | |
| function updateToSchemaVersion11(currentVersion, db) {
 | |
|   if (currentVersion >= 11) {
 | |
|     return;
 | |
|   }
 | |
|   console.log('updateToSchemaVersion11: starting...');
 | |
|   db.transaction(() => {
 | |
|     db.exec(`
 | |
|       DROP TABLE groups;
 | |
|     `);
 | |
| 
 | |
|     db.pragma('user_version = 11');
 | |
|   })();
 | |
|   console.log('updateToSchemaVersion11: success!');
 | |
| }
 | |
| 
 | |
| const SCHEMA_VERSIONS = [
 | |
|   updateToSchemaVersion1,
 | |
|   updateToSchemaVersion2,
 | |
|   updateToSchemaVersion3,
 | |
|   updateToSchemaVersion4,
 | |
|   () => null, // version 5 was dropped
 | |
|   updateToSchemaVersion6,
 | |
|   updateToSchemaVersion7,
 | |
|   updateToSchemaVersion8,
 | |
|   updateToSchemaVersion9,
 | |
|   updateToSchemaVersion10,
 | |
|   updateToSchemaVersion11,
 | |
| ];
 | |
| 
 | |
| function updateSchema(db) {
 | |
|   const sqliteVersion = getSQLiteVersion(db);
 | |
|   const sqlcipherVersion = getSQLCipherVersion(db);
 | |
|   const userVersion = getUserVersion(db);
 | |
|   const maxUserVersion = SCHEMA_VERSIONS.length;
 | |
|   const schemaVersion = getSchemaVersion(db);
 | |
| 
 | |
|   console.log('updateSchema:');
 | |
|   console.log(` Current user_version: ${userVersion}`);
 | |
|   console.log(` Most recent db schema: ${maxUserVersion}`);
 | |
|   console.log(` SQLite version: ${sqliteVersion}`);
 | |
|   console.log(` SQLCipher version: ${sqlcipherVersion}`);
 | |
|   console.log(` (deprecated) schema_version: ${schemaVersion}`);
 | |
| 
 | |
|   for (let index = 0, max = SCHEMA_VERSIONS.length; index < max; index += 1) {
 | |
|     const runSchemaUpdate = SCHEMA_VERSIONS[index];
 | |
|     runSchemaUpdate(schemaVersion, db);
 | |
|   }
 | |
|   updateLokiSchema(db);
 | |
| }
 | |
| 
 | |
| const LOKI_SCHEMA_VERSIONS = [
 | |
|   updateToLokiSchemaVersion1,
 | |
|   updateToLokiSchemaVersion2,
 | |
|   updateToLokiSchemaVersion3,
 | |
|   updateToLokiSchemaVersion4,
 | |
|   updateToLokiSchemaVersion5,
 | |
|   updateToLokiSchemaVersion6,
 | |
|   updateToLokiSchemaVersion7,
 | |
|   updateToLokiSchemaVersion8,
 | |
|   updateToLokiSchemaVersion9,
 | |
|   updateToLokiSchemaVersion10,
 | |
|   updateToLokiSchemaVersion11,
 | |
|   updateToLokiSchemaVersion12,
 | |
|   updateToLokiSchemaVersion13,
 | |
|   updateToLokiSchemaVersion14,
 | |
|   updateToLokiSchemaVersion15,
 | |
|   updateToLokiSchemaVersion16,
 | |
| ];
 | |
| 
 | |
| function updateToLokiSchemaVersion1(currentVersion, db) {
 | |
|   const targetVersion = 1;
 | |
|   if (currentVersion >= targetVersion) {
 | |
|     return;
 | |
|   }
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: starting...`);
 | |
|   db.transaction(() => {
 | |
|     db.exec(`
 | |
|     ALTER TABLE ${MESSAGES_TABLE}
 | |
|     ADD COLUMN serverId INTEGER;
 | |
| 
 | |
|     CREATE TABLE servers(
 | |
|       serverUrl STRING PRIMARY KEY ASC,
 | |
|       token TEXT
 | |
|     );
 | |
|     `);
 | |
|     writeLokiSchemaVersion(targetVersion, db);
 | |
|   })();
 | |
| 
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: success!`);
 | |
| }
 | |
| 
 | |
| function updateToLokiSchemaVersion2(currentVersion, db) {
 | |
|   const targetVersion = 2;
 | |
| 
 | |
|   if (currentVersion >= targetVersion) {
 | |
|     return;
 | |
|   }
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: starting...`);
 | |
| 
 | |
|   db.transaction(() => {
 | |
|     db.exec(`
 | |
|     CREATE TABLE pairingAuthorisations(
 | |
|       id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
 | |
|       primaryDevicePubKey VARCHAR(255),
 | |
|       secondaryDevicePubKey VARCHAR(255),
 | |
|       isGranted BOOLEAN,
 | |
|       json TEXT,
 | |
|       UNIQUE(primaryDevicePubKey, secondaryDevicePubKey)
 | |
|     );
 | |
|     `);
 | |
|     writeLokiSchemaVersion(targetVersion, db);
 | |
|   })();
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: success!`);
 | |
| }
 | |
| 
 | |
| function updateToLokiSchemaVersion3(currentVersion, db) {
 | |
|   const targetVersion = 3;
 | |
| 
 | |
|   if (currentVersion >= targetVersion) {
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: starting...`);
 | |
| 
 | |
|   db.transaction(() => {
 | |
|     db.exec(`
 | |
|     CREATE TABLE ${GUARD_NODE_TABLE}(
 | |
|       id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
 | |
|       ed25519PubKey VARCHAR(64)
 | |
|     );
 | |
|     `);
 | |
|     writeLokiSchemaVersion(targetVersion, db);
 | |
|   })();
 | |
| 
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: success!`);
 | |
| }
 | |
| 
 | |
| function updateToLokiSchemaVersion4(currentVersion, db) {
 | |
|   const targetVersion = 4;
 | |
|   if (currentVersion >= targetVersion) {
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: starting...`);
 | |
| 
 | |
|   db.transaction(() => {
 | |
|     db.exec(`
 | |
|     DROP TABLE lastHashes;
 | |
|     CREATE TABLE lastHashes(
 | |
|       id TEXT,
 | |
|       snode TEXT,
 | |
|       hash TEXT,
 | |
|       expiresAt INTEGER,
 | |
|       PRIMARY KEY (id, snode)
 | |
|     );
 | |
|     -- Add senderIdentity field to unprocessed needed for medium size groups
 | |
|     ALTER TABLE unprocessed ADD senderIdentity TEXT;
 | |
|     `);
 | |
|     writeLokiSchemaVersion(targetVersion, db);
 | |
|   })();
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: success!`);
 | |
| }
 | |
| 
 | |
| function updateToLokiSchemaVersion5(currentVersion, db) {
 | |
|   const targetVersion = 5;
 | |
|   if (currentVersion >= targetVersion) {
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: starting...`);
 | |
| 
 | |
|   db.transaction(() => {
 | |
|     db.exec(`
 | |
|     CREATE TABLE ${NODES_FOR_PUBKEY_TABLE} (
 | |
|       pubkey TEXT PRIMARY KEY,
 | |
|       json TEXT
 | |
|     );
 | |
| 
 | |
|     `);
 | |
|     writeLokiSchemaVersion(targetVersion, db);
 | |
|   })();
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: success!`);
 | |
| }
 | |
| 
 | |
| function updateToLokiSchemaVersion6(currentVersion, db) {
 | |
|   const targetVersion = 6;
 | |
|   if (currentVersion >= targetVersion) {
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: starting...`);
 | |
| 
 | |
|   db.transaction(() => {
 | |
|     db.exec(`
 | |
|     -- Remove RSS Feed conversations
 | |
|     DELETE FROM ${CONVERSATIONS_TABLE} WHERE
 | |
|     type = 'group' AND
 | |
|     id LIKE 'rss://%';
 | |
| 
 | |
|     `);
 | |
|     writeLokiSchemaVersion(targetVersion, db);
 | |
|   })();
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: success!`);
 | |
| }
 | |
| 
 | |
| function updateToLokiSchemaVersion7(currentVersion, db) {
 | |
|   const targetVersion = 7;
 | |
|   if (currentVersion >= targetVersion) {
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: starting...`);
 | |
| 
 | |
|   db.transaction(() => {
 | |
|     db.exec(`
 | |
|     -- Remove multi device data
 | |
| 
 | |
|     DELETE FROM pairingAuthorisations;
 | |
|     `);
 | |
|     writeLokiSchemaVersion(targetVersion, db);
 | |
|   })();
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: success!`);
 | |
| }
 | |
| 
 | |
| function updateToLokiSchemaVersion8(currentVersion, db) {
 | |
|   const targetVersion = 8;
 | |
|   if (currentVersion >= targetVersion) {
 | |
|     return;
 | |
|   }
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: starting...`);
 | |
| 
 | |
|   db.transaction(() => {
 | |
|     db.exec(`
 | |
| 
 | |
|     ALTER TABLE ${MESSAGES_TABLE}
 | |
|     ADD COLUMN serverTimestamp INTEGER;
 | |
|     `);
 | |
|     writeLokiSchemaVersion(targetVersion, db);
 | |
|   })();
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: success!`);
 | |
| }
 | |
| 
 | |
| function updateToLokiSchemaVersion9(currentVersion, db) {
 | |
|   const targetVersion = 9;
 | |
|   if (currentVersion >= targetVersion) {
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: starting...`);
 | |
|   db.transaction(() => {
 | |
|     const rows = db
 | |
|       .prepare(
 | |
|         `SELECT json FROM ${CONVERSATIONS_TABLE} WHERE
 | |
|       type = 'group' AND
 | |
|       id LIKE '__textsecure_group__!%';
 | |
|     `
 | |
|       )
 | |
|       .all();
 | |
| 
 | |
|     const objs = map(rows, row => jsonToObject(row.json));
 | |
| 
 | |
|     const conversationIdRows = db
 | |
|       .prepare(`SELECT id FROM ${CONVERSATIONS_TABLE} ORDER BY id ASC;`)
 | |
|       .all();
 | |
| 
 | |
|     const allOldConversationIds = map(conversationIdRows, row => row.id);
 | |
|     objs.forEach(o => {
 | |
|       const oldId = o.id;
 | |
|       const newId = oldId.replace('__textsecure_group__!', '');
 | |
|       console.log(`migrating conversation, ${oldId} to ${newId}`);
 | |
| 
 | |
|       if (allOldConversationIds.includes(newId)) {
 | |
|         console.log(
 | |
|           'Found a duplicate conversation after prefix removing. We need to take care of it'
 | |
|         );
 | |
|         // We have another conversation with the same future name.
 | |
|         // We decided to keep only the conversation with the higher number of messages
 | |
|         const countMessagesOld = getMessagesCountByConversation(db, oldId, {
 | |
|           limit: Number.MAX_VALUE,
 | |
|         });
 | |
|         const countMessagesNew = getMessagesCountByConversation(db, newId, {
 | |
|           limit: Number.MAX_VALUE,
 | |
|         });
 | |
| 
 | |
|         console.log(`countMessagesOld: ${countMessagesOld}, countMessagesNew: ${countMessagesNew}`);
 | |
| 
 | |
|         const deleteId = countMessagesOld > countMessagesNew ? newId : oldId;
 | |
|         db.prepare(`DELETE FROM ${CONVERSATIONS_TABLE} WHERE id = $deleteId;`).run({ deleteId });
 | |
|       }
 | |
| 
 | |
|       const morphedObject = {
 | |
|         ...o,
 | |
|         id: newId,
 | |
|       };
 | |
| 
 | |
|       db.prepare(
 | |
|         `UPDATE ${CONVERSATIONS_TABLE} SET
 | |
|         id = $newId,
 | |
|         json = $json
 | |
|         WHERE id = $oldId;`
 | |
|       ).run({
 | |
|         newId,
 | |
|         json: objectToJSON(morphedObject),
 | |
|         oldId,
 | |
|       });
 | |
|     });
 | |
| 
 | |
|     writeLokiSchemaVersion(targetVersion, db);
 | |
|   })();
 | |
| 
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: success!`);
 | |
| }
 | |
| 
 | |
| function updateToLokiSchemaVersion10(currentVersion, db) {
 | |
|   const targetVersion = 10;
 | |
|   if (currentVersion >= targetVersion) {
 | |
|     return;
 | |
|   }
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: starting...`);
 | |
| 
 | |
|   db.transaction(() => {
 | |
|     db.exec(`
 | |
|     CREATE TABLE ${CLOSED_GROUP_V2_KEY_PAIRS_TABLE} (
 | |
|       id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
 | |
|       groupPublicKey TEXT,
 | |
|       timestamp NUMBER,
 | |
|       json TEXT
 | |
|     );
 | |
| 
 | |
|     `);
 | |
|     writeLokiSchemaVersion(targetVersion, db);
 | |
|   })();
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: success!`);
 | |
| }
 | |
| 
 | |
| function updateToLokiSchemaVersion11(currentVersion, db) {
 | |
|   const targetVersion = 11;
 | |
|   if (currentVersion >= targetVersion) {
 | |
|     return;
 | |
|   }
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: starting...`);
 | |
| 
 | |
|   db.transaction(() => {
 | |
|     updateExistingClosedGroupV1ToClosedGroupV2(db);
 | |
|     writeLokiSchemaVersion(targetVersion, db);
 | |
|   })();
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: success!`);
 | |
| }
 | |
| 
 | |
| function updateToLokiSchemaVersion12(currentVersion, db) {
 | |
|   const targetVersion = 12;
 | |
|   if (currentVersion >= targetVersion) {
 | |
|     return;
 | |
|   }
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: starting...`);
 | |
| 
 | |
|   db.transaction(() => {
 | |
|     db.exec(`
 | |
|     CREATE TABLE ${OPEN_GROUP_ROOMS_V2_TABLE} (
 | |
|       serverUrl TEXT NOT NULL,
 | |
|       roomId TEXT NOT NULL,
 | |
|       conversationId TEXT,
 | |
|       json TEXT,
 | |
|       PRIMARY KEY (serverUrl, roomId)
 | |
|     );
 | |
| 
 | |
|     `);
 | |
|     writeLokiSchemaVersion(targetVersion, db);
 | |
|   })();
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: success!`);
 | |
| }
 | |
| 
 | |
| function updateToLokiSchemaVersion13(currentVersion, db) {
 | |
|   const targetVersion = 13;
 | |
|   if (currentVersion >= targetVersion) {
 | |
|     return;
 | |
|   }
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: starting...`);
 | |
| 
 | |
|   // Clear any already deleted db entries.
 | |
|   // secure_delete = ON will make sure next deleted entries are overwritten with 0 right away
 | |
|   db.transaction(() => {
 | |
|     db.pragma('secure_delete = ON');
 | |
|     writeLokiSchemaVersion(targetVersion, db);
 | |
|   })();
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: success!`);
 | |
| }
 | |
| 
 | |
| function updateToLokiSchemaVersion14(currentVersion, db) {
 | |
|   const targetVersion = 14;
 | |
|   if (currentVersion >= targetVersion) {
 | |
|     return;
 | |
|   }
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: starting...`);
 | |
| 
 | |
|   db.transaction(() => {
 | |
|     db.exec(`
 | |
|     DROP TABLE IF EXISTS servers;
 | |
|     DROP TABLE IF EXISTS sessions;
 | |
|     DROP TABLE IF EXISTS preKeys;
 | |
|     DROP TABLE IF EXISTS contactPreKeys;
 | |
|     DROP TABLE IF EXISTS contactSignedPreKeys;
 | |
|     DROP TABLE IF EXISTS signedPreKeys;
 | |
|     DROP TABLE IF EXISTS senderKeys;
 | |
|     `);
 | |
|     writeLokiSchemaVersion(targetVersion, db);
 | |
|   })();
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: success!`);
 | |
| }
 | |
| 
 | |
| function updateToLokiSchemaVersion15(currentVersion, db) {
 | |
|   const targetVersion = 15;
 | |
|   if (currentVersion >= targetVersion) {
 | |
|     return;
 | |
|   }
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: starting...`);
 | |
| 
 | |
|   db.transaction(() => {
 | |
|     db.exec(`
 | |
|       DROP TABLE pairingAuthorisations;
 | |
|       DROP TRIGGER messages_on_delete;
 | |
|       DROP TRIGGER messages_on_update;
 | |
|     `);
 | |
| 
 | |
|     writeLokiSchemaVersion(targetVersion, db);
 | |
|   })();
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: success!`);
 | |
| }
 | |
| 
 | |
| function updateToLokiSchemaVersion16(currentVersion, db) {
 | |
|   const targetVersion = 16;
 | |
|   if (currentVersion >= targetVersion) {
 | |
|     return;
 | |
|   }
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: starting...`);
 | |
| 
 | |
|   db.transaction(() => {
 | |
|     db.exec(`
 | |
|       ALTER TABLE ${MESSAGES_TABLE} ADD COLUMN serverHash TEXT;
 | |
|       ALTER TABLE ${MESSAGES_TABLE} ADD COLUMN isDeleted BOOLEAN;
 | |
| 
 | |
|       CREATE INDEX messages_serverHash ON ${MESSAGES_TABLE} (
 | |
|         serverHash
 | |
|       ) WHERE serverHash IS NOT NULL;
 | |
| 
 | |
|       CREATE INDEX messages_isDeleted ON ${MESSAGES_TABLE} (
 | |
|         isDeleted
 | |
|       ) WHERE isDeleted IS NOT NULL;
 | |
| 
 | |
|       ALTER TABLE unprocessed ADD serverHash TEXT;
 | |
|       CREATE INDEX messages_messageHash ON unprocessed (
 | |
|         serverHash
 | |
|       ) WHERE serverHash IS NOT NULL;
 | |
|     `);
 | |
| 
 | |
|     writeLokiSchemaVersion(targetVersion, db);
 | |
|   })();
 | |
|   console.log(`updateToLokiSchemaVersion${targetVersion}: success!`);
 | |
| }
 | |
| 
 | |
| function writeLokiSchemaVersion(newVersion, db) {
 | |
|   db.prepare(
 | |
|     `INSERT INTO loki_schema(
 | |
|       version
 | |
|     ) values (
 | |
|       $newVersion
 | |
|     )`
 | |
|   ).run({ newVersion });
 | |
| }
 | |
| 
 | |
| function updateLokiSchema(db) {
 | |
|   const result = db
 | |
|     .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name='loki_schema';`)
 | |
|     .get();
 | |
| 
 | |
|   if (!result) {
 | |
|     createLokiSchemaTable(db);
 | |
|   }
 | |
|   const lokiSchemaVersion = getLokiSchemaVersion(db);
 | |
|   console.log(
 | |
|     'updateLokiSchema:',
 | |
|     `Current loki schema version: ${lokiSchemaVersion};`,
 | |
|     `Most recent schema version: ${LOKI_SCHEMA_VERSIONS.length};`
 | |
|   );
 | |
|   for (let index = 0, max = LOKI_SCHEMA_VERSIONS.length; index < max; index += 1) {
 | |
|     const runSchemaUpdate = LOKI_SCHEMA_VERSIONS[index];
 | |
|     runSchemaUpdate(lokiSchemaVersion, db);
 | |
|   }
 | |
| }
 | |
| 
 | |
| function getLokiSchemaVersion(db) {
 | |
|   const result = db
 | |
|     .prepare(
 | |
|       `
 | |
|     SELECT MAX(version) as version FROM loki_schema;
 | |
|     `
 | |
|     )
 | |
|     .get();
 | |
|   if (!result || !result.version) {
 | |
|     return 0;
 | |
|   }
 | |
|   return result.version;
 | |
| }
 | |
| 
 | |
| function createLokiSchemaTable(db) {
 | |
|   db.transaction(() => {
 | |
|     db.exec(`
 | |
|     CREATE TABLE loki_schema(
 | |
|       id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
 | |
|       version INTEGER
 | |
|     );
 | |
|     INSERT INTO loki_schema (
 | |
|       version
 | |
|     ) values (
 | |
|       0
 | |
|     );
 | |
|     `);
 | |
|   })();
 | |
| }
 | |
| let globalInstance;
 | |
| 
 | |
| let databaseFilePath;
 | |
| 
 | |
| function _initializePaths(configDir) {
 | |
|   const dbDir = path.join(configDir, 'sql');
 | |
|   mkdirp.sync(dbDir);
 | |
| 
 | |
|   databaseFilePath = path.join(dbDir, 'db.sqlite');
 | |
| }
 | |
| 
 | |
| function initialize({ configDir, key, messages, passwordAttempt }) {
 | |
|   if (globalInstance) {
 | |
|     throw new Error('Cannot initialize more than once!');
 | |
|   }
 | |
| 
 | |
|   if (!isString(configDir)) {
 | |
|     throw new Error('initialize: configDir is required!');
 | |
|   }
 | |
|   if (!isString(key)) {
 | |
|     throw new Error('initialize: key is required!');
 | |
|   }
 | |
|   if (!isObject(messages)) {
 | |
|     throw new Error('initialize: message is required!');
 | |
|   }
 | |
| 
 | |
|   _initializePaths(configDir);
 | |
| 
 | |
|   let db;
 | |
|   try {
 | |
|     db = openAndSetUpSQLCipher(databaseFilePath, { key });
 | |
|     updateSchema(db);
 | |
| 
 | |
|     // test database
 | |
| 
 | |
|     const cipherIntegrityResult = getSQLCipherIntegrityCheck(db);
 | |
|     if (cipherIntegrityResult) {
 | |
|       console.log('Database cipher integrity check failed:', cipherIntegrityResult);
 | |
|       throw new Error(`Cipher integrity check failed: ${cipherIntegrityResult}`);
 | |
|     }
 | |
|     const integrityResult = getSQLIntegrityCheck(db);
 | |
|     if (integrityResult) {
 | |
|       console.log('Database integrity check failed:', integrityResult);
 | |
|       throw new Error(`Integrity check failed: ${integrityResult}`);
 | |
|     }
 | |
| 
 | |
|     // At this point we can allow general access to the database
 | |
|     globalInstance = db;
 | |
| 
 | |
|     // Clear any already deleted db entries on each app start.
 | |
|     vacuumDatabase(db);
 | |
|     const msgCount = getMessageCount();
 | |
|     console.warn('total message count: ', msgCount);
 | |
|   } catch (error) {
 | |
|     if (passwordAttempt) {
 | |
|       throw error;
 | |
|     }
 | |
|     console.log('Database startup error:', error.stack);
 | |
|     const buttonIndex = dialog.showMessageBox({
 | |
|       buttons: [messages.copyErrorAndQuit, messages.clearAllData],
 | |
|       defaultId: 0,
 | |
|       detail: redactAll(error.stack),
 | |
|       message: messages.databaseError,
 | |
|       noLink: true,
 | |
|       type: 'error',
 | |
|     });
 | |
| 
 | |
|     if (buttonIndex === 0) {
 | |
|       clipboard.writeText(`Database startup error:\n\n${redactAll(error.stack)}`);
 | |
|     } else {
 | |
|       close();
 | |
|       removeDB();
 | |
|       removeUserConfig();
 | |
|       app.relaunch();
 | |
|     }
 | |
| 
 | |
|     app.exit(1);
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   return true;
 | |
| }
 | |
| 
 | |
| function close() {
 | |
|   if (!globalInstance) {
 | |
|     return;
 | |
|   }
 | |
|   const dbRef = globalInstance;
 | |
|   globalInstance = null;
 | |
|   // SQLLite documentation suggests that we run `PRAGMA optimize` right before
 | |
|   // closing the database connection.
 | |
|   dbRef.pragma('optimize');
 | |
|   dbRef.close();
 | |
| }
 | |
| 
 | |
| function removeDB(configDir = null) {
 | |
|   if (globalInstance) {
 | |
|     throw new Error('removeDB: Cannot erase database when it is open!');
 | |
|   }
 | |
| 
 | |
|   if (globalInstance) {
 | |
|     throw new Error('removeDB: Cannot erase database when it is open!');
 | |
|   }
 | |
| 
 | |
|   if (!databaseFilePath && configDir) {
 | |
|     _initializePaths(configDir);
 | |
|   }
 | |
| 
 | |
|   rimraf.sync(databaseFilePath);
 | |
|   rimraf.sync(`${databaseFilePath}-shm`);
 | |
|   rimraf.sync(`${databaseFilePath}-wal`);
 | |
| }
 | |
| 
 | |
| // Password hash
 | |
| const PASS_HASH_ID = 'passHash';
 | |
| function getPasswordHash() {
 | |
|   const item = getItemById(PASS_HASH_ID);
 | |
|   return item && item.value;
 | |
| }
 | |
| function savePasswordHash(hash) {
 | |
|   if (isEmpty(hash)) {
 | |
|     return removePasswordHash();
 | |
|   }
 | |
| 
 | |
|   const data = { id: PASS_HASH_ID, value: hash };
 | |
|   return createOrUpdateItem(data);
 | |
| }
 | |
| function removePasswordHash() {
 | |
|   return removeItemById(PASS_HASH_ID);
 | |
| }
 | |
| 
 | |
| function getIdentityKeyById(id, instance) {
 | |
|   return getById(IDENTITY_KEYS_TABLE, id, instance);
 | |
| }
 | |
| 
 | |
| function getGuardNodes() {
 | |
|   const nodes = globalInstance.prepare(`SELECT ed25519PubKey FROM ${GUARD_NODE_TABLE};`).all();
 | |
| 
 | |
|   if (!nodes) {
 | |
|     return null;
 | |
|   }
 | |
| 
 | |
|   return nodes;
 | |
| }
 | |
| 
 | |
| function updateGuardNodes(nodes) {
 | |
|   globalInstance.transaction(() => {
 | |
|     globalInstance.exec(`DELETE FROM ${GUARD_NODE_TABLE}`);
 | |
|     nodes.map(edkey =>
 | |
|       globalInstance
 | |
|         .prepare(
 | |
|           `INSERT INTO ${GUARD_NODE_TABLE} (
 | |
|         ed25519PubKey
 | |
|       ) values ($ed25519PubKey)`
 | |
|         )
 | |
|         .run({
 | |
|           ed25519PubKey: edkey,
 | |
|         })
 | |
|     );
 | |
|   })();
 | |
| }
 | |
| 
 | |
| function createOrUpdateItem(data, instance) {
 | |
|   return createOrUpdate(ITEMS_TABLE, data, instance);
 | |
| }
 | |
| function getItemById(id) {
 | |
|   return getById(ITEMS_TABLE, id);
 | |
| }
 | |
| function getAllItems() {
 | |
|   const rows = globalInstance.prepare(`SELECT json FROM ${ITEMS_TABLE} ORDER BY id ASC;`).all();
 | |
|   return map(rows, row => jsonToObject(row.json));
 | |
| }
 | |
| function removeItemById(id) {
 | |
|   return removeById(ITEMS_TABLE, id);
 | |
| }
 | |
| 
 | |
| function createOrUpdate(table, data, instance) {
 | |
|   const { id } = data;
 | |
|   if (!id) {
 | |
|     throw new Error('createOrUpdate: Provided data did not have a truthy id');
 | |
|   }
 | |
| 
 | |
|   (globalInstance || instance)
 | |
|     .prepare(
 | |
|       `INSERT OR REPLACE INTO ${table} (
 | |
|       id,
 | |
|       json
 | |
|     ) values (
 | |
|       $id,
 | |
|       $json
 | |
|     )`
 | |
|     )
 | |
|     .run({
 | |
|       id,
 | |
|       json: objectToJSON(data),
 | |
|     });
 | |
| }
 | |
| 
 | |
| function getById(table, id, instance) {
 | |
|   const row = (globalInstance || instance).prepare(`SELECT * FROM ${table} WHERE id = $id;`).get({
 | |
|     id,
 | |
|   });
 | |
| 
 | |
|   if (!row) {
 | |
|     return null;
 | |
|   }
 | |
| 
 | |
|   return jsonToObject(row.json);
 | |
| }
 | |
| 
 | |
| function removeById(table, id) {
 | |
|   if (!Array.isArray(id)) {
 | |
|     globalInstance.prepare(`DELETE FROM ${table} WHERE id = $id;`).run({ id });
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   if (!id.length) {
 | |
|     throw new Error('removeById: No ids to delete!');
 | |
|   }
 | |
| 
 | |
|   // Our node interface doesn't seem to allow you to replace one single ? with an array
 | |
|   globalInstance
 | |
|     .prepare(`DELETE FROM ${table} WHERE id IN ( ${id.map(() => '?').join(', ')} );`)
 | |
|     .run({ id });
 | |
| }
 | |
| 
 | |
| // Conversations
 | |
| 
 | |
| function getSwarmNodesForPubkey(pubkey) {
 | |
|   const row = globalInstance
 | |
|     .prepare(`SELECT * FROM ${NODES_FOR_PUBKEY_TABLE} WHERE pubkey = $pubkey;`)
 | |
|     .get({
 | |
|       pubkey,
 | |
|     });
 | |
| 
 | |
|   if (!row) {
 | |
|     return [];
 | |
|   }
 | |
| 
 | |
|   return jsonToObject(row.json);
 | |
| }
 | |
| 
 | |
| function updateSwarmNodesForPubkey(pubkey, snodeEdKeys) {
 | |
|   globalInstance
 | |
|     .prepare(
 | |
|       `INSERT OR REPLACE INTO ${NODES_FOR_PUBKEY_TABLE} (
 | |
|         pubkey,
 | |
|         json
 | |
|         ) values (
 | |
|           $pubkey,
 | |
|           $json
 | |
|           );`
 | |
|     )
 | |
|     .run({
 | |
|       pubkey,
 | |
|       json: objectToJSON(snodeEdKeys),
 | |
|     });
 | |
| }
 | |
| 
 | |
| function getConversationCount() {
 | |
|   const row = globalInstance.prepare(`SELECT count(*) from ${CONVERSATIONS_TABLE};`).get();
 | |
|   if (!row) {
 | |
|     throw new Error(`getConversationCount: Unable to get count of ${CONVERSATIONS_TABLE}`);
 | |
|   }
 | |
| 
 | |
|   return row['count(*)'];
 | |
| }
 | |
| 
 | |
| function saveConversation(data) {
 | |
|   const { id, active_at, type, members, name, profileName } = data;
 | |
| 
 | |
|   globalInstance
 | |
|     .prepare(
 | |
|       `INSERT INTO ${CONVERSATIONS_TABLE} (
 | |
|     id,
 | |
|     json,
 | |
| 
 | |
|     active_at,
 | |
|     type,
 | |
|     members,
 | |
|     name,
 | |
|     profileName
 | |
|   ) values (
 | |
|     $id,
 | |
|     $json,
 | |
| 
 | |
|     $active_at,
 | |
|     $type,
 | |
|     $members,
 | |
|     $name,
 | |
|     $profileName
 | |
|   );`
 | |
|     )
 | |
|     .run({
 | |
|       id,
 | |
|       json: objectToJSON(data),
 | |
| 
 | |
|       active_at,
 | |
|       type,
 | |
|       members: members ? members.join(' ') : null,
 | |
|       name,
 | |
|       profileName,
 | |
|     });
 | |
| }
 | |
| 
 | |
| function updateConversation(data) {
 | |
|   const {
 | |
|     id,
 | |
|     // eslint-disable-next-line camelcase
 | |
|     active_at,
 | |
|     type,
 | |
|     members,
 | |
|     name,
 | |
|     profileName,
 | |
|   } = data;
 | |
| 
 | |
|   globalInstance
 | |
|     .prepare(
 | |
|       `UPDATE ${CONVERSATIONS_TABLE} SET
 | |
|     json = $json,
 | |
| 
 | |
|     active_at = $active_at,
 | |
|     type = $type,
 | |
|     members = $members,
 | |
|     name = $name,
 | |
|     profileName = $profileName
 | |
|   WHERE id = $id;`
 | |
|     )
 | |
|     .run({
 | |
|       id,
 | |
|       json: objectToJSON(data),
 | |
| 
 | |
|       active_at,
 | |
|       type,
 | |
|       members: members ? members.join(' ') : null,
 | |
|       name,
 | |
|       profileName,
 | |
|     });
 | |
| }
 | |
| 
 | |
| function removeConversation(id) {
 | |
|   if (!Array.isArray(id)) {
 | |
|     globalInstance.prepare(`DELETE FROM ${CONVERSATIONS_TABLE} WHERE id = $id;`).run({
 | |
|       id,
 | |
|     });
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   if (!id.length) {
 | |
|     throw new Error('removeConversation: No ids to delete!');
 | |
|   }
 | |
| 
 | |
|   // Our node interface doesn't seem to allow you to replace one single ? with an array
 | |
|   globalInstance
 | |
|     .prepare(`DELETE FROM ${CONVERSATIONS_TABLE} WHERE id IN ( ${id.map(() => '?').join(', ')} );`)
 | |
|     .run(id);
 | |
| }
 | |
| 
 | |
| function getConversationById(id) {
 | |
|   const row = globalInstance.prepare(`SELECT * FROM ${CONVERSATIONS_TABLE} WHERE id = $id;`).get({
 | |
|     id,
 | |
|   });
 | |
| 
 | |
|   if (!row) {
 | |
|     return null;
 | |
|   }
 | |
| 
 | |
|   return jsonToObject(row.json);
 | |
| }
 | |
| 
 | |
| function getAllConversations() {
 | |
|   const rows = globalInstance
 | |
|     .prepare(`SELECT json FROM ${CONVERSATIONS_TABLE} ORDER BY id ASC;`)
 | |
|     .all();
 | |
|   return map(rows, row => jsonToObject(row.json));
 | |
| }
 | |
| 
 | |
| function getAllOpenGroupV1Conversations() {
 | |
|   const rows = globalInstance
 | |
|     .prepare(
 | |
|       `SELECT json FROM ${CONVERSATIONS_TABLE} WHERE
 | |
|       type = 'group' AND
 | |
|       id LIKE 'publicChat:1@%'
 | |
|      ORDER BY id ASC;`
 | |
|     )
 | |
|     .all();
 | |
| 
 | |
|   return map(rows, row => jsonToObject(row.json));
 | |
| }
 | |
| 
 | |
| function getAllOpenGroupV2Conversations() {
 | |
|   // first _ matches all opengroupv1,
 | |
|   // second _ force a second char to be there, so it can only be opengroupv2 convos
 | |
| 
 | |
|   const rows = globalInstance
 | |
|     .prepare(
 | |
|       `SELECT json FROM ${CONVERSATIONS_TABLE} WHERE
 | |
|       type = 'group' AND
 | |
|       id LIKE 'publicChat:__%@%'
 | |
|      ORDER BY id ASC;`
 | |
|     )
 | |
|     .all();
 | |
| 
 | |
|   return map(rows, row => jsonToObject(row.json));
 | |
| }
 | |
| 
 | |
| function getPubkeysInPublicConversation(conversationId) {
 | |
|   const rows = globalInstance
 | |
|     .prepare(
 | |
|       `SELECT DISTINCT source FROM ${MESSAGES_TABLE} WHERE
 | |
|       conversationId = $conversationId
 | |
|      ORDER BY received_at DESC LIMIT ${MAX_PUBKEYS_MEMBERS};`
 | |
|     )
 | |
|     .all({
 | |
|       conversationId,
 | |
|     });
 | |
| 
 | |
|   return map(rows, row => row.source);
 | |
| }
 | |
| 
 | |
| function getAllGroupsInvolvingId(id) {
 | |
|   const rows = globalInstance
 | |
|     .prepare(
 | |
|       `SELECT json FROM ${CONVERSATIONS_TABLE} WHERE
 | |
|       type = 'group' AND
 | |
|       members LIKE $id
 | |
|      ORDER BY id ASC;`
 | |
|     )
 | |
|     .all({
 | |
|       id: `%${id}%`,
 | |
|     });
 | |
| 
 | |
|   return map(rows, row => jsonToObject(row.json));
 | |
| }
 | |
| 
 | |
| function searchConversations(query, { limit } = {}) {
 | |
|   const rows = globalInstance
 | |
|     .prepare(
 | |
|       `SELECT json FROM ${CONVERSATIONS_TABLE} WHERE
 | |
|       (
 | |
|         id LIKE $id OR
 | |
|         name LIKE $name OR
 | |
|         profileName LIKE $profileName
 | |
|       )
 | |
|      ORDER BY id ASC
 | |
|      LIMIT $limit`
 | |
|     )
 | |
|     .all({
 | |
|       id: `%${query}%`,
 | |
|       name: `%${query}%`,
 | |
|       profileName: `%${query}%`,
 | |
|       limit: limit || 50,
 | |
|     });
 | |
| 
 | |
|   return map(rows, row => jsonToObject(row.json));
 | |
| }
 | |
| 
 | |
| function searchMessages(query, { limit } = {}) {
 | |
|   const rows = globalInstance
 | |
|     .prepare(
 | |
|       `SELECT
 | |
|       messages.json,
 | |
|       snippet(messages_fts, -1, '<<left>>', '<<right>>', '...', 15) as snippet
 | |
|     FROM ${MESSAGES_FTS_TABLE}
 | |
|     INNER JOIN ${MESSAGES_TABLE} on messages_fts.id = messages.id
 | |
|     WHERE
 | |
|       messages_fts match $query
 | |
|     ORDER BY messages.received_at DESC
 | |
|     LIMIT $limit;`
 | |
|     )
 | |
|     .all({
 | |
|       query,
 | |
|       limit: limit || 100,
 | |
|     });
 | |
| 
 | |
|   return map(rows, row => ({
 | |
|     ...jsonToObject(row.json),
 | |
|     snippet: row.snippet,
 | |
|   }));
 | |
| }
 | |
| 
 | |
| function searchMessagesInConversation(query, conversationId, { limit } = {}) {
 | |
|   const rows = globalInstance
 | |
|     .prepare(
 | |
|       `SELECT
 | |
|       messages.json,
 | |
|       snippet(messages_fts, -1, '<<left>>', '<<right>>', '...', 15) as snippet
 | |
|     FROM messages_fts
 | |
|     INNER JOIN ${MESSAGES_TABLE} on messages_fts.id = messages.id
 | |
|     WHERE
 | |
|       messages_fts match $query AND
 | |
|       messages.conversationId = $conversationId
 | |
|     ORDER BY messages.received_at DESC
 | |
|     LIMIT $limit;`
 | |
|     )
 | |
|     .all({
 | |
|       query,
 | |
|       conversationId,
 | |
|       limit: limit || 100,
 | |
|     });
 | |
| 
 | |
|   return map(rows, row => ({
 | |
|     ...jsonToObject(row.json),
 | |
|     snippet: row.snippet,
 | |
|   }));
 | |
| }
 | |
| 
 | |
| function getMessageCount() {
 | |
|   const row = globalInstance.prepare(`SELECT count(*) from ${MESSAGES_TABLE};`).get();
 | |
| 
 | |
|   if (!row) {
 | |
|     throw new Error(`getMessageCount: Unable to get count of ${MESSAGES_TABLE}`);
 | |
|   }
 | |
|   return row['count(*)'];
 | |
| }
 | |
| 
 | |
| function saveMessage(data) {
 | |
|   const {
 | |
|     body,
 | |
|     conversationId,
 | |
|     // eslint-disable-next-line camelcase
 | |
|     expires_at,
 | |
|     hasAttachments,
 | |
|     hasFileAttachments,
 | |
|     hasVisualMediaAttachments,
 | |
|     id,
 | |
|     serverId,
 | |
|     serverTimestamp,
 | |
|     // eslint-disable-next-line camelcase
 | |
|     received_at,
 | |
|     schemaVersion,
 | |
|     sent,
 | |
|     // eslint-disable-next-line camelcase
 | |
|     sent_at,
 | |
|     source,
 | |
|     sourceDevice,
 | |
|     type,
 | |
|     unread,
 | |
|     expireTimer,
 | |
|     expirationStartTimestamp,
 | |
|   } = data;
 | |
| 
 | |
|   if (!id) {
 | |
|     throw new Error('id is required');
 | |
|   }
 | |
| 
 | |
|   if (!conversationId) {
 | |
|     throw new Error('conversationId is required');
 | |
|   }
 | |
| 
 | |
|   const payload = {
 | |
|     id,
 | |
|     json: objectToJSON(data),
 | |
| 
 | |
|     serverId,
 | |
|     serverTimestamp,
 | |
|     body,
 | |
|     conversationId,
 | |
|     expirationStartTimestamp,
 | |
|     expires_at,
 | |
|     expireTimer,
 | |
|     hasAttachments,
 | |
|     hasFileAttachments,
 | |
|     hasVisualMediaAttachments,
 | |
|     received_at,
 | |
|     schemaVersion,
 | |
|     sent,
 | |
|     sent_at,
 | |
|     source,
 | |
|     sourceDevice,
 | |
|     type: type || '',
 | |
|     unread,
 | |
|   };
 | |
| 
 | |
|   globalInstance
 | |
|     .prepare(
 | |
|       `INSERT OR REPLACE INTO ${MESSAGES_TABLE} (
 | |
|     id,
 | |
|     json,
 | |
|     serverId,
 | |
|     serverTimestamp,
 | |
|     body,
 | |
|     conversationId,
 | |
|     expirationStartTimestamp,
 | |
|     expires_at,
 | |
|     expireTimer,
 | |
|     hasAttachments,
 | |
|     hasFileAttachments,
 | |
|     hasVisualMediaAttachments,
 | |
|     received_at,
 | |
|     schemaVersion,
 | |
|     sent,
 | |
|     sent_at,
 | |
|     source,
 | |
|     sourceDevice,
 | |
|     type,
 | |
|     unread
 | |
|   ) values (
 | |
|     $id,
 | |
|     $json,
 | |
|     $serverId,
 | |
|     $serverTimestamp,
 | |
|     $body,
 | |
|     $conversationId,
 | |
|     $expirationStartTimestamp,
 | |
|     $expires_at,
 | |
|     $expireTimer,
 | |
|     $hasAttachments,
 | |
|     $hasFileAttachments,
 | |
|     $hasVisualMediaAttachments,
 | |
|     $received_at,
 | |
|     $schemaVersion,
 | |
|     $sent,
 | |
|     $sent_at,
 | |
|     $source,
 | |
|     $sourceDevice,
 | |
|     $type,
 | |
|     $unread
 | |
|   );`
 | |
|     )
 | |
|     .run(payload);
 | |
| 
 | |
|   return id;
 | |
| }
 | |
| 
 | |
| function saveSeenMessageHashes(arrayOfHashes) {
 | |
|   globalInstance.transaction(() => {
 | |
|     map(arrayOfHashes, hashData => saveSeenMessageHash(hashData));
 | |
|   })();
 | |
| }
 | |
| 
 | |
| function updateLastHash(data) {
 | |
|   const { convoId, snode, hash, expiresAt } = data;
 | |
| 
 | |
|   const id = convoId;
 | |
| 
 | |
|   globalInstance
 | |
|     .prepare(
 | |
|       `INSERT OR REPLACE INTO lastHashes (
 | |
|       id,
 | |
|       snode,
 | |
|       hash,
 | |
|       expiresAt
 | |
|     ) values (
 | |
|       $id,
 | |
|       $snode,
 | |
|       $hash,
 | |
|       $expiresAt
 | |
|     )`
 | |
|     )
 | |
|     .run({
 | |
|       id,
 | |
|       snode,
 | |
|       hash,
 | |
|       expiresAt,
 | |
|     });
 | |
| }
 | |
| 
 | |
| function saveSeenMessageHash(data) {
 | |
|   const { expiresAt, hash } = data;
 | |
|   try {
 | |
|     globalInstance
 | |
|       .prepare(
 | |
|         `INSERT INTO seenMessages (
 | |
|       expiresAt,
 | |
|       hash
 | |
|       ) values (
 | |
|         $expiresAt,
 | |
|         $hash
 | |
|         );`
 | |
|       )
 | |
|       .run({
 | |
|         expiresAt,
 | |
|         hash,
 | |
|       });
 | |
|   } catch (e) {
 | |
|     console.warn('saveSeenMessageHash failed:', e);
 | |
|   }
 | |
| }
 | |
| 
 | |
| function cleanLastHashes() {
 | |
|   globalInstance.prepare('DELETE FROM lastHashes WHERE expiresAt <= $now;').run({
 | |
|     now: Date.now(),
 | |
|   });
 | |
| }
 | |
| 
 | |
| function cleanSeenMessages() {
 | |
|   globalInstance.prepare('DELETE FROM seenMessages WHERE expiresAt <= $now;').run({
 | |
|     now: Date.now(),
 | |
|   });
 | |
| }
 | |
| 
 | |
| function saveMessages(arrayOfMessages) {
 | |
|   globalInstance.transaction(() => {
 | |
|     map(arrayOfMessages, message => saveMessage(message));
 | |
|   })();
 | |
| }
 | |
| 
 | |
| function removeMessage(id, instance) {
 | |
|   if (!Array.isArray(id)) {
 | |
|     (globalInstance || instance)
 | |
|       .prepare(`DELETE FROM ${MESSAGES_TABLE} WHERE id = $id;`)
 | |
|       .run({ id });
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   if (!id.length) {
 | |
|     throw new Error('removeMessages: No ids to delete!');
 | |
|   }
 | |
| 
 | |
|   // Our node interface doesn't seem to allow you to replace one single ? with an array
 | |
|   (globalInstance || instance)
 | |
|     .prepare(`DELETE FROM ${MESSAGES_TABLE} WHERE id IN ( ${id.map(() => '?').join(', ')} );`)
 | |
|     .run(id);
 | |
| }
 | |
| 
 | |
| function getMessageIdsFromServerIds(serverIds, conversationId) {
 | |
|   if (!Array.isArray(serverIds)) {
 | |
|     return [];
 | |
|   }
 | |
| 
 | |
|   // Sanitize the input as we're going to use it directly in the query
 | |
|   const validIds = serverIds.map(id => Number(id)).filter(n => !Number.isNaN(n));
 | |
| 
 | |
|   /*
 | |
|     Sqlite3 doesn't have a good way to have `IN` query with another query.
 | |
|     See: https://github.com/mapbox/node-sqlite3/issues/762.
 | |
| 
 | |
|     So we have to use templating to insert the values.
 | |
|   */
 | |
|   const rows = globalInstance
 | |
|     .prepare(
 | |
|       `SELECT id FROM ${MESSAGES_TABLE} WHERE
 | |
|     serverId IN (${validIds.join(',')}) AND
 | |
|     conversationId = $conversationId;`
 | |
|     )
 | |
|     .all({
 | |
|       conversationId,
 | |
|     });
 | |
|   return rows.map(row => row.id);
 | |
| }
 | |
| 
 | |
| function getMessageById(id) {
 | |
|   const row = globalInstance.prepare(`SELECT * FROM ${MESSAGES_TABLE} WHERE id = $id;`).get({
 | |
|     id,
 | |
|   });
 | |
| 
 | |
|   if (!row) {
 | |
|     return null;
 | |
|   }
 | |
| 
 | |
|   return jsonToObject(row.json);
 | |
| }
 | |
| 
 | |
| function getMessageBySender({ source, sourceDevice, sentAt }) {
 | |
|   const rows = globalInstance
 | |
|     .prepare(
 | |
|       `SELECT json FROM ${MESSAGES_TABLE} WHERE
 | |
|       source = $source AND
 | |
|       sourceDevice = $sourceDevice AND
 | |
|       sent_at = $sent_at;`
 | |
|     )
 | |
|     .all({
 | |
|       source,
 | |
|       sourceDevice,
 | |
|       sent_at: sentAt,
 | |
|     });
 | |
| 
 | |
|   return map(rows, row => jsonToObject(row.json));
 | |
| }
 | |
| 
 | |
| function getMessageBySenderAndServerId({ source, serverId }) {
 | |
|   const rows = globalInstance
 | |
|     .prepare(
 | |
|       `SELECT json FROM ${MESSAGES_TABLE} WHERE
 | |
|       source = $source AND
 | |
|       serverId = $serverId;`
 | |
|     )
 | |
|     .all({
 | |
|       source,
 | |
|       serverId,
 | |
|     });
 | |
| 
 | |
|   return map(rows, row => jsonToObject(row.json));
 | |
| }
 | |
| 
 | |
| function getMessageBySenderAndTimestamp({ source, timestamp }) {
 | |
|   const rows = globalInstance
 | |
|     .prepare(
 | |
|       `SELECT json FROM ${MESSAGES_TABLE} WHERE
 | |
|       source = $source AND
 | |
|       sent_at = $timestamp;`
 | |
|     )
 | |
|     .all({
 | |
|       source,
 | |
|       timestamp,
 | |
|     });
 | |
| 
 | |
|   return map(rows, row => jsonToObject(row.json));
 | |
| }
 | |
| 
 | |
| function getMessageBySenderAndServerTimestamp({ source, serverTimestamp }) {
 | |
|   const rows = globalInstance
 | |
|     .prepare(
 | |
|       `SELECT json FROM ${MESSAGES_TABLE} WHERE
 | |
|       source = $source AND
 | |
|       serverTimestamp = $serverTimestamp;`
 | |
|     )
 | |
|     .all({
 | |
|       source,
 | |
|       serverTimestamp,
 | |
|     });
 | |
| 
 | |
|   return map(rows, row => jsonToObject(row.json));
 | |
| }
 | |
| 
 | |
| function getUnreadByConversation(conversationId) {
 | |
|   const rows = globalInstance
 | |
|     .prepare(
 | |
|       `SELECT json FROM ${MESSAGES_TABLE} WHERE
 | |
|       unread = $unread AND
 | |
|       conversationId = $conversationId
 | |
|      ORDER BY received_at DESC;`
 | |
|     )
 | |
|     .all({
 | |
|       unread: 1,
 | |
|       conversationId,
 | |
|     });
 | |
| 
 | |
|   return map(rows, row => jsonToObject(row.json));
 | |
| }
 | |
| 
 | |
| function getUnreadCountByConversation(conversationId) {
 | |
|   const row = globalInstance
 | |
|     .prepare(
 | |
|       `SELECT count(*) from ${MESSAGES_TABLE} WHERE
 | |
|     unread = $unread AND
 | |
|     conversationId = $conversationId
 | |
|     ORDER BY received_at DESC;`
 | |
|     )
 | |
|     .get({
 | |
|       unread: 1,
 | |
|       conversationId,
 | |
|     });
 | |
| 
 | |
|   if (!row) {
 | |
|     throw new Error(
 | |
|       `getUnreadCountByConversation: Unable to get unread count of ${conversationId}`
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   return row['count(*)'];
 | |
| }
 | |
| 
 | |
| // Note: Sorting here is necessary for getting the last message (with limit 1)
 | |
| // be sure to update the sorting order to sort messages on redux too (sortMessages)
 | |
| 
 | |
| function getMessagesByConversation(
 | |
|   conversationId,
 | |
|   { limit = 100, receivedAt = Number.MAX_VALUE, type = '%' } = {}
 | |
| ) {
 | |
|   const rows = globalInstance
 | |
|     .prepare(
 | |
|       `
 | |
|     SELECT json FROM ${MESSAGES_TABLE} WHERE
 | |
|       conversationId = $conversationId AND
 | |
|       received_at < $received_at AND
 | |
|       type LIKE $type
 | |
|       ORDER BY serverTimestamp DESC, serverId DESC, sent_at DESC, received_at DESC
 | |
|     LIMIT $limit;
 | |
|     `
 | |
|     )
 | |
|     .all({
 | |
|       conversationId,
 | |
|       received_at: receivedAt,
 | |
|       limit,
 | |
|       type,
 | |
|     });
 | |
|   return map(rows, row => jsonToObject(row.json));
 | |
| }
 | |
| 
 | |
| function getFirstUnreadMessageIdInConversation(conversationId) {
 | |
|   const rows = globalInstance
 | |
|     .prepare(
 | |
|       `
 | |
|     SELECT id FROM ${MESSAGES_TABLE} WHERE
 | |
|       conversationId = $conversationId AND
 | |
|       unread = $unread
 | |
|       ORDER BY serverTimestamp ASC, serverId ASC, sent_at ASC, received_at ASC
 | |
|     LIMIT 1;
 | |
|     `
 | |
|     )
 | |
|     .all({
 | |
|       conversationId,
 | |
|       unread: 1,
 | |
|     });
 | |
| 
 | |
|   if (rows.length === 0) {
 | |
|     return undefined;
 | |
|   }
 | |
|   return rows[0].id;
 | |
| }
 | |
| 
 | |
| function getMessagesBySentAt(sentAt) {
 | |
|   const rows = globalInstance
 | |
|     .prepare(
 | |
|       `SELECT * FROM ${MESSAGES_TABLE}
 | |
|      WHERE sent_at = $sent_at
 | |
|      ORDER BY received_at DESC;`
 | |
|     )
 | |
|     .all({
 | |
|       sent_at: sentAt,
 | |
|     });
 | |
| 
 | |
|   return map(rows, row => jsonToObject(row.json));
 | |
| }
 | |
| 
 | |
| function getLastHashBySnode(convoId, snode) {
 | |
|   const row = globalInstance
 | |
|     .prepare('SELECT * FROM lastHashes WHERE snode = $snode AND id = $id;')
 | |
|     .get({
 | |
|       snode,
 | |
|       id: convoId,
 | |
|     });
 | |
| 
 | |
|   if (!row) {
 | |
|     return null;
 | |
|   }
 | |
| 
 | |
|   return row.hash;
 | |
| }
 | |
| 
 | |
| function getSeenMessagesByHashList(hashes) {
 | |
|   const rows = globalInstance
 | |
|     .prepare(`SELECT * FROM seenMessages WHERE hash IN ( ${hashes.map(() => '?').join(', ')} );`)
 | |
|     .all(hashes);
 | |
| 
 | |
|   return map(rows, row => row.hash);
 | |
| }
 | |
| 
 | |
| function getExpiredMessages() {
 | |
|   const now = Date.now();
 | |
| 
 | |
|   const rows = globalInstance
 | |
|     .prepare(
 | |
|       `SELECT json FROM ${MESSAGES_TABLE} WHERE
 | |
|       expires_at IS NOT NULL AND
 | |
|       expires_at <= $expires_at
 | |
|      ORDER BY expires_at ASC;`
 | |
|     )
 | |
|     .all({
 | |
|       expires_at: now,
 | |
|     });
 | |
| 
 | |
|   return map(rows, row => jsonToObject(row.json));
 | |
| }
 | |
| 
 | |
| function getOutgoingWithoutExpiresAt() {
 | |
|   const rows = globalInstance
 | |
|     .prepare(
 | |
|       `
 | |
|     SELECT json FROM ${MESSAGES_TABLE}
 | |
|     WHERE
 | |
|       expireTimer > 0 AND
 | |
|       expires_at IS NULL AND
 | |
|       type IS 'outgoing'
 | |
|     ORDER BY expires_at ASC;
 | |
|   `
 | |
|     )
 | |
|     .all();
 | |
| 
 | |
|   return map(rows, row => jsonToObject(row.json));
 | |
| }
 | |
| 
 | |
| function getNextExpiringMessage() {
 | |
|   const rows = globalInstance
 | |
|     .prepare(
 | |
|       `
 | |
|     SELECT json FROM ${MESSAGES_TABLE}
 | |
|     WHERE expires_at > 0
 | |
|     ORDER BY expires_at ASC
 | |
|     LIMIT 1;
 | |
|   `
 | |
|     )
 | |
|     .all();
 | |
| 
 | |
|   return map(rows, row => jsonToObject(row.json));
 | |
| }
 | |
| 
 | |
| /* Unproccessed a received messages not yet processed */
 | |
| function saveUnprocessed(data) {
 | |
|   const { id, timestamp, version, attempts, envelope, senderIdentity, messageHash } = data;
 | |
|   if (!id) {
 | |
|     throw new Error(`saveUnprocessed: id was falsey: ${id}`);
 | |
|   }
 | |
| 
 | |
|   globalInstance
 | |
|     .prepare(
 | |
|       `INSERT OR REPLACE INTO unprocessed (
 | |
|       id,
 | |
|       timestamp,
 | |
|       version,
 | |
|       attempts,
 | |
|       envelope,
 | |
|       senderIdentity,
 | |
|       serverHash
 | |
|     ) values (
 | |
|       $id,
 | |
|       $timestamp,
 | |
|       $version,
 | |
|       $attempts,
 | |
|       $envelope,
 | |
|       $senderIdentity,
 | |
|       $messageHash
 | |
|     );`
 | |
|     )
 | |
|     .run({
 | |
|       id,
 | |
|       timestamp,
 | |
|       version,
 | |
|       attempts,
 | |
|       envelope,
 | |
|       senderIdentity,
 | |
|       messageHash,
 | |
|     });
 | |
| 
 | |
|   return id;
 | |
| }
 | |
| 
 | |
| function updateUnprocessedAttempts(id, attempts) {
 | |
|   globalInstance.prepare('UPDATE unprocessed SET attempts = $attempts WHERE id = $id;').run({
 | |
|     id,
 | |
|     attempts,
 | |
|   });
 | |
| }
 | |
| function updateUnprocessedWithData(id, data = {}) {
 | |
|   const { source, sourceDevice, serverTimestamp, decrypted, senderIdentity } = data;
 | |
| 
 | |
|   globalInstance
 | |
|     .prepare(
 | |
|       `UPDATE unprocessed SET
 | |
|       source = $source,
 | |
|       sourceDevice = $sourceDevice,
 | |
|       serverTimestamp = $serverTimestamp,
 | |
|       decrypted = $decrypted,
 | |
|       senderIdentity = $senderIdentity
 | |
|     WHERE id = $id;`
 | |
|     )
 | |
|     .run({
 | |
|       id,
 | |
|       source,
 | |
|       sourceDevice,
 | |
|       serverTimestamp,
 | |
|       decrypted,
 | |
|       senderIdentity,
 | |
|     });
 | |
| }
 | |
| 
 | |
| function getUnprocessedById(id) {
 | |
|   const row = globalInstance.prepare('SELECT * FROM unprocessed WHERE id = $id;').get({
 | |
|     id,
 | |
|   });
 | |
| 
 | |
|   return row;
 | |
| }
 | |
| 
 | |
| function getUnprocessedCount() {
 | |
|   const row = globalInstance.prepare('SELECT count(*) from unprocessed;').get();
 | |
| 
 | |
|   if (!row) {
 | |
|     throw new Error('getMessageCount: Unable to get count of unprocessed');
 | |
|   }
 | |
| 
 | |
|   return row['count(*)'];
 | |
| }
 | |
| 
 | |
| function getAllUnprocessed() {
 | |
|   const rows = globalInstance.prepare('SELECT * FROM unprocessed ORDER BY timestamp ASC;').all();
 | |
| 
 | |
|   return rows;
 | |
| }
 | |
| 
 | |
| function removeUnprocessed(id) {
 | |
|   if (!Array.isArray(id)) {
 | |
|     globalInstance.prepare('DELETE FROM unprocessed WHERE id = $id;').run({ id });
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   if (!id.length) {
 | |
|     throw new Error('removeUnprocessed: No ids to delete!');
 | |
|   }
 | |
| 
 | |
|   // Our node interface doesn't seem to allow you to replace one single ? with an array
 | |
|   globalInstance
 | |
|     .prepare(`DELETE FROM unprocessed WHERE id IN ( ${id.map(() => '?').join(', ')} );`)
 | |
|     .run(id);
 | |
| }
 | |
| 
 | |
| function removeAllUnprocessed() {
 | |
|   globalInstance.prepare('DELETE FROM unprocessed;').run();
 | |
| }
 | |
| 
 | |
| function getNextAttachmentDownloadJobs(limit, options = {}) {
 | |
|   const timestamp = options.timestamp || Date.now();
 | |
| 
 | |
|   const rows = globalInstance
 | |
|     .prepare(
 | |
|       `SELECT json FROM ${ATTACHMENT_DOWNLOADS_TABLE}
 | |
|     WHERE pending = 0 AND timestamp < $timestamp
 | |
|     ORDER BY timestamp DESC
 | |
|     LIMIT $limit;`
 | |
|     )
 | |
|     .all({
 | |
|       limit,
 | |
|       timestamp,
 | |
|     });
 | |
| 
 | |
|   return map(rows, row => jsonToObject(row.json));
 | |
| }
 | |
| 
 | |
| function saveAttachmentDownloadJob(job) {
 | |
|   const { id, pending, timestamp } = job;
 | |
|   if (!id) {
 | |
|     throw new Error('saveAttachmentDownloadJob: Provided job did not have a truthy id');
 | |
|   }
 | |
| 
 | |
|   globalInstance
 | |
|     .prepare(
 | |
|       `INSERT OR REPLACE INTO ${ATTACHMENT_DOWNLOADS_TABLE} (
 | |
|       id,
 | |
|       pending,
 | |
|       timestamp,
 | |
|       json
 | |
|     ) values (
 | |
|       $id,
 | |
|       $pending,
 | |
|       $timestamp,
 | |
|       $json
 | |
|     )`
 | |
|     )
 | |
|     .run({
 | |
|       id,
 | |
|       pending,
 | |
|       timestamp,
 | |
|       json: objectToJSON(job),
 | |
|     });
 | |
| }
 | |
| 
 | |
| function setAttachmentDownloadJobPending(id, pending) {
 | |
|   globalInstance
 | |
|     .prepare(`UPDATE ${ATTACHMENT_DOWNLOADS_TABLE} SET pending = $pending WHERE id = $id;`)
 | |
|     .run({
 | |
|       id,
 | |
|       pending,
 | |
|     });
 | |
| }
 | |
| 
 | |
| function resetAttachmentDownloadPending() {
 | |
|   globalInstance
 | |
|     .prepare(`UPDATE ${ATTACHMENT_DOWNLOADS_TABLE} SET pending = 0 WHERE pending != 0;`)
 | |
|     .run();
 | |
| }
 | |
| function removeAttachmentDownloadJob(id) {
 | |
|   return removeById(ATTACHMENT_DOWNLOADS_TABLE, id);
 | |
| }
 | |
| function removeAllAttachmentDownloadJobs() {
 | |
|   globalInstance.exec(`DELETE FROM ${ATTACHMENT_DOWNLOADS_TABLE};`);
 | |
| }
 | |
| 
 | |
| // All data in database
 | |
| function removeAll() {
 | |
|   globalInstance.exec(`
 | |
|     DELETE FROM ${IDENTITY_KEYS_TABLE};
 | |
| 
 | |
|     DELETE FROM ${ITEMS_TABLE};
 | |
|     DELETE FROM unprocessed;
 | |
|     DELETE FROM lastHashes;
 | |
|     DELETE FROM ${NODES_FOR_PUBKEY_TABLE};
 | |
|     DELETE FROM ${CLOSED_GROUP_V2_KEY_PAIRS_TABLE};
 | |
|     DELETE FROM seenMessages;
 | |
|     DELETE FROM ${CONVERSATIONS_TABLE};
 | |
|     DELETE FROM ${MESSAGES_TABLE};
 | |
|     DELETE FROM ${ATTACHMENT_DOWNLOADS_TABLE};
 | |
|     DELETE FROM ${MESSAGES_FTS_TABLE};
 | |
| `);
 | |
| }
 | |
| 
 | |
| function removeAllConversations() {
 | |
|   globalInstance.prepare(`DELETE FROM ${CONVERSATIONS_TABLE};`).run();
 | |
| }
 | |
| 
 | |
| function getMessagesWithVisualMediaAttachments(conversationId, { limit }) {
 | |
|   const rows = globalInstance
 | |
|     .prepare(
 | |
|       `SELECT json FROM ${MESSAGES_TABLE} WHERE
 | |
|       conversationId = $conversationId AND
 | |
|       hasVisualMediaAttachments = 1
 | |
|      ORDER BY received_at DESC
 | |
|      LIMIT $limit;`
 | |
|     )
 | |
|     .all({
 | |
|       conversationId,
 | |
|       limit,
 | |
|     });
 | |
| 
 | |
|   return map(rows, row => jsonToObject(row.json));
 | |
| }
 | |
| 
 | |
| function getMessagesWithFileAttachments(conversationId, { limit }) {
 | |
|   const rows = globalInstance
 | |
|     .prepare(
 | |
|       `SELECT json FROM ${MESSAGES_TABLE} WHERE
 | |
|       conversationId = $conversationId AND
 | |
|       hasFileAttachments = 1
 | |
|      ORDER BY received_at DESC
 | |
|      LIMIT $limit;`
 | |
|     )
 | |
|     .all({
 | |
|       conversationId,
 | |
|       limit,
 | |
|     });
 | |
| 
 | |
|   return map(rows, row => jsonToObject(row.json));
 | |
| }
 | |
| 
 | |
| function getExternalFilesForMessage(message) {
 | |
|   const { attachments, contact, quote, preview } = message;
 | |
|   const files = [];
 | |
| 
 | |
|   forEach(attachments, attachment => {
 | |
|     const { path: file, thumbnail, screenshot } = attachment;
 | |
|     if (file) {
 | |
|       files.push(file);
 | |
|     }
 | |
| 
 | |
|     if (thumbnail && thumbnail.path) {
 | |
|       files.push(thumbnail.path);
 | |
|     }
 | |
| 
 | |
|     if (screenshot && screenshot.path) {
 | |
|       files.push(screenshot.path);
 | |
|     }
 | |
|   });
 | |
| 
 | |
|   if (quote && quote.attachments && quote.attachments.length) {
 | |
|     forEach(quote.attachments, attachment => {
 | |
|       const { thumbnail } = attachment;
 | |
| 
 | |
|       if (thumbnail && thumbnail.path) {
 | |
|         files.push(thumbnail.path);
 | |
|       }
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   if (contact && contact.length) {
 | |
|     forEach(contact, item => {
 | |
|       const { avatar } = item;
 | |
| 
 | |
|       if (avatar && avatar.avatar && avatar.avatar.path) {
 | |
|         files.push(avatar.avatar.path);
 | |
|       }
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   if (preview && preview.length) {
 | |
|     forEach(preview, item => {
 | |
|       const { image } = item;
 | |
| 
 | |
|       if (image && image.path) {
 | |
|         files.push(image.path);
 | |
|       }
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   return files;
 | |
| }
 | |
| 
 | |
| function getExternalFilesForConversation(conversation) {
 | |
|   const { avatar, profileAvatar } = conversation;
 | |
|   const files = [];
 | |
| 
 | |
|   if (avatar && avatar.path) {
 | |
|     files.push(avatar.path);
 | |
|   }
 | |
| 
 | |
|   if (profileAvatar && profileAvatar.path) {
 | |
|     files.push(profileAvatar.path);
 | |
|   }
 | |
| 
 | |
|   return files;
 | |
| }
 | |
| 
 | |
| function removeKnownAttachments(allAttachments) {
 | |
|   const lookup = fromPairs(map(allAttachments, file => [file, true]));
 | |
|   const chunkSize = 50;
 | |
| 
 | |
|   const total = getMessageCount();
 | |
|   console.log(`removeKnownAttachments: About to iterate through ${total} messages`);
 | |
| 
 | |
|   let count = 0;
 | |
|   let complete = false;
 | |
|   let id = '';
 | |
| 
 | |
|   while (!complete) {
 | |
|     const rows = globalInstance
 | |
|       .prepare(
 | |
|         `SELECT json FROM ${MESSAGES_TABLE}
 | |
|        WHERE id > $id
 | |
|        ORDER BY id ASC
 | |
|        LIMIT $chunkSize;`
 | |
|       )
 | |
|       .all({
 | |
|         id,
 | |
|         chunkSize,
 | |
|       });
 | |
| 
 | |
|     const messages = map(rows, row => jsonToObject(row.json));
 | |
|     forEach(messages, message => {
 | |
|       const externalFiles = getExternalFilesForMessage(message);
 | |
|       forEach(externalFiles, file => {
 | |
|         delete lookup[file];
 | |
|       });
 | |
|     });
 | |
| 
 | |
|     const lastMessage = last(messages);
 | |
|     if (lastMessage) {
 | |
|       ({ id } = lastMessage);
 | |
|     }
 | |
|     complete = messages.length < chunkSize;
 | |
|     count += messages.length;
 | |
|   }
 | |
| 
 | |
|   console.log(`removeKnownAttachments: Done processing ${count} ${MESSAGES_TABLE}`);
 | |
| 
 | |
|   complete = false;
 | |
|   count = 0;
 | |
|   // Though conversations.id is a string, this ensures that, when coerced, this
 | |
|   //   value is still a string but it's smaller than every other string.
 | |
|   id = 0;
 | |
| 
 | |
|   const conversationTotal = getConversationCount();
 | |
|   console.log(
 | |
|     `removeKnownAttachments: About to iterate through ${conversationTotal} ${CONVERSATIONS_TABLE}`
 | |
|   );
 | |
| 
 | |
|   while (!complete) {
 | |
|     const rows = globalInstance
 | |
|       .prepare(
 | |
|         `SELECT json FROM ${CONVERSATIONS_TABLE}
 | |
|        WHERE id > $id
 | |
|        ORDER BY id ASC
 | |
|        LIMIT $chunkSize;`
 | |
|       )
 | |
|       .all({
 | |
|         id,
 | |
|         chunkSize,
 | |
|       });
 | |
| 
 | |
|     const conversations = map(rows, row => jsonToObject(row.json));
 | |
|     forEach(conversations, conversation => {
 | |
|       const externalFiles = getExternalFilesForConversation(conversation);
 | |
|       forEach(externalFiles, file => {
 | |
|         delete lookup[file];
 | |
|       });
 | |
|     });
 | |
| 
 | |
|     const lastMessage = last(conversations);
 | |
|     if (lastMessage) {
 | |
|       ({ id } = lastMessage);
 | |
|     }
 | |
|     complete = conversations.length < chunkSize;
 | |
|     count += conversations.length;
 | |
|   }
 | |
| 
 | |
|   console.log(`removeKnownAttachments: Done processing ${count} ${CONVERSATIONS_TABLE}`);
 | |
| 
 | |
|   return Object.keys(lookup);
 | |
| }
 | |
| 
 | |
| function getMessagesCountByConversation(instance, conversationId) {
 | |
|   const row = instance
 | |
|     .prepare(`SELECT count(*) from ${MESSAGES_TABLE} WHERE conversationId = $conversationId;`)
 | |
|     .get({ conversationId });
 | |
| 
 | |
|   return row ? row['count(*)'] : 0;
 | |
| }
 | |
| 
 | |
| function getAllClosedGroupConversationsV1(instance) {
 | |
|   const rows = (globalInstance || instance)
 | |
|     .prepare(
 | |
|       `SELECT json FROM ${CONVERSATIONS_TABLE} WHERE
 | |
|       type = 'group' AND
 | |
|       id NOT LIKE 'publicChat:%'
 | |
|      ORDER BY id ASC;`
 | |
|     )
 | |
|     .all();
 | |
| 
 | |
|   return map(rows, row => jsonToObject(row.json));
 | |
| }
 | |
| 
 | |
| function remove05PrefixFromStringIfNeeded(str) {
 | |
|   if (str.length === 66 && str.startsWith('05')) {
 | |
|     return str.substr(2);
 | |
|   }
 | |
|   return str;
 | |
| }
 | |
| 
 | |
| function updateExistingClosedGroupV1ToClosedGroupV2(db) {
 | |
|   // the migration is called only once, so all current groups not being open groups are v1 closed group.
 | |
|   const allClosedGroupV1 = getAllClosedGroupConversationsV1(db) || [];
 | |
| 
 | |
|   allClosedGroupV1.forEach(groupV1 => {
 | |
|     const groupId = groupV1.id;
 | |
|     try {
 | |
|       console.log('Migrating closed group v1 to v2: pubkey', groupId);
 | |
|       const groupV1IdentityKey = getIdentityKeyById(groupId, db);
 | |
|       const encryptionPubKeyWithoutPrefix = remove05PrefixFromStringIfNeeded(groupV1IdentityKey.id);
 | |
| 
 | |
|       // Note:
 | |
|       // this is what we get from getIdentityKeyById:
 | |
|       //   {
 | |
|       //     id: string;
 | |
|       //     secretKey?: string;
 | |
|       //   }
 | |
| 
 | |
|       // and this is what we want saved in db:
 | |
|       //   {
 | |
|       //    publicHex: string; // without prefix
 | |
|       //    privateHex: string;
 | |
|       //   }
 | |
|       const keyPair = {
 | |
|         publicHex: encryptionPubKeyWithoutPrefix,
 | |
|         privateHex: groupV1IdentityKey.secretKey,
 | |
|       };
 | |
|       addClosedGroupEncryptionKeyPair(groupId, keyPair, db);
 | |
|     } catch (e) {
 | |
|       console.warn(e);
 | |
|     }
 | |
|   });
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * The returned array is ordered based on the timestamp, the latest is at the end.
 | |
|  * @param {*} groupPublicKey string | PubKey
 | |
|  */
 | |
| function getAllEncryptionKeyPairsForGroup(groupPublicKey) {
 | |
|   const rows = getAllEncryptionKeyPairsForGroupRaw(groupPublicKey);
 | |
| 
 | |
|   return map(rows, row => jsonToObject(row.json));
 | |
| }
 | |
| 
 | |
| function getAllEncryptionKeyPairsForGroupRaw(groupPublicKey) {
 | |
|   const pubkeyAsString = groupPublicKey.key ? groupPublicKey.key : groupPublicKey;
 | |
|   const rows = globalInstance
 | |
|     .prepare(
 | |
|       `SELECT * FROM ${CLOSED_GROUP_V2_KEY_PAIRS_TABLE} WHERE groupPublicKey = $groupPublicKey ORDER BY timestamp ASC;`
 | |
|     )
 | |
|     .all({
 | |
|       groupPublicKey: pubkeyAsString,
 | |
|     });
 | |
| 
 | |
|   return rows;
 | |
| }
 | |
| 
 | |
| function getLatestClosedGroupEncryptionKeyPair(groupPublicKey) {
 | |
|   const rows = getAllEncryptionKeyPairsForGroup(groupPublicKey);
 | |
|   if (!rows || rows.length === 0) {
 | |
|     return undefined;
 | |
|   }
 | |
|   return rows[rows.length - 1];
 | |
| }
 | |
| 
 | |
| function addClosedGroupEncryptionKeyPair(groupPublicKey, keypair, instance) {
 | |
|   const timestamp = Date.now();
 | |
| 
 | |
|   (globalInstance || instance)
 | |
|     .prepare(
 | |
|       `INSERT OR REPLACE INTO ${CLOSED_GROUP_V2_KEY_PAIRS_TABLE} (
 | |
|       groupPublicKey,
 | |
|       timestamp,
 | |
|         json
 | |
|         ) values (
 | |
|           $groupPublicKey,
 | |
|           $timestamp,
 | |
|           $json
 | |
|           );`
 | |
|     )
 | |
|     .run({
 | |
|       groupPublicKey,
 | |
|       timestamp,
 | |
|       json: objectToJSON(keypair),
 | |
|     });
 | |
| }
 | |
| 
 | |
| function removeAllClosedGroupEncryptionKeyPairs(groupPublicKey) {
 | |
|   globalInstance
 | |
|     .prepare(
 | |
|       `DELETE FROM ${CLOSED_GROUP_V2_KEY_PAIRS_TABLE} WHERE groupPublicKey = $groupPublicKey`
 | |
|     )
 | |
|     .run({
 | |
|       groupPublicKey,
 | |
|     });
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Related to Opengroup V2
 | |
|  */
 | |
| function getAllV2OpenGroupRooms() {
 | |
|   const rows = globalInstance.prepare(`SELECT json FROM ${OPEN_GROUP_ROOMS_V2_TABLE};`).all();
 | |
| 
 | |
|   return map(rows, row => jsonToObject(row.json));
 | |
| }
 | |
| 
 | |
| function getV2OpenGroupRoom(conversationId) {
 | |
|   const row = globalInstance
 | |
|     .prepare(`SELECT * FROM ${OPEN_GROUP_ROOMS_V2_TABLE} WHERE conversationId = $conversationId;`)
 | |
|     .get({
 | |
|       conversationId,
 | |
|     });
 | |
| 
 | |
|   if (!row) {
 | |
|     return null;
 | |
|   }
 | |
| 
 | |
|   return jsonToObject(row.json);
 | |
| }
 | |
| 
 | |
| function getV2OpenGroupRoomByRoomId(serverUrl, roomId) {
 | |
|   const row = globalInstance
 | |
|     .prepare(
 | |
|       `SELECT * FROM ${OPEN_GROUP_ROOMS_V2_TABLE} WHERE serverUrl = $serverUrl AND roomId = $roomId;`
 | |
|     )
 | |
|     .get({
 | |
|       serverUrl,
 | |
|       roomId,
 | |
|     });
 | |
| 
 | |
|   if (!row) {
 | |
|     return null;
 | |
|   }
 | |
| 
 | |
|   return jsonToObject(row.json);
 | |
| }
 | |
| 
 | |
| function saveV2OpenGroupRoom(opengroupsv2Room) {
 | |
|   const { serverUrl, roomId, conversationId } = opengroupsv2Room;
 | |
|   globalInstance
 | |
|     .prepare(
 | |
|       `INSERT OR REPLACE INTO ${OPEN_GROUP_ROOMS_V2_TABLE} (
 | |
|       serverUrl,
 | |
|       roomId,
 | |
|       conversationId,
 | |
|       json
 | |
|     ) values (
 | |
|       $serverUrl,
 | |
|       $roomId,
 | |
|       $conversationId,
 | |
|       $json
 | |
|     )`
 | |
|     )
 | |
|     .run({
 | |
|       serverUrl,
 | |
|       roomId,
 | |
|       conversationId,
 | |
|       json: objectToJSON(opengroupsv2Room),
 | |
|     });
 | |
| }
 | |
| 
 | |
| function removeV2OpenGroupRoom(conversationId) {
 | |
|   globalInstance
 | |
|     .prepare(`DELETE FROM ${OPEN_GROUP_ROOMS_V2_TABLE} WHERE conversationId = $conversationId`)
 | |
|     .run({
 | |
|       conversationId,
 | |
|     });
 | |
| }
 | |
| 
 | |
| function removeOneOpenGroupV1Message() {
 | |
|   const row = globalInstance
 | |
|     .prepare(
 | |
|       `SELECT count(*) from ${MESSAGES_TABLE} WHERE
 | |
|     conversationId LIKE 'publicChat:1@%';`
 | |
|     )
 | |
|     .get();
 | |
|   const toRemoveCount = row['count(*)'];
 | |
| 
 | |
|   if (toRemoveCount <= 0) {
 | |
|     return 0;
 | |
|   }
 | |
|   console.warn('left opengroupv1 message to remove: ', toRemoveCount);
 | |
|   const rowMessageIds = globalInstance
 | |
|     .prepare(
 | |
|       `SELECT id from ${MESSAGES_TABLE} WHERE conversationId LIKE 'publicChat:1@%' ORDER BY id LIMIT 1;`
 | |
|     )
 | |
|     .all();
 | |
| 
 | |
|   const messagesIds = map(rowMessageIds, r => r.id)[0];
 | |
| 
 | |
|   console.time('removeOneOpenGroupV1Message');
 | |
| 
 | |
|   removeMessage(messagesIds);
 | |
|   console.timeEnd('removeOneOpenGroupV1Message');
 | |
| 
 | |
|   return toRemoveCount - 1;
 | |
| }
 |