signal-desktop/ts/sql/Client.ts
2023-01-03 11:55:46 -08:00

842 lines
22 KiB
TypeScript

// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { ipcRenderer as ipc } from 'electron';
import fs from 'fs-extra';
import pify from 'pify';
import PQueue from 'p-queue';
import {
compact,
fromPairs,
groupBy,
isFunction,
isTypedArray,
last,
map,
omit,
toPairs,
} from 'lodash';
import { deleteExternalFiles } from '../types/Conversation';
import { expiringMessagesDeletionService } from '../services/expiringMessagesDeletion';
import { tapToViewMessagesDeletionService } from '../services/tapToViewMessagesDeletionService';
import * as Bytes from '../Bytes';
import { createBatcher } from '../util/batcher';
import { explodePromise } from '../util/explodePromise';
import { assertDev, softAssert, strictAssert } from '../util/assert';
import { mapObjectWithSpec } from '../util/mapObjectWithSpec';
import type { ObjectMappingSpecType } from '../util/mapObjectWithSpec';
import { cleanDataForIpc } from './cleanDataForIpc';
import type { UUIDStringType } from '../types/UUID';
import createTaskWithTimeout from '../textsecure/TaskWithTimeout';
import * as log from '../logging/log';
import { isValidUuid } from '../types/UUID';
import * as Errors from '../types/errors';
import type { StoredJob } from '../jobs/types';
import { formatJobForInsert } from '../jobs/formatJobForInsert';
import { cleanupMessage } from '../util/cleanup';
import { drop } from '../util/drop';
import type {
AllItemsType,
AttachmentDownloadJobType,
ClientInterface,
ClientExclusiveInterface,
ClientSearchResultMessageType,
ConversationType,
GetConversationRangeCenteredOnMessageResultType,
IdentityKeyIdType,
IdentityKeyType,
StoredIdentityKeyType,
ItemKeyType,
ItemType,
StoredItemType,
MessageType,
MessageTypeUnhydrated,
PreKeyIdType,
PreKeyType,
StoredPreKeyType,
ServerInterface,
ServerSearchResultMessageType,
SignedPreKeyIdType,
SignedPreKeyType,
StoredSignedPreKeyType,
} from './Interface';
import Server from './Server';
import { isCorruptionError } from './errors';
import { MINUTE } from '../util/durations';
import { getMessageIdForLogging } from '../util/idForLogging';
const getRealPath = pify(fs.realpath);
const MIN_TRACE_DURATION = 10;
const SQL_CHANNEL_KEY = 'sql-channel';
const ERASE_SQL_KEY = 'erase-sql-key';
const ERASE_ATTACHMENTS_KEY = 'erase-attachments';
const ERASE_STICKERS_KEY = 'erase-stickers';
const ERASE_TEMP_KEY = 'erase-temp';
const ERASE_DRAFTS_KEY = 'erase-drafts';
const CLEANUP_ORPHANED_ATTACHMENTS_KEY = 'cleanup-orphaned-attachments';
const ENSURE_FILE_PERMISSIONS = 'ensure-file-permissions';
enum RendererState {
InMain = 'InMain',
Opening = 'Opening',
InRenderer = 'InRenderer',
Closing = 'Closing',
}
let activeJobCount = 0;
let resolveShutdown: (() => void) | undefined;
let shutdownPromise: Promise<void> | null = null;
let state = RendererState.InMain;
const startupQueries = new Map<string, number>();
async function startInRendererProcess(isTesting = false): Promise<void> {
strictAssert(
state === RendererState.InMain,
`startInRendererProcess: expected ${state} to be ${RendererState.InMain}`
);
log.info('data.startInRendererProcess: switching to renderer process');
state = RendererState.Opening;
if (!isTesting) {
await ipc.invoke('database-ready');
}
const configDir = await getRealPath(ipc.sendSync('get-user-data-path'));
const key = ipc.sendSync('user-config-key');
await Server.initializeRenderer({ configDir, key });
log.info('data.startInRendererProcess: switched to renderer process');
state = RendererState.InRenderer;
}
async function goBackToMainProcess(): Promise<void> {
if (state === RendererState.InMain) {
log.info('goBackToMainProcess: Already in the main process');
return;
}
strictAssert(
state === RendererState.InRenderer,
`goBackToMainProcess: expected ${state} to be ${RendererState.InRenderer}`
);
// We don't need to wait for pending queries since they are synchronous.
log.info('data.goBackToMainProcess: switching to main process');
const closePromise = channels.close();
// It should be the last query we run in renderer process
state = RendererState.Closing;
await closePromise;
state = RendererState.InMain;
// Print query statistics for whole startup
const entries = Array.from(startupQueries.entries());
startupQueries.clear();
// Sort by decreasing duration
entries
.sort((a, b) => b[1] - a[1])
.filter(([_, duration]) => duration > MIN_TRACE_DURATION)
.forEach(([query, duration]) => {
log.info(`startup query: ${query} ${duration}ms`);
});
log.info('data.goBackToMainProcess: switched to main process');
}
const channelsAsUnknown = fromPairs(
compact(
map(toPairs(Server), ([name, value]: [string, unknown]) => {
if (isFunction(value)) {
return [name, makeChannel(name)];
}
return null;
})
)
) as unknown;
const channels: ServerInterface = channelsAsUnknown as ServerInterface;
const exclusiveInterface: ClientExclusiveInterface = {
createOrUpdateIdentityKey,
getIdentityKeyById,
bulkAddIdentityKeys,
getAllIdentityKeys,
createOrUpdatePreKey,
getPreKeyById,
bulkAddPreKeys,
getAllPreKeys,
createOrUpdateSignedPreKey,
getSignedPreKeyById,
bulkAddSignedPreKeys,
getAllSignedPreKeys,
createOrUpdateItem,
getItemById,
getAllItems,
updateConversation,
removeConversation,
searchMessages,
searchMessagesInConversation,
getOlderMessagesByConversation,
getConversationRangeCenteredOnMessage,
getNewerMessagesByConversation,
// Client-side only
shutdown,
removeAllMessagesInConversation,
removeOtherData,
cleanupOrphanedAttachments,
ensureFilePermissions,
// Client-side only, and test-only
startInRendererProcess,
goBackToMainProcess,
};
// Because we can't force this module to conform to an interface, we narrow our exports
// to this one default export, which does conform to the interface.
// Note: In Javascript, you need to access the .default property when requiring it
// https://github.com/microsoft/TypeScript/issues/420
const dataInterface: ClientInterface = {
...channels,
...exclusiveInterface,
// Overrides
updateConversations,
saveMessage,
saveMessages,
removeMessage,
saveAttachmentDownloadJob,
};
export default dataInterface;
function _cleanData(
data: unknown
): ReturnType<typeof cleanDataForIpc>['cleaned'] {
const { cleaned, pathsChanged } = cleanDataForIpc(data);
if (pathsChanged.length) {
log.info(
`_cleanData cleaned the following paths: ${pathsChanged.join(', ')}`
);
}
return cleaned;
}
export function _cleanMessageData(data: MessageType): MessageType {
const result = { ...data };
// Ensure that all messages have the received_at set properly
if (!data.received_at) {
assertDev(false, 'received_at was not set on the message');
result.received_at = window.Signal.Util.incrementMessageCounter();
}
if (data.attachments) {
const logId = getMessageIdForLogging(data);
result.attachments = data.attachments.map((attachment, index) => {
if (attachment.data && !isTypedArray(attachment.data)) {
log.warn(
`_cleanMessageData/${logId}: Attachment ${index} had non-array \`data\` field; deleting.`
);
return omit(attachment, ['data']);
}
return attachment;
});
}
return _cleanData(omit(result, ['dataMessage']));
}
async function doShutdown() {
log.info(
`data.shutdown: shutdown requested. ${activeJobCount} jobs outstanding`
);
if (shutdownPromise) {
return shutdownPromise;
}
// No outstanding jobs, return immediately
if (activeJobCount === 0) {
return;
}
({ promise: shutdownPromise, resolve: resolveShutdown } =
explodePromise<void>());
try {
await shutdownPromise;
} finally {
log.info('data.shutdown: process complete');
}
}
function makeChannel(fnName: string) {
return async (...args: ReadonlyArray<unknown>) => {
// During startup we want to avoid the high overhead of IPC so we utilize
// the db that exists in the renderer process to be able to boot up quickly
// once the app is running we switch back to the main process to avoid the
// UI from locking up whenever we do costly db operations.
if (state === RendererState.InRenderer) {
const serverFnName = fnName as keyof ServerInterface;
const serverFn = Server[serverFnName] as (
...fnArgs: ReadonlyArray<unknown>
) => unknown;
const start = Date.now();
try {
// Ignoring this error TS2556: Expected 3 arguments, but got 0 or more.
return await serverFn(...args);
} catch (error) {
if (isCorruptionError(error)) {
log.error(
'Detected sql corruption in renderer process. ' +
`Restarting the application immediately. Error: ${error.message}`
);
ipc?.send('database-error', error.stack);
}
log.error(
`Renderer SQL channel job (${fnName}) error ${error.message}`
);
throw error;
} finally {
const duration = Date.now() - start;
startupQueries.set(
serverFnName,
(startupQueries.get(serverFnName) || 0) + duration
);
if (duration > MIN_TRACE_DURATION) {
log.info(
`Renderer SQL channel job (${fnName}) completed in ${duration}ms`
);
}
}
}
if (shutdownPromise && fnName !== 'close') {
throw new Error(
`Rejecting SQL channel job (${fnName}); application is shutting down`
);
}
activeJobCount += 1;
return createTaskWithTimeout(async () => {
try {
return await ipc.invoke(SQL_CHANNEL_KEY, fnName, ...args);
} finally {
activeJobCount -= 1;
if (activeJobCount === 0) {
resolveShutdown?.();
}
}
}, `SQL channel call (${fnName})`)();
};
}
function specToBytes<Input, Output>(
spec: ObjectMappingSpecType,
data: Input
): Output {
return mapObjectWithSpec<string, Uint8Array>(spec, data, x =>
Bytes.fromBase64(x)
);
}
function specFromBytes<Input, Output>(
spec: ObjectMappingSpecType,
data: Input
): Output {
return mapObjectWithSpec<Uint8Array, string>(spec, data, x =>
Bytes.toBase64(x)
);
}
// Top-level calls
async function shutdown(): Promise<void> {
log.info('Client.shutdown');
// Stop accepting new SQL jobs, flush outstanding queue
await doShutdown();
// Close database
await channels.close();
}
// Identity Keys
const IDENTITY_KEY_SPEC = ['publicKey'];
async function createOrUpdateIdentityKey(data: IdentityKeyType): Promise<void> {
const updated: StoredIdentityKeyType = specFromBytes(IDENTITY_KEY_SPEC, data);
await channels.createOrUpdateIdentityKey(updated);
}
async function getIdentityKeyById(
id: IdentityKeyIdType
): Promise<IdentityKeyType | undefined> {
const data = await channels.getIdentityKeyById(id);
return specToBytes(IDENTITY_KEY_SPEC, data);
}
async function bulkAddIdentityKeys(
array: Array<IdentityKeyType>
): Promise<void> {
const updated: Array<StoredIdentityKeyType> = map(array, data =>
specFromBytes(IDENTITY_KEY_SPEC, data)
);
await channels.bulkAddIdentityKeys(updated);
}
async function getAllIdentityKeys(): Promise<Array<IdentityKeyType>> {
const keys = await channels.getAllIdentityKeys();
return keys.map(key => specToBytes(IDENTITY_KEY_SPEC, key));
}
// Pre Keys
async function createOrUpdatePreKey(data: PreKeyType): Promise<void> {
const updated: StoredPreKeyType = specFromBytes(PRE_KEY_SPEC, data);
await channels.createOrUpdatePreKey(updated);
}
async function getPreKeyById(
id: PreKeyIdType
): Promise<PreKeyType | undefined> {
const data = await channels.getPreKeyById(id);
return specToBytes(PRE_KEY_SPEC, data);
}
async function bulkAddPreKeys(array: Array<PreKeyType>): Promise<void> {
const updated: Array<StoredPreKeyType> = map(array, data =>
specFromBytes(PRE_KEY_SPEC, data)
);
await channels.bulkAddPreKeys(updated);
}
async function getAllPreKeys(): Promise<Array<PreKeyType>> {
const keys = await channels.getAllPreKeys();
return keys.map(key => specToBytes(PRE_KEY_SPEC, key));
}
// Signed Pre Keys
const PRE_KEY_SPEC = ['privateKey', 'publicKey'];
async function createOrUpdateSignedPreKey(
data: SignedPreKeyType
): Promise<void> {
const updated: StoredSignedPreKeyType = specFromBytes(PRE_KEY_SPEC, data);
await channels.createOrUpdateSignedPreKey(updated);
}
async function getSignedPreKeyById(
id: SignedPreKeyIdType
): Promise<SignedPreKeyType | undefined> {
const data = await channels.getSignedPreKeyById(id);
return specToBytes(PRE_KEY_SPEC, data);
}
async function getAllSignedPreKeys(): Promise<Array<SignedPreKeyType>> {
const keys = await channels.getAllSignedPreKeys();
return keys.map(key => specToBytes(PRE_KEY_SPEC, key));
}
async function bulkAddSignedPreKeys(
array: Array<SignedPreKeyType>
): Promise<void> {
const updated: Array<StoredSignedPreKeyType> = map(array, data =>
specFromBytes(PRE_KEY_SPEC, data)
);
await channels.bulkAddSignedPreKeys(updated);
}
// Items
const ITEM_SPECS: Partial<Record<ItemKeyType, ObjectMappingSpecType>> = {
identityKeyMap: {
key: 'value',
valueSpec: {
isMap: true,
valueSpec: ['privKey', 'pubKey'],
},
},
profileKey: ['value'],
senderCertificate: ['value.serialized'],
senderCertificateNoE164: ['value.serialized'],
subscriberId: ['value'],
};
async function createOrUpdateItem<K extends ItemKeyType>(
data: ItemType<K>
): Promise<void> {
const { id } = data;
if (!id) {
throw new Error(
'createOrUpdateItem: Provided data did not have a truthy id'
);
}
const spec = ITEM_SPECS[id];
const updated: StoredItemType<K> = spec
? specFromBytes(spec, data)
: (data as unknown as StoredItemType<K>);
await channels.createOrUpdateItem(updated);
}
async function getItemById<K extends ItemKeyType>(
id: K
): Promise<ItemType<K> | undefined> {
const spec = ITEM_SPECS[id];
const data = await channels.getItemById(id);
return spec ? specToBytes(spec, data) : (data as unknown as ItemType<K>);
}
async function getAllItems(): Promise<AllItemsType> {
const items = await channels.getAllItems();
const result = Object.create(null);
for (const id of Object.keys(items)) {
const key = id as ItemKeyType;
const value = items[key];
const keys = ITEM_SPECS[key];
const deserializedValue = keys
? (specToBytes(keys, { value }) as ItemType<typeof key>).value
: value;
result[key] = deserializedValue;
}
return result;
}
// Conversation
const updateConversationBatcher = createBatcher<ConversationType>({
name: 'sql.Client.updateConversationBatcher',
wait: 500,
maxSize: 20,
processBatch: async (items: Array<ConversationType>) => {
// We only care about the most recent update for each conversation
const byId = groupBy(items, item => item.id);
const ids = Object.keys(byId);
const mostRecent = ids.map((id: string): ConversationType => {
const maybeLast = last(byId[id]);
assertDev(maybeLast !== undefined, 'Empty array in `groupBy` result');
return maybeLast;
});
await updateConversations(mostRecent);
},
});
function updateConversation(data: ConversationType): void {
updateConversationBatcher.add(data);
}
async function updateConversations(
array: Array<ConversationType>
): Promise<void> {
const { cleaned, pathsChanged } = cleanDataForIpc(array);
assertDev(
!pathsChanged.length,
`Paths were cleaned: ${JSON.stringify(pathsChanged)}`
);
await channels.updateConversations(cleaned);
}
async function removeConversation(id: string): Promise<void> {
const existing = await channels.getConversationById(id);
// Note: It's important to have a fully database-hydrated model to delete here because
// it needs to delete all associated on-disk files along with the database delete.
if (existing) {
await channels.removeConversation(id);
await deleteExternalFiles(existing, {
deleteAttachmentData: window.Signal.Migrations.deleteAttachmentData,
});
}
}
function handleSearchMessageJSON(
messages: Array<ServerSearchResultMessageType>
): Array<ClientSearchResultMessageType> {
return messages.map(message => ({
json: message.json,
// Empty array is a default value. `message.json` has the real field
bodyRanges: [],
...JSON.parse(message.json),
snippet: message.snippet,
}));
}
async function searchMessages(
query: string,
{ limit }: { limit?: number } = {}
): Promise<Array<ClientSearchResultMessageType>> {
const messages = await channels.searchMessages(query, { limit });
return handleSearchMessageJSON(messages);
}
async function searchMessagesInConversation(
query: string,
conversationId: string,
{ limit }: { limit?: number } = {}
): Promise<Array<ClientSearchResultMessageType>> {
const messages = await channels.searchMessagesInConversation(
query,
conversationId,
{ limit }
);
return handleSearchMessageJSON(messages);
}
// Message
async function saveMessage(
data: MessageType,
options: {
jobToInsert?: Readonly<StoredJob>;
forceSave?: boolean;
ourUuid: UUIDStringType;
}
): Promise<string> {
const id = await channels.saveMessage(_cleanMessageData(data), {
...options,
jobToInsert: options.jobToInsert && formatJobForInsert(options.jobToInsert),
});
softAssert(isValidUuid(id), 'saveMessage: messageId is not a UUID');
void expiringMessagesDeletionService.update();
void tapToViewMessagesDeletionService.update();
return id;
}
async function saveMessages(
arrayOfMessages: ReadonlyArray<MessageType>,
options: { forceSave?: boolean; ourUuid: UUIDStringType }
): Promise<void> {
await channels.saveMessages(
arrayOfMessages.map(message => _cleanMessageData(message)),
options
);
void expiringMessagesDeletionService.update();
void tapToViewMessagesDeletionService.update();
}
async function removeMessage(id: string): Promise<void> {
const message = await channels.getMessageById(id);
// Note: It's important to have a fully database-hydrated model to delete here because
// it needs to delete all associated on-disk files along with the database delete.
if (message) {
await channels.removeMessage(id);
await cleanupMessage(message);
}
}
function handleMessageJSON(
messages: Array<MessageTypeUnhydrated>
): Array<MessageType> {
return messages.map(message => JSON.parse(message.json));
}
async function getNewerMessagesByConversation(
conversationId: string,
{
includeStoryReplies,
limit = 100,
receivedAt = 0,
sentAt = 0,
storyId,
}: {
includeStoryReplies: boolean;
limit?: number;
receivedAt?: number;
sentAt?: number;
storyId: UUIDStringType | undefined;
}
): Promise<Array<MessageType>> {
const messages = await channels.getNewerMessagesByConversation(
conversationId,
{
includeStoryReplies,
limit,
receivedAt,
sentAt,
storyId,
}
);
return handleMessageJSON(messages);
}
async function getOlderMessagesByConversation(
conversationId: string,
{
includeStoryReplies,
limit = 100,
messageId,
receivedAt = Number.MAX_VALUE,
sentAt = Number.MAX_VALUE,
storyId,
}: {
includeStoryReplies: boolean;
limit?: number;
messageId?: string;
receivedAt?: number;
sentAt?: number;
storyId: string | undefined;
}
): Promise<Array<MessageType>> {
const messages = await channels.getOlderMessagesByConversation(
conversationId,
{
includeStoryReplies,
limit,
receivedAt,
sentAt,
messageId,
storyId,
}
);
return handleMessageJSON(messages);
}
async function getConversationRangeCenteredOnMessage(options: {
conversationId: string;
includeStoryReplies: boolean;
limit?: number;
messageId: string;
receivedAt: number;
sentAt?: number;
storyId: UUIDStringType | undefined;
}): Promise<GetConversationRangeCenteredOnMessageResultType<MessageType>> {
const result = await channels.getConversationRangeCenteredOnMessage(options);
return {
...result,
older: handleMessageJSON(result.older),
newer: handleMessageJSON(result.newer),
};
}
async function removeAllMessagesInConversation(
conversationId: string,
{
logId,
}: {
logId: string;
}
): Promise<void> {
let messages;
do {
const chunkSize = 20;
log.info(
`removeAllMessagesInConversation/${logId}: Fetching chunk of ${chunkSize} messages`
);
// Yes, we really want the await in the loop. We're deleting a chunk at a
// time so we don't use too much memory.
// eslint-disable-next-line no-await-in-loop
messages = await getOlderMessagesByConversation(conversationId, {
limit: chunkSize,
includeStoryReplies: true,
storyId: undefined,
});
if (!messages.length) {
return;
}
const ids = messages.map(message => message.id);
log.info(`removeAllMessagesInConversation/${logId}: Cleanup...`);
// Note: It's very important that these models are fully hydrated because
// we need to delete all associated on-disk files along with the database delete.
const queue = new PQueue({ concurrency: 3, timeout: MINUTE * 30 });
drop(
queue.addAll(
messages.map(
(message: MessageType) => async () => cleanupMessage(message)
)
)
);
// eslint-disable-next-line no-await-in-loop
await queue.onIdle();
log.info(`removeAllMessagesInConversation/${logId}: Deleting...`);
// eslint-disable-next-line no-await-in-loop
await channels.removeMessages(ids);
} while (messages.length > 0);
}
// Attachment downloads
async function saveAttachmentDownloadJob(
job: AttachmentDownloadJobType
): Promise<void> {
await channels.saveAttachmentDownloadJob(_cleanData(job));
}
// Other
async function cleanupOrphanedAttachments(): Promise<void> {
try {
await invokeWithTimeout(CLEANUP_ORPHANED_ATTACHMENTS_KEY);
} catch (error) {
log.warn(
'sql/Client: cleanupOrphanedAttachments failure',
Errors.toLogFormat(error)
);
}
}
async function ensureFilePermissions(): Promise<void> {
await invokeWithTimeout(ENSURE_FILE_PERMISSIONS);
}
// Note: will need to restart the app after calling this, to set up afresh
async function removeOtherData(): Promise<void> {
await Promise.all([
invokeWithTimeout(ERASE_SQL_KEY),
invokeWithTimeout(ERASE_ATTACHMENTS_KEY),
invokeWithTimeout(ERASE_STICKERS_KEY),
invokeWithTimeout(ERASE_TEMP_KEY),
invokeWithTimeout(ERASE_DRAFTS_KEY),
]);
}
async function invokeWithTimeout(name: string): Promise<void> {
return createTaskWithTimeout(
() => ipc.invoke(name),
`callChannel call to ${name}`
)();
}