Co-authored-by: scott@signal.org
Co-authored-by: ken@signal.org
This commit is contained in:
Ken Powers 2019-05-16 15:32:11 -07:00 committed by Scott Nonnenberg
parent 8c8856785b
commit 29de50c12a
100 changed files with 7572 additions and 693 deletions

View file

@ -11,6 +11,7 @@ module.exports = {
let initialized = false;
const ERASE_ATTACHMENTS_KEY = 'erase-attachments';
const ERASE_STICKERS_KEY = 'erase-stickers';
const CLEANUP_ORPHANED_ATTACHMENTS_KEY = 'cleanup-orphaned-attachments';
async function initialize({ configDir, cleanupOrphanedAttachments }) {
@ -19,12 +20,10 @@ async function initialize({ configDir, cleanupOrphanedAttachments }) {
}
initialized = true;
console.log('Ensure attachments directory exists');
await Attachments.ensureDirectory(configDir);
const attachmentsDir = Attachments.getPath(configDir);
const stickersDir = Attachments.getStickersPath(configDir);
ipcMain.on(ERASE_ATTACHMENTS_KEY, async event => {
ipcMain.on(ERASE_ATTACHMENTS_KEY, event => {
try {
rimraf.sync(attachmentsDir);
event.sender.send(`${ERASE_ATTACHMENTS_KEY}-done`);
@ -35,6 +34,17 @@ async function initialize({ configDir, cleanupOrphanedAttachments }) {
}
});
ipcMain.on(ERASE_STICKERS_KEY, event => {
try {
rimraf.sync(stickersDir);
event.sender.send(`${ERASE_STICKERS_KEY}-done`);
} catch (error) {
const errorForDisplay = error && error.stack ? error.stack : error;
console.log(`erase stickers error: ${errorForDisplay}`);
event.sender.send(`${ERASE_STICKERS_KEY}-done`, error);
}
});
ipcMain.on(CLEANUP_ORPHANED_ATTACHMENTS_KEY, async event => {
try {
await cleanupOrphanedAttachments();

View file

@ -8,6 +8,7 @@ const toArrayBuffer = require('to-arraybuffer');
const { map, isArrayBuffer, isString } = require('lodash');
const PATH = 'attachments.noindex';
const STICKER_PATH = 'stickers.noindex';
exports.getAllAttachments = async userDataPath => {
const dir = exports.getPath(userDataPath);
@ -17,6 +18,14 @@ exports.getAllAttachments = async userDataPath => {
return map(files, file => path.relative(dir, file));
};
exports.getAllStickers = async userDataPath => {
const dir = exports.getStickersPath(userDataPath);
const pattern = path.join(dir, '**', '*');
const files = await pify(glob)(pattern, { nodir: true });
return map(files, file => path.relative(dir, file));
};
// getPath :: AbsolutePath -> AbsolutePath
exports.getPath = userDataPath => {
if (!isString(userDataPath)) {
@ -25,12 +34,12 @@ exports.getPath = userDataPath => {
return path.join(userDataPath, PATH);
};
// ensureDirectory :: AbsolutePath -> IO Unit
exports.ensureDirectory = async userDataPath => {
// getStickersPath :: AbsolutePath -> AbsolutePath
exports.getStickersPath = userDataPath => {
if (!isString(userDataPath)) {
throw new TypeError("'userDataPath' must be a string");
}
await fse.ensureDir(exports.getPath(userDataPath));
return path.join(userDataPath, STICKER_PATH);
};
// createReader :: AttachmentsPath ->
@ -56,6 +65,30 @@ exports.createReader = root => {
};
};
exports.copyIntoAttachmentsDirectory = root => {
if (!isString(root)) {
throw new TypeError("'root' must be a path");
}
return async sourcePath => {
if (!isString(sourcePath)) {
throw new TypeError('sourcePath must be a string');
}
const name = exports.createName();
const relativePath = exports.getRelativePath(name);
const absolutePath = path.join(root, relativePath);
const normalized = path.normalize(absolutePath);
if (!normalized.startsWith(root)) {
throw new Error('Invalid relative path');
}
await fse.ensureFile(normalized);
await fse.copy(sourcePath, normalized);
return relativePath;
};
};
// createWriterForNew :: AttachmentsPath ->
// ArrayBuffer ->
// IO (Promise RelativePath)
@ -142,6 +175,20 @@ exports.deleteAll = async ({ userDataPath, attachments }) => {
console.log(`deleteAll: deleted ${attachments.length} files`);
};
exports.deleteAllStickers = async ({ userDataPath, stickers }) => {
const deleteFromDisk = exports.createDeleter(
exports.getStickersPath(userDataPath)
);
for (let index = 0, max = stickers.length; index < max; index += 1) {
const file = stickers[index];
// eslint-disable-next-line no-await-in-loop
await deleteFromDisk(file);
}
console.log(`deleteAllStickers: deleted ${stickers.length} files`);
};
// createName :: Unit -> IO String
exports.createName = () => {
const buffer = crypto.randomBytes(32);

View file

@ -1,4 +1,4 @@
const path = require('path');
const { join } = require('path');
const mkdirp = require('mkdirp');
const rimraf = require('rimraf');
const sql = require('@journeyapps/sqlcipher');
@ -8,7 +8,15 @@ const { remove: removeUserConfig } = require('./user_config');
const pify = require('pify');
const uuidv4 = require('uuid/v4');
const { map, isObject, isString, fromPairs, forEach, last } = require('lodash');
const {
forEach,
fromPairs,
isNumber,
isObject,
isString,
last,
map,
} = require('lodash');
// To get long stack traces
// https://github.com/mapbox/node-sqlite3/wiki/API#sqlite3verbose
@ -104,6 +112,17 @@ module.exports = {
removeAttachmentDownloadJob,
removeAllAttachmentDownloadJobs,
createOrUpdateStickerPack,
updateStickerPackStatus,
createOrUpdateSticker,
updateStickerLastUsed,
addStickerPackReference,
deleteStickerPackReference,
deleteStickerPack,
getAllStickerPacks,
getAllStickers,
getRecentStickers,
removeAll,
removeAllConfiguration,
@ -112,6 +131,7 @@ module.exports = {
getMessagesWithFileAttachments,
removeKnownAttachments,
removeKnownStickers,
};
function generateUUID() {
@ -179,6 +199,9 @@ async function setupSQLCipher(instance, { key }) {
// https://www.zetetic.net/sqlcipher/sqlcipher-api/#key
await instance.run(`PRAGMA key = "x'${key}'";`);
// Because foreign key support is not enabled by default!
await instance.run('PRAGMA foreign_keys = ON;');
}
async function updateToSchemaVersion1(currentVersion, instance) {
@ -635,6 +658,83 @@ async function updateToSchemaVersion11(currentVersion, instance) {
console.log('updateToSchemaVersion11: success!');
}
async function updateToSchemaVersion12(currentVersion, instance) {
if (currentVersion >= 12) {
return;
}
console.log('updateToSchemaVersion12: starting...');
await instance.run('BEGIN TRANSACTION;');
await instance.run(`CREATE TABLE sticker_packs(
id TEXT PRIMARY KEY,
key TEXT NOT NULL,
author STRING,
coverStickerId INTEGER,
createdAt INTEGER,
downloadAttempts INTEGER,
installedAt INTEGER,
lastUsed INTEGER,
status STRING,
stickerCount INTEGER,
title STRING
);`);
await instance.run(`CREATE TABLE stickers(
id INTEGER NOT NULL,
packId TEXT NOT NULL,
emoji STRING,
height INTEGER,
isCoverOnly INTEGER,
lastUsed INTEGER,
path STRING,
width INTEGER,
PRIMARY KEY (id, packId),
CONSTRAINT stickers_fk
FOREIGN KEY (packId)
REFERENCES sticker_packs(id)
ON DELETE CASCADE
);`);
await instance.run(`CREATE INDEX stickers_recents
ON stickers (
lastUsed
) WHERE lastUsed IS NOT NULL;`);
await instance.run(`CREATE TABLE sticker_references(
messageId STRING,
packId TEXT,
CONSTRAINT sticker_references_fk
FOREIGN KEY(packId)
REFERENCES sticker_packs(id)
ON DELETE CASCADE
);`);
await instance.run('PRAGMA schema_version = 12;');
await instance.run('COMMIT TRANSACTION;');
console.log('updateToSchemaVersion12: success!');
}
async function updateToSchemaVersion13(currentVersion, instance) {
if (currentVersion >= 13) {
return;
}
console.log('updateToSchemaVersion13: starting...');
await instance.run('BEGIN TRANSACTION;');
await instance.run(
'ALTER TABLE sticker_packs ADD COLUMN attemptedStatus STRING;'
);
await instance.run('PRAGMA schema_version = 13;');
await instance.run('COMMIT TRANSACTION;');
console.log('updateToSchemaVersion13: success!');
}
const SCHEMA_VERSIONS = [
updateToSchemaVersion1,
updateToSchemaVersion2,
@ -647,6 +747,8 @@ const SCHEMA_VERSIONS = [
updateToSchemaVersion9,
updateToSchemaVersion10,
updateToSchemaVersion11,
updateToSchemaVersion12,
updateToSchemaVersion13,
];
async function updateSchema(instance) {
@ -689,12 +791,12 @@ async function initialize({ configDir, key, messages }) {
throw new Error('initialize: message is required!');
}
indexedDBPath = path.join(configDir, 'IndexedDB');
indexedDBPath = join(configDir, 'IndexedDB');
const dbDir = path.join(configDir, 'sql');
const dbDir = join(configDir, 'sql');
mkdirp.sync(dbDir);
filePath = path.join(dbDir, 'db.sqlite');
filePath = join(dbDir, 'db.sqlite');
try {
const sqlInstance = await openDatabase(filePath);
@ -773,7 +875,7 @@ async function removeIndexedDBFiles() {
);
}
const pattern = path.join(indexedDBPath, '*.leveldb');
const pattern = join(indexedDBPath, '*.leveldb');
rimraf.sync(pattern);
indexedDBPath = null;
}
@ -1507,6 +1609,7 @@ async function getOutgoingWithoutExpiresAt() {
}
async function getNextExpiringMessage() {
// Note: we avoid 'IS NOT NULL' here because it does seem to bypass our index
const rows = await db.all(`
SELECT json FROM messages
WHERE expires_at > 0
@ -1658,6 +1761,8 @@ async function removeAllUnprocessed() {
await db.run('DELETE FROM unprocessed;');
}
// Attachment Downloads
const ATTACHMENT_DOWNLOADS_TABLE = 'attachment_downloads';
async function getNextAttachmentDownloadJobs(limit, options = {}) {
const timestamp = options.timestamp || Date.now();
@ -1724,6 +1829,359 @@ async function removeAllAttachmentDownloadJobs() {
return removeAllFromTable(ATTACHMENT_DOWNLOADS_TABLE);
}
// Stickers
async function createOrUpdateStickerPack(pack) {
const {
attemptedStatus,
author,
coverStickerId,
createdAt,
downloadAttempts,
id,
installedAt,
key,
lastUsed,
status,
stickerCount,
title,
} = pack;
if (!id) {
throw new Error(
'createOrUpdateStickerPack: Provided data did not have a truthy id'
);
}
await db.run(
`INSERT OR REPLACE INTO sticker_packs (
attemptedStatus,
author,
coverStickerId,
createdAt,
downloadAttempts,
id,
installedAt,
key,
lastUsed,
status,
stickerCount,
title
) values (
$attemptedStatus,
$author,
$coverStickerId,
$createdAt,
$downloadAttempts,
$id,
$installedAt,
$key,
$lastUsed,
$status,
$stickerCount,
$title
)`,
{
$attemptedStatus: attemptedStatus,
$author: author,
$coverStickerId: coverStickerId,
$createdAt: createdAt || Date.now(),
$downloadAttempts: downloadAttempts || 1,
$id: id,
$installedAt: installedAt,
$key: key,
$lastUsed: lastUsed || null,
$status: status,
$stickerCount: stickerCount,
$title: title,
}
);
}
async function updateStickerPackStatus(id, status, options) {
// Strange, but an undefined parameter gets coerced into null via ipc
const timestamp = (options || {}).timestamp || Date.now();
const installedAt = status === 'installed' ? timestamp : null;
await db.run(
`UPDATE sticker_packs
SET status = $status, installedAt = $installedAt
WHERE id = $id;
)`,
{
$id: id,
$status: status,
$installedAt: installedAt,
}
);
}
async function createOrUpdateSticker(sticker) {
const {
emoji,
height,
id,
isCoverOnly,
lastUsed,
packId,
path,
width,
} = sticker;
if (!isNumber(id)) {
throw new Error(
'createOrUpdateSticker: Provided data did not have a numeric id'
);
}
if (!packId) {
throw new Error(
'createOrUpdateSticker: Provided data did not have a truthy id'
);
}
await db.run(
`INSERT OR REPLACE INTO stickers (
emoji,
height,
id,
isCoverOnly,
lastUsed,
packId,
path,
width
) values (
$emoji,
$height,
$id,
$isCoverOnly,
$lastUsed,
$packId,
$path,
$width
)`,
{
$emoji: emoji,
$height: height,
$id: id,
$isCoverOnly: isCoverOnly,
$lastUsed: lastUsed,
$packId: packId,
$path: path,
$width: width,
}
);
}
async function updateStickerLastUsed(packId, stickerId, lastUsed) {
await db.run(
`UPDATE stickers
SET lastUsed = $lastUsed
WHERE id = $id AND packId = $packId;`,
{
$id: stickerId,
$packId: packId,
$lastUsed: lastUsed,
}
);
await db.run(
`UPDATE sticker_packs
SET lastUsed = $lastUsed
WHERE id = $id;`,
{
$id: packId,
$lastUsed: lastUsed,
}
);
}
async function addStickerPackReference(messageId, packId) {
if (!messageId) {
throw new Error(
'addStickerPackReference: Provided data did not have a truthy messageId'
);
}
if (!packId) {
throw new Error(
'addStickerPackReference: Provided data did not have a truthy packId'
);
}
await db.run(
`INSERT OR REPLACE INTO sticker_references (
messageId,
packId
) values (
$messageId,
$packId
)`,
{
$messageId: messageId,
$packId: packId,
}
);
}
async function deleteStickerPackReference(messageId, packId) {
if (!messageId) {
throw new Error(
'addStickerPackReference: Provided data did not have a truthy messageId'
);
}
if (!packId) {
throw new Error(
'addStickerPackReference: Provided data did not have a truthy packId'
);
}
try {
// We use an immediate transaction here to immediately acquire an exclusive lock,
// which would normally only happen when we did our first write.
// We need this to ensure that our five queries are all atomic, with no other changes
// happening while we do it:
// 1. Delete our target messageId/packId references
// 2. Check the number of references still pointing at packId
// 3. If that number is zero, get pack from sticker_packs database
// 4. If it's not installed, then grab all of its sticker paths
// 5. If it's not installed, then sticker pack (which cascades to all stickers and
// references)
await db.run('BEGIN IMMEDIATE TRANSACTION;');
await db.run(
`DELETE FROM sticker_references
WHERE messageId = $messageId AND packId = $packId;`,
{
$messageId: messageId,
$packId: packId,
}
);
const countRow = await db.get(
`SELECT count(*) FROM sticker_references
WHERE packId = $packId;`,
{ $packId: packId }
);
if (!countRow) {
throw new Error(
'deleteStickerPackReference: Unable to get count of references'
);
}
const count = countRow['count(*)'];
if (count > 0) {
await db.run('COMMIT TRANSACTION');
return null;
}
const packRow = await db.get(
`SELECT status FROM sticker_packs
WHERE id = $packId;`,
{ $packId: packId }
);
if (!packRow) {
console.log('deleteStickerPackReference: did not find referenced pack');
await db.run('COMMIT TRANSACTION');
return null;
}
const { status } = packRow;
if (status === 'installed') {
await db.run('COMMIT TRANSACTION');
return null;
}
const stickerPathRows = await db.all(
`SELECT path FROM stickers
WHERE packId = $packId;`,
{
$packId: packId,
}
);
await db.run(
`DELETE FROM sticker_packs
WHERE id = $packId;`,
{ $packId: packId }
);
await db.run('COMMIT TRANSACTION;');
return (stickerPathRows || []).map(row => row.path);
} catch (error) {
await db.run('ROLLBACK;');
throw error;
}
}
async function deleteStickerPack(packId) {
if (!packId) {
throw new Error(
'deleteStickerPack: Provided data did not have a truthy packId'
);
}
try {
// We use an immediate transaction here to immediately acquire an exclusive lock,
// which would normally only happen when we did our first write.
// We need this to ensure that our two queries are atomic, with no other changes
// happening while we do it:
// 1. Grab all of target pack's sticker paths
// 2. Delete sticker pack (which cascades to all stickers and references)
await db.run('BEGIN IMMEDIATE TRANSACTION;');
const stickerPathRows = await db.all(
`SELECT path FROM stickers
WHERE packId = $packId;`,
{
$packId: packId,
}
);
await db.run(
`DELETE FROM sticker_packs
WHERE id = $packId;`,
{ $packId: packId }
);
await db.run('COMMIT TRANSACTION;');
return (stickerPathRows || []).map(row => row.path);
} catch (error) {
await db.run('ROLLBACK;');
throw error;
}
}
async function getStickerCount() {
const row = await db.get('SELECT count(*) from stickers;');
if (!row) {
throw new Error('getStickerCount: Unable to get count of stickers');
}
return row['count(*)'];
}
async function getAllStickerPacks() {
const rows = await db.all(
`SELECT * FROM sticker_packs
ORDER BY installedAt DESC, createdAt DESC`
);
return rows || [];
}
async function getAllStickers() {
const rows = await db.all(
`SELECT * FROM stickers
ORDER BY packId ASC, id ASC`
);
return rows || [];
}
async function getRecentStickers({ limit } = {}) {
// Note: we avoid 'IS NOT NULL' here because it does seem to bypass our index
const rows = await db.all(
`SELECT stickers.* FROM stickers
JOIN sticker_packs on stickers.packId = sticker_packs.id
WHERE stickers.lastUsed > 0 AND sticker_packs.status = 'installed'
ORDER BY stickers.lastUsed DESC
LIMIT $limit`,
{
$limit: limit || 24,
}
);
return rows || [];
}
// All data in database
async function removeAll() {
let promise;
@ -1741,6 +2199,9 @@ async function removeAll() {
db.run('DELETE FROM unprocessed;'),
db.run('DELETE FROM attachment_downloads;'),
db.run('DELETE FROM messages_fts;'),
db.run('DELETE FROM stickers;'),
db.run('DELETE FROM sticker_packs;'),
db.run('DELETE FROM sticker_references;'),
db.run('COMMIT TRANSACTION;'),
]);
});
@ -1818,7 +2279,7 @@ async function getMessagesWithFileAttachments(conversationId, { limit }) {
}
function getExternalFilesForMessage(message) {
const { attachments, contact, quote, preview } = message;
const { attachments, contact, quote, preview, sticker } = message;
const files = [];
forEach(attachments, attachment => {
@ -1866,6 +2327,14 @@ function getExternalFilesForMessage(message) {
});
}
if (sticker && sticker.data && sticker.data.path) {
files.push(sticker.data.path);
if (sticker.data.thumbnail && sticker.data.thumbnail.path) {
files.push(sticker.data.thumbnail.path);
}
}
return files;
}
@ -1972,3 +2441,47 @@ async function removeKnownAttachments(allAttachments) {
return Object.keys(lookup);
}
async function removeKnownStickers(allStickers) {
const lookup = fromPairs(map(allStickers, file => [file, true]));
const chunkSize = 50;
const total = await getStickerCount();
console.log(
`removeKnownStickers: About to iterate through ${total} stickers`
);
let count = 0;
let complete = false;
let rowid = 0;
while (!complete) {
// eslint-disable-next-line no-await-in-loop
const rows = await db.all(
`SELECT rowid, path FROM stickers
WHERE rowid > $rowid
ORDER BY rowid ASC
LIMIT $chunkSize;`,
{
$rowid: rowid,
$chunkSize: chunkSize,
}
);
const files = map(rows, row => row.path);
forEach(files, file => {
delete lookup[file];
});
const lastSticker = last(rows);
if (lastSticker) {
({ rowid } = lastSticker);
}
complete = rows.length < chunkSize;
count += rows.length;
}
console.log(`removeKnownStickers: Done processing ${count} stickers`);
return Object.keys(lookup);
}

View file

@ -1,4 +1,5 @@
const electron = require('electron');
const Queue = require('p-queue');
const sql = require('./sql');
const { remove: removeUserConfig } = require('./user_config');
const { remove: removeEphemeralConfig } = require('./ephemeral_config');
@ -14,6 +15,8 @@ let initialized = false;
const SQL_CHANNEL_KEY = 'sql-channel';
const ERASE_SQL_KEY = 'erase-sql-key';
const queue = new Queue({ concurrency: 1 });
function initialize() {
if (initialized) {
throw new Error('sqlChannels: already initialized!');
@ -29,7 +32,10 @@ function initialize() {
);
}
const result = await fn(...args);
// Note: we queue here to keep multi-query operations atomic. Without it, any
// multistage data operation (even within a BEGIN/COMMIT) can become interleaved,
// since all requests share one database connection.
const result = await queue.add(() => fn(...args));
event.sender.send(`${SQL_CHANNEL_KEY}-done`, jobId, null, result);
} catch (error) {
const errorForDisplay = error && error.stack ? error.stack : error;