Add an assertion when updating conversations; update cleanData
This commit is contained in:
parent
73a304faba
commit
bc37b5c907
23 changed files with 749 additions and 79 deletions
|
@ -6,12 +6,18 @@
|
|||
const { ipcRenderer } = require('electron');
|
||||
const url = require('url');
|
||||
const i18n = require('./js/modules/i18n');
|
||||
const {
|
||||
getEnvironment,
|
||||
setEnvironment,
|
||||
parseEnvironment,
|
||||
} = require('./ts/environment');
|
||||
|
||||
const config = url.parse(window.location.toString(), true).query;
|
||||
const { locale } = config;
|
||||
const localeMessages = ipcRenderer.sendSync('locale-data');
|
||||
setEnvironment(parseEnvironment(config.environment));
|
||||
|
||||
window.getEnvironment = () => config.environment;
|
||||
window.getEnvironment = getEnvironment;
|
||||
window.getVersion = () => config.version;
|
||||
window.getAppInstance = () => config.appInstance;
|
||||
|
||||
|
|
|
@ -3,21 +3,25 @@
|
|||
|
||||
const path = require('path');
|
||||
const { app } = require('electron');
|
||||
|
||||
let environment;
|
||||
const {
|
||||
Environment,
|
||||
getEnvironment,
|
||||
setEnvironment,
|
||||
parseEnvironment,
|
||||
} = require('../ts/environment');
|
||||
|
||||
// In production mode, NODE_ENV cannot be customized by the user
|
||||
if (!app.isPackaged) {
|
||||
environment = process.env.NODE_ENV || 'development';
|
||||
if (app.isPackaged) {
|
||||
setEnvironment(Environment.Production);
|
||||
} else {
|
||||
environment = 'production';
|
||||
setEnvironment(parseEnvironment(process.env.NODE_ENV || 'development'));
|
||||
}
|
||||
|
||||
// Set environment vars to configure node-config before requiring it
|
||||
process.env.NODE_ENV = environment;
|
||||
process.env.NODE_ENV = getEnvironment();
|
||||
process.env.NODE_CONFIG_DIR = path.join(__dirname, '..', 'config');
|
||||
|
||||
if (environment === 'production') {
|
||||
if (getEnvironment() === Environment.Production) {
|
||||
// harden production config against the local env
|
||||
process.env.NODE_CONFIG = '';
|
||||
process.env.NODE_CONFIG_STRICT_MODE = true;
|
||||
|
@ -30,9 +34,10 @@ if (environment === 'production') {
|
|||
}
|
||||
|
||||
// We load config after we've made our modifications to NODE_ENV
|
||||
// eslint-disable-next-line import/order
|
||||
const config = require('config');
|
||||
|
||||
config.environment = environment;
|
||||
config.environment = getEnvironment();
|
||||
config.enableHttp = process.env.SIGNAL_ENABLE_HTTP;
|
||||
|
||||
// Log resulting env vars in use by config
|
||||
|
|
|
@ -7,10 +7,16 @@ const { ipcRenderer } = require('electron');
|
|||
const url = require('url');
|
||||
const copyText = require('copy-text-to-clipboard');
|
||||
const i18n = require('./js/modules/i18n');
|
||||
const {
|
||||
getEnvironment,
|
||||
setEnvironment,
|
||||
parseEnvironment,
|
||||
} = require('./ts/environment');
|
||||
|
||||
const config = url.parse(window.location.toString(), true).query;
|
||||
const { locale } = config;
|
||||
const localeMessages = ipcRenderer.sendSync('locale-data');
|
||||
setEnvironment(parseEnvironment(config.environment));
|
||||
|
||||
window.getVersion = () => config.version;
|
||||
window.theme = config.theme;
|
||||
|
@ -21,7 +27,7 @@ window.copyText = copyText;
|
|||
window.nodeSetImmediate = setImmediate;
|
||||
|
||||
window.getNodeVersion = () => config.node_version;
|
||||
window.getEnvironment = () => config.environment;
|
||||
window.getEnvironment = getEnvironment;
|
||||
|
||||
require('./ts/logging/set_up_renderer_logging');
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@ const rimraf = require('rimraf');
|
|||
const electronRemote = require('electron').remote;
|
||||
|
||||
const crypto = require('../../ts/Crypto');
|
||||
const { getEnvironment } = require('../../ts/environment');
|
||||
|
||||
const { dialog, BrowserWindow } = electronRemote;
|
||||
|
||||
|
@ -1198,7 +1199,7 @@ function deleteAll(pattern) {
|
|||
const ARCHIVE_NAME = 'messages.tar.gz';
|
||||
|
||||
async function exportToDirectory(directory, options) {
|
||||
const env = window.getEnvironment();
|
||||
const env = getEnvironment();
|
||||
if (env !== 'test') {
|
||||
throw new Error('export is only supported in test mode');
|
||||
}
|
||||
|
@ -1266,7 +1267,7 @@ async function importFromDirectory(directory, options) {
|
|||
|
||||
const archivePath = path.join(directory, ARCHIVE_NAME);
|
||||
if (fs.existsSync(archivePath)) {
|
||||
const env = window.getEnvironment();
|
||||
const env = getEnvironment();
|
||||
if (env !== 'test') {
|
||||
throw new Error('import is only supported in test mode');
|
||||
}
|
||||
|
|
|
@ -29,7 +29,7 @@
|
|||
"publish-to-apt": "NAME=$npm_package_name VERSION=$npm_package_version ./aptly.sh",
|
||||
"test": "yarn test-node && yarn test-electron",
|
||||
"test-electron": "yarn grunt test",
|
||||
"test-node": "electron-mocha --require test/setup-test-node.js --recursive test/app test/modules ts/test-node ts/test-both",
|
||||
"test-node": "electron-mocha --file test/setup-test-node.js --recursive test/app test/modules ts/test-node ts/test-both",
|
||||
"test-node-coverage": "nyc --reporter=lcov --reporter=text mocha --recursive test/app test/modules ts/test-node ts/test-both",
|
||||
"eslint": "eslint .",
|
||||
"lint": "yarn format --list-different && yarn eslint",
|
||||
|
|
|
@ -11,14 +11,20 @@ const url = require('url');
|
|||
const i18n = require('./js/modules/i18n');
|
||||
const { ConfirmationModal } = require('./ts/components/ConfirmationModal');
|
||||
const { makeGetter, makeSetter } = require('./preload_utils');
|
||||
const {
|
||||
getEnvironment,
|
||||
setEnvironment,
|
||||
parseEnvironment,
|
||||
} = require('./ts/environment');
|
||||
|
||||
const { nativeTheme } = remote.require('electron');
|
||||
|
||||
const config = url.parse(window.location.toString(), true).query;
|
||||
const { locale } = config;
|
||||
const localeMessages = ipcRenderer.sendSync('locale-data');
|
||||
setEnvironment(parseEnvironment(config.environment));
|
||||
|
||||
window.getEnvironment = () => config.environment;
|
||||
window.getEnvironment = getEnvironment;
|
||||
window.getVersion = () => config.version;
|
||||
window.theme = config.theme;
|
||||
window.i18n = i18n.setup(locale, localeMessages);
|
||||
|
|
14
preload.js
14
preload.js
|
@ -11,6 +11,12 @@ try {
|
|||
const client = require('libsignal-client');
|
||||
const _ = require('lodash');
|
||||
const { installGetter, installSetter } = require('./preload_utils');
|
||||
const {
|
||||
getEnvironment,
|
||||
setEnvironment,
|
||||
parseEnvironment,
|
||||
Environment,
|
||||
} = require('./ts/environment');
|
||||
|
||||
const { remote } = electron;
|
||||
const { app } = remote;
|
||||
|
@ -19,9 +25,11 @@ try {
|
|||
window.PROTO_ROOT = 'protos';
|
||||
const config = require('url').parse(window.location.toString(), true).query;
|
||||
|
||||
setEnvironment(parseEnvironment(config.environment));
|
||||
|
||||
let title = config.name;
|
||||
if (config.environment !== 'production') {
|
||||
title += ` - ${config.environment}`;
|
||||
if (getEnvironment() !== Environment.Production) {
|
||||
title += ` - ${getEnvironment()}`;
|
||||
}
|
||||
if (config.appInstance) {
|
||||
title += ` - ${config.appInstance}`;
|
||||
|
@ -37,7 +45,7 @@ try {
|
|||
|
||||
window.platform = process.platform;
|
||||
window.getTitle = () => title;
|
||||
window.getEnvironment = () => config.environment;
|
||||
window.getEnvironment = getEnvironment;
|
||||
window.getAppInstance = () => config.appInstance;
|
||||
window.getVersion = () => config.version;
|
||||
window.getExpiration = () => {
|
||||
|
|
|
@ -7,10 +7,16 @@ const { ipcRenderer, remote } = require('electron');
|
|||
|
||||
const url = require('url');
|
||||
const i18n = require('./js/modules/i18n');
|
||||
const {
|
||||
getEnvironment,
|
||||
setEnvironment,
|
||||
parseEnvironment,
|
||||
} = require('./ts/environment');
|
||||
|
||||
const config = url.parse(window.location.toString(), true).query;
|
||||
const { locale } = config;
|
||||
const localeMessages = ipcRenderer.sendSync('locale-data');
|
||||
setEnvironment(parseEnvironment(config.environment));
|
||||
|
||||
const { nativeTheme } = remote.require('electron');
|
||||
|
||||
|
@ -33,7 +39,7 @@ window.subscribeToSystemThemeChange = fn => {
|
|||
});
|
||||
};
|
||||
|
||||
window.getEnvironment = () => config.environment;
|
||||
window.getEnvironment = getEnvironment;
|
||||
window.getVersion = () => config.version;
|
||||
window.getAppInstance = () => config.appInstance;
|
||||
|
||||
|
|
|
@ -10,6 +10,11 @@ const config = require('url').parse(window.location.toString(), true).query;
|
|||
const { noop, uniqBy } = require('lodash');
|
||||
const pMap = require('p-map');
|
||||
const { deriveStickerPackKey } = require('../ts/Crypto');
|
||||
const {
|
||||
getEnvironment,
|
||||
setEnvironment,
|
||||
parseEnvironment,
|
||||
} = require('../ts/environment');
|
||||
const { makeGetter } = require('../preload_utils');
|
||||
|
||||
const { dialog } = remote;
|
||||
|
@ -21,9 +26,11 @@ const MAX_STICKER_DIMENSION = STICKER_SIZE;
|
|||
const MAX_WEBP_STICKER_BYTE_LENGTH = 100 * 1024;
|
||||
const MAX_ANIMATED_STICKER_BYTE_LENGTH = 300 * 1024;
|
||||
|
||||
setEnvironment(parseEnvironment(config.environment));
|
||||
|
||||
window.ROOT_PATH = window.location.href.startsWith('file') ? '../../' : '/';
|
||||
window.PROTO_ROOT = '../../protos';
|
||||
window.getEnvironment = () => config.environment;
|
||||
window.getEnvironment = getEnvironment;
|
||||
window.getVersion = () => config.version;
|
||||
window.getGuid = require('uuid/v4');
|
||||
window.PQueue = require('p-queue').default;
|
||||
|
|
|
@ -3,6 +3,12 @@
|
|||
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const { setEnvironment, Environment } = require('../ts/environment');
|
||||
|
||||
before(() => {
|
||||
setEnvironment(Environment.Test);
|
||||
});
|
||||
|
||||
// To replicate logic we have on the client side
|
||||
global.window = {
|
||||
log: {
|
||||
|
|
49
ts/environment.ts
Normal file
49
ts/environment.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
// Many places rely on this enum being a string.
|
||||
export enum Environment {
|
||||
Development = 'development',
|
||||
Production = 'production',
|
||||
Staging = 'staging',
|
||||
Test = 'test',
|
||||
TestLib = 'test-lib',
|
||||
}
|
||||
|
||||
let environment: undefined | Environment;
|
||||
|
||||
export function getEnvironment(): Environment {
|
||||
if (environment === undefined) {
|
||||
// This should never happen—we should always have initialized the environment by this
|
||||
// point. It'd be nice to log here but the logger depends on the environment and we
|
||||
// can't have circular dependencies.
|
||||
return Environment.Production;
|
||||
}
|
||||
return environment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current environment. Should be called early in a process's life, and can only
|
||||
* be called once.
|
||||
*/
|
||||
export function setEnvironment(env: Environment): void {
|
||||
if (environment !== undefined) {
|
||||
throw new Error('Environment has already been set');
|
||||
}
|
||||
environment = env;
|
||||
}
|
||||
|
||||
const ENVIRONMENTS_BY_STRING = new Map<string, Environment>([
|
||||
['development', Environment.Development],
|
||||
['production', Environment.Production],
|
||||
['staging', Environment.Staging],
|
||||
['test', Environment.Test],
|
||||
['test-lib', Environment.TestLib],
|
||||
]);
|
||||
export function parseEnvironment(value: unknown): Environment {
|
||||
if (typeof value !== 'string') {
|
||||
return Environment.Production;
|
||||
}
|
||||
const result = ENVIRONMENTS_BY_STRING.get(value);
|
||||
return result || Environment.Production;
|
||||
}
|
33
ts/logging/log.ts
Normal file
33
ts/logging/log.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { noop } from 'lodash';
|
||||
import { LogLevel } from './shared';
|
||||
|
||||
type LogAtLevelFnType = (
|
||||
level: LogLevel,
|
||||
...args: ReadonlyArray<unknown>
|
||||
) => void;
|
||||
|
||||
let logAtLevel: LogAtLevelFnType = noop;
|
||||
let hasInitialized = false;
|
||||
|
||||
type LogFn = (...args: ReadonlyArray<unknown>) => void;
|
||||
export const fatal: LogFn = (...args) => logAtLevel(LogLevel.Fatal, ...args);
|
||||
export const error: LogFn = (...args) => logAtLevel(LogLevel.Error, ...args);
|
||||
export const warn: LogFn = (...args) => logAtLevel(LogLevel.Warn, ...args);
|
||||
export const info: LogFn = (...args) => logAtLevel(LogLevel.Info, ...args);
|
||||
export const debug: LogFn = (...args) => logAtLevel(LogLevel.Debug, ...args);
|
||||
export const trace: LogFn = (...args) => logAtLevel(LogLevel.Trace, ...args);
|
||||
|
||||
/**
|
||||
* Sets the low-level logging interface. Should be called early in a process's life, and
|
||||
* can only be called once.
|
||||
*/
|
||||
export function setLogAtLevel(log: LogAtLevelFnType): void {
|
||||
if (hasInitialized) {
|
||||
throw new Error('Logger has already been initialized');
|
||||
}
|
||||
logAtLevel = log;
|
||||
hasInitialized = true;
|
||||
}
|
|
@ -19,6 +19,7 @@ import {
|
|||
getLogLevelString,
|
||||
isLogEntry,
|
||||
} from './shared';
|
||||
import * as log from './log';
|
||||
import { reallyJsonStringify } from '../util/reallyJsonStringify';
|
||||
|
||||
// To make it easier to visually scan logs, we make all levels the same length
|
||||
|
@ -33,13 +34,13 @@ function now() {
|
|||
return date.toJSON();
|
||||
}
|
||||
|
||||
function log(...args: ReadonlyArray<unknown>) {
|
||||
function consoleLog(...args: ReadonlyArray<unknown>) {
|
||||
logAtLevel(LogLevel.Info, ...args);
|
||||
}
|
||||
|
||||
if (window.console) {
|
||||
console._log = console.log;
|
||||
console.log = log;
|
||||
console.log = consoleLog;
|
||||
}
|
||||
|
||||
// The mechanics of preparing a log for publish
|
||||
|
@ -126,13 +127,15 @@ function logAtLevel(level: LogLevel, ...args: ReadonlyArray<unknown>): void {
|
|||
});
|
||||
}
|
||||
|
||||
log.setLogAtLevel(logAtLevel);
|
||||
|
||||
window.log = {
|
||||
fatal: _.partial(logAtLevel, LogLevel.Fatal),
|
||||
error: _.partial(logAtLevel, LogLevel.Error),
|
||||
warn: _.partial(logAtLevel, LogLevel.Warn),
|
||||
info: _.partial(logAtLevel, LogLevel.Info),
|
||||
debug: _.partial(logAtLevel, LogLevel.Debug),
|
||||
trace: _.partial(logAtLevel, LogLevel.Trace),
|
||||
fatal: log.fatal,
|
||||
error: log.error,
|
||||
warn: log.warn,
|
||||
info: log.info,
|
||||
debug: log.debug,
|
||||
trace: log.trace,
|
||||
fetch,
|
||||
publish,
|
||||
};
|
||||
|
|
|
@ -16,15 +16,17 @@ import {
|
|||
get,
|
||||
groupBy,
|
||||
isFunction,
|
||||
isObject,
|
||||
last,
|
||||
map,
|
||||
omit,
|
||||
set,
|
||||
} from 'lodash';
|
||||
|
||||
import { arrayBufferToBase64, base64ToArrayBuffer } from '../Crypto';
|
||||
import { CURRENT_SCHEMA_VERSION } from '../../js/modules/types/message';
|
||||
import { createBatcher } from '../util/batcher';
|
||||
import { assert } from '../util/assert';
|
||||
import { cleanDataForIpc } from './cleanDataForIpc';
|
||||
|
||||
import {
|
||||
ConversationModelCollectionType,
|
||||
|
@ -231,7 +233,6 @@ const dataInterface: ClientInterface = {
|
|||
// Client-side only, and test-only
|
||||
|
||||
_removeConversations,
|
||||
_cleanData,
|
||||
_jobs,
|
||||
};
|
||||
|
||||
|
@ -251,55 +252,22 @@ const channelsAsUnknown = fromPairs(
|
|||
|
||||
const channels: ServerInterface = channelsAsUnknown;
|
||||
|
||||
// When IPC arguments are prepared for the cross-process send, they are serialized with
|
||||
// the [structured clone algorithm][0]. We can't send some values, like BigNumbers and
|
||||
// functions (both of which come from protobufjs), so we clean them up.
|
||||
// [0]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
|
||||
function _cleanData(data: any, path = 'root') {
|
||||
if (data === null || data === undefined) {
|
||||
window.log.warn(`_cleanData: null or undefined value at path ${path}`);
|
||||
function _cleanData(
|
||||
data: unknown
|
||||
): ReturnType<typeof cleanDataForIpc>['cleaned'] {
|
||||
const { cleaned, pathsChanged } = cleanDataForIpc(data);
|
||||
|
||||
return data;
|
||||
if (pathsChanged.length) {
|
||||
window.log.info(
|
||||
`_cleanData cleaned the following paths: ${pathsChanged.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof data === 'string' ||
|
||||
typeof data === 'number' ||
|
||||
typeof data === 'boolean'
|
||||
) {
|
||||
return data;
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
const keys = Object.keys(data);
|
||||
const max = keys.length;
|
||||
for (let index = 0; index < max; index += 1) {
|
||||
const key = keys[index];
|
||||
const value = data[key];
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isFunction(value)) {
|
||||
delete data[key];
|
||||
} else if (isFunction(value.toNumber)) {
|
||||
data[key] = value.toNumber();
|
||||
} else if (Array.isArray(value)) {
|
||||
data[key] = value.map((item, mapIndex) =>
|
||||
_cleanData(item, `${path}.${key}.${mapIndex}`)
|
||||
);
|
||||
} else if (isObject(value)) {
|
||||
data[key] = _cleanData(value, `${path}.${key}`);
|
||||
} else if (
|
||||
typeof value !== 'string' &&
|
||||
typeof value !== 'number' &&
|
||||
typeof value !== 'boolean'
|
||||
) {
|
||||
window.log.info(`_cleanData: key ${key} had type ${typeof value}`);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
function _cleanMessageData(data: MessageType): MessageType {
|
||||
return _cleanData(omit(data, ['dataMessage']));
|
||||
}
|
||||
|
||||
async function _shutdown() {
|
||||
|
@ -764,7 +732,12 @@ function updateConversation(data: ConversationType) {
|
|||
}
|
||||
|
||||
async function updateConversations(array: Array<ConversationType>) {
|
||||
await channels.updateConversations(array);
|
||||
const { cleaned, pathsChanged } = cleanDataForIpc(array);
|
||||
assert(
|
||||
!pathsChanged.length,
|
||||
`Paths were cleaned: ${JSON.stringify(pathsChanged)}`
|
||||
);
|
||||
await channels.updateConversations(cleaned);
|
||||
}
|
||||
|
||||
async function removeConversation(
|
||||
|
@ -884,7 +857,9 @@ async function saveMessage(
|
|||
data: MessageType,
|
||||
{ forceSave, Message }: { forceSave?: boolean; Message: typeof MessageModel }
|
||||
) {
|
||||
const id = await channels.saveMessage(_cleanData(data), { forceSave });
|
||||
const id = await channels.saveMessage(_cleanMessageData(data), {
|
||||
forceSave,
|
||||
});
|
||||
Message.updateTimers();
|
||||
|
||||
return id;
|
||||
|
@ -894,7 +869,10 @@ async function saveMessages(
|
|||
arrayOfMessages: Array<MessageType>,
|
||||
{ forceSave }: { forceSave?: boolean } = {}
|
||||
) {
|
||||
await channels.saveMessages(_cleanData(arrayOfMessages), { forceSave });
|
||||
await channels.saveMessages(
|
||||
arrayOfMessages.map(message => _cleanMessageData(message)),
|
||||
{ forceSave }
|
||||
);
|
||||
}
|
||||
|
||||
async function removeMessage(
|
||||
|
|
|
@ -393,7 +393,6 @@ export type ClientInterface = DataInterface & {
|
|||
// Client-side only, and test-only
|
||||
|
||||
_removeConversations: (ids: Array<string>) => Promise<void>;
|
||||
_cleanData: (data: any, path?: string) => any;
|
||||
_jobs: { [id: string]: ClientJobType };
|
||||
};
|
||||
|
||||
|
|
163
ts/sql/cleanDataForIpc.ts
Normal file
163
ts/sql/cleanDataForIpc.ts
Normal file
|
@ -0,0 +1,163 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { isPlainObject } from 'lodash';
|
||||
|
||||
import { isIterable } from '../util/isIterable';
|
||||
|
||||
/**
|
||||
* IPC arguments are serialized with the [structured clone algorithm][0], but we can only
|
||||
* save some data types to disk.
|
||||
*
|
||||
* This cleans the data so it's roughly JSON-serializable, though it does not handle
|
||||
* every case. You can see the expected behavior in the tests. Notably, we try to convert
|
||||
* protobufjs numbers to JavaScript numbers, and we don't touch ArrayBuffers.
|
||||
*
|
||||
* [0]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
|
||||
*/
|
||||
export function cleanDataForIpc(
|
||||
data: unknown
|
||||
): {
|
||||
// `any`s are dangerous but it's difficult (impossible?) to type this with generics.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
cleaned: any;
|
||||
pathsChanged: Array<string>;
|
||||
} {
|
||||
const pathsChanged: Array<string> = [];
|
||||
const cleaned = cleanDataInner(data, 'root', pathsChanged);
|
||||
return { cleaned, pathsChanged };
|
||||
}
|
||||
|
||||
// These type definitions are lifted from [this GitHub comment][1].
|
||||
//
|
||||
// [1]: https://github.com/Microsoft/TypeScript/issues/3496#issuecomment-128553540
|
||||
type CleanedDataValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| undefined
|
||||
| CleanedObject
|
||||
| CleanedArray;
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
interface CleanedObject {
|
||||
[x: string]: CleanedDataValue;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
interface CleanedArray extends Array<CleanedDataValue> {}
|
||||
/* eslint-enable no-restricted-syntax */
|
||||
|
||||
function cleanDataInner(
|
||||
data: unknown,
|
||||
path: string,
|
||||
pathsChanged: Array<string>
|
||||
): CleanedDataValue {
|
||||
switch (typeof data) {
|
||||
case 'undefined':
|
||||
case 'boolean':
|
||||
case 'number':
|
||||
case 'string':
|
||||
return data;
|
||||
case 'bigint':
|
||||
pathsChanged.push(path);
|
||||
return data.toString();
|
||||
case 'function':
|
||||
// For backwards compatibility with previous versions of this function, we clean
|
||||
// functions but don't mark them as cleaned.
|
||||
return undefined;
|
||||
case 'object': {
|
||||
if (data === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
const result: CleanedArray = [];
|
||||
data.forEach((item, index) => {
|
||||
const indexPath = `${path}.${index}`;
|
||||
if (item === undefined || item === null) {
|
||||
pathsChanged.push(indexPath);
|
||||
} else {
|
||||
result.push(cleanDataInner(item, indexPath, pathsChanged));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
if (data instanceof Map) {
|
||||
const result: CleanedObject = {};
|
||||
pathsChanged.push(path);
|
||||
data.forEach((value, key) => {
|
||||
if (typeof key === 'string') {
|
||||
result[key] = cleanDataInner(
|
||||
value,
|
||||
`${path}.<map value at ${key}>`,
|
||||
pathsChanged
|
||||
);
|
||||
} else {
|
||||
pathsChanged.push(`${path}.<map key ${String(key)}>`);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
if (data instanceof Date) {
|
||||
pathsChanged.push(path);
|
||||
return Number.isNaN(data.valueOf()) ? undefined : data.toISOString();
|
||||
}
|
||||
|
||||
if (data instanceof ArrayBuffer) {
|
||||
pathsChanged.push(path);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const dataAsRecord = data as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
'toNumber' in dataAsRecord &&
|
||||
typeof dataAsRecord.toNumber === 'function'
|
||||
) {
|
||||
// We clean this just in case `toNumber` returns something bogus.
|
||||
return cleanDataInner(dataAsRecord.toNumber(), path, pathsChanged);
|
||||
}
|
||||
|
||||
if (isIterable(dataAsRecord)) {
|
||||
const result: CleanedArray = [];
|
||||
let index = 0;
|
||||
pathsChanged.push(path);
|
||||
// `for ... of` is the cleanest way to go through "generic" iterables without
|
||||
// a helper library.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const value of dataAsRecord) {
|
||||
result.push(
|
||||
cleanDataInner(
|
||||
value,
|
||||
`${path}.<iterator index ${index}>`,
|
||||
pathsChanged
|
||||
)
|
||||
);
|
||||
index += 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// We'll still try to clean non-plain objects, but we want to mark that they've
|
||||
// changed.
|
||||
if (!isPlainObject(data)) {
|
||||
pathsChanged.push(path);
|
||||
}
|
||||
|
||||
const result: CleanedObject = {};
|
||||
|
||||
// Conveniently, `Object.entries` removes symbol keys.
|
||||
Object.entries(dataAsRecord).forEach(([key, value]) => {
|
||||
result[key] = cleanDataInner(value, `${path}.${key}`, pathsChanged);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
default: {
|
||||
pathsChanged.push(path);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
41
ts/test-both/environment.ts
Normal file
41
ts/test-both/environment.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
|
||||
import { parseEnvironment, Environment } from '../environment';
|
||||
|
||||
describe('environment utilities', () => {
|
||||
describe('parseEnvironment', () => {
|
||||
it('returns Environment.Production for non-strings', () => {
|
||||
assert.equal(parseEnvironment(undefined), Environment.Production);
|
||||
assert.equal(parseEnvironment(0), Environment.Production);
|
||||
});
|
||||
|
||||
it('returns Environment.Production for invalid strings', () => {
|
||||
assert.equal(parseEnvironment(''), Environment.Production);
|
||||
assert.equal(parseEnvironment(' development '), Environment.Production);
|
||||
assert.equal(parseEnvironment('PRODUCTION'), Environment.Production);
|
||||
});
|
||||
|
||||
it('parses "development" as Environment.Development', () => {
|
||||
assert.equal(parseEnvironment('development'), Environment.Development);
|
||||
});
|
||||
|
||||
it('parses "production" as Environment.Production', () => {
|
||||
assert.equal(parseEnvironment('production'), Environment.Production);
|
||||
});
|
||||
|
||||
it('parses "staging" as Environment.Staging', () => {
|
||||
assert.equal(parseEnvironment('staging'), Environment.Staging);
|
||||
});
|
||||
|
||||
it('parses "test" as Environment.Test', () => {
|
||||
assert.equal(parseEnvironment('test'), Environment.Test);
|
||||
});
|
||||
|
||||
it('parses "test-lib" as Environment.TestLib', () => {
|
||||
assert.equal(parseEnvironment('test-lib'), Environment.TestLib);
|
||||
});
|
||||
});
|
||||
});
|
254
ts/test-both/sql/cleanDataForIpc_test.ts
Normal file
254
ts/test-both/sql/cleanDataForIpc_test.ts
Normal file
|
@ -0,0 +1,254 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { noop } from 'lodash';
|
||||
|
||||
import { cleanDataForIpc } from '../../sql/cleanDataForIpc';
|
||||
|
||||
describe('cleanDataForIpc', () => {
|
||||
it('does nothing to JSON primitives', () => {
|
||||
['', 'foo bar', 0, 123, true, false, null].forEach(value => {
|
||||
assert.deepEqual(cleanDataForIpc(value), {
|
||||
cleaned: value,
|
||||
pathsChanged: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('does nothing to undefined', () => {
|
||||
// Though `undefined` is not technically JSON-serializable, we don't clean it because
|
||||
// its key is dropped.
|
||||
assert.deepEqual(cleanDataForIpc(undefined), {
|
||||
cleaned: undefined,
|
||||
pathsChanged: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('converts BigInts to strings', () => {
|
||||
assert.deepEqual(cleanDataForIpc(BigInt(0)), {
|
||||
cleaned: '0',
|
||||
pathsChanged: ['root'],
|
||||
});
|
||||
assert.deepEqual(cleanDataForIpc(BigInt(123)), {
|
||||
cleaned: '123',
|
||||
pathsChanged: ['root'],
|
||||
});
|
||||
assert.deepEqual(cleanDataForIpc(BigInt(-123)), {
|
||||
cleaned: '-123',
|
||||
pathsChanged: ['root'],
|
||||
});
|
||||
});
|
||||
|
||||
it('converts functions to `undefined` but does not mark them as cleaned, for backwards compatibility', () => {
|
||||
assert.deepEqual(cleanDataForIpc(noop), {
|
||||
cleaned: undefined,
|
||||
pathsChanged: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('converts symbols to `undefined`', () => {
|
||||
assert.deepEqual(cleanDataForIpc(Symbol('test')), {
|
||||
cleaned: undefined,
|
||||
pathsChanged: ['root'],
|
||||
});
|
||||
});
|
||||
|
||||
it('converts ArrayBuffers to `undefined`', () => {
|
||||
assert.deepEqual(cleanDataForIpc(new ArrayBuffer(2)), {
|
||||
cleaned: undefined,
|
||||
pathsChanged: ['root'],
|
||||
});
|
||||
});
|
||||
|
||||
it('converts valid dates to ISO strings', () => {
|
||||
assert.deepEqual(cleanDataForIpc(new Date(924588548000)), {
|
||||
cleaned: '1999-04-20T06:09:08.000Z',
|
||||
pathsChanged: ['root'],
|
||||
});
|
||||
});
|
||||
|
||||
it('converts invalid dates to `undefined`', () => {
|
||||
assert.deepEqual(cleanDataForIpc(new Date(NaN)), {
|
||||
cleaned: undefined,
|
||||
pathsChanged: ['root'],
|
||||
});
|
||||
});
|
||||
|
||||
it('converts other iterables to arrays', () => {
|
||||
assert.deepEqual(cleanDataForIpc(new Uint8Array([1, 2, 3])), {
|
||||
cleaned: [1, 2, 3],
|
||||
pathsChanged: ['root'],
|
||||
});
|
||||
|
||||
assert.deepEqual(cleanDataForIpc(new Float32Array([1, 2, 3])), {
|
||||
cleaned: [1, 2, 3],
|
||||
pathsChanged: ['root'],
|
||||
});
|
||||
|
||||
function* generator() {
|
||||
yield 1;
|
||||
yield 2;
|
||||
}
|
||||
assert.deepEqual(cleanDataForIpc(generator()), {
|
||||
cleaned: [1, 2],
|
||||
pathsChanged: ['root'],
|
||||
});
|
||||
});
|
||||
|
||||
it('deeply cleans arrays, removing `undefined` and `null`s', () => {
|
||||
const result = cleanDataForIpc([
|
||||
12,
|
||||
Symbol('top level symbol'),
|
||||
{ foo: 3, symb: Symbol('nested symbol 1') },
|
||||
[45, Symbol('nested symbol 2')],
|
||||
undefined,
|
||||
null,
|
||||
]);
|
||||
|
||||
assert.deepEqual(result.cleaned, [
|
||||
12,
|
||||
undefined,
|
||||
{
|
||||
foo: 3,
|
||||
symb: undefined,
|
||||
},
|
||||
[45, undefined],
|
||||
]);
|
||||
assert.sameMembers(result.pathsChanged, [
|
||||
'root.1',
|
||||
'root.2.symb',
|
||||
'root.3.1',
|
||||
'root.4',
|
||||
'root.5',
|
||||
]);
|
||||
});
|
||||
|
||||
it('deeply cleans sets and converts them to arrays', () => {
|
||||
const result = cleanDataForIpc(
|
||||
new Set([
|
||||
12,
|
||||
Symbol('top level symbol'),
|
||||
{ foo: 3, symb: Symbol('nested symbol 1') },
|
||||
[45, Symbol('nested symbol 2')],
|
||||
])
|
||||
);
|
||||
|
||||
assert.isArray(result.cleaned);
|
||||
assert.sameDeepMembers(result.cleaned, [
|
||||
12,
|
||||
undefined,
|
||||
{
|
||||
foo: 3,
|
||||
symb: undefined,
|
||||
},
|
||||
[45, undefined],
|
||||
]);
|
||||
assert.sameMembers(result.pathsChanged, [
|
||||
'root',
|
||||
'root.<iterator index 1>',
|
||||
'root.<iterator index 2>.symb',
|
||||
'root.<iterator index 3>.1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('deeply cleans maps and converts them to objects', () => {
|
||||
const result = cleanDataForIpc(
|
||||
new Map<unknown, unknown>([
|
||||
['key 1', 'value'],
|
||||
[Symbol('symbol key'), 'dropped'],
|
||||
['key 2', ['foo', Symbol('nested symbol')]],
|
||||
[3, 'dropped'],
|
||||
[BigInt(4), 'dropped'],
|
||||
])
|
||||
);
|
||||
|
||||
assert.deepEqual(result.cleaned, {
|
||||
'key 1': 'value',
|
||||
'key 2': ['foo', undefined],
|
||||
});
|
||||
assert.sameMembers(result.pathsChanged, [
|
||||
'root',
|
||||
'root.<map key Symbol(symbol key)>',
|
||||
'root.<map value at key 2>.1',
|
||||
'root.<map key 3>',
|
||||
'root.<map key 4>',
|
||||
]);
|
||||
});
|
||||
|
||||
it('calls `toNumber` when available', () => {
|
||||
assert.deepEqual(
|
||||
cleanDataForIpc([
|
||||
{
|
||||
toNumber() {
|
||||
return 5;
|
||||
},
|
||||
},
|
||||
{
|
||||
toNumber() {
|
||||
return Symbol('bogus');
|
||||
},
|
||||
},
|
||||
]),
|
||||
{
|
||||
cleaned: [5, undefined],
|
||||
pathsChanged: ['root.1'],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('deeply cleans objects with a `null` prototype', () => {
|
||||
const value = Object.assign(Object.create(null), {
|
||||
'key 1': 'value',
|
||||
[Symbol('symbol key')]: 'dropped',
|
||||
'key 2': ['foo', Symbol('nested symbol')],
|
||||
});
|
||||
const result = cleanDataForIpc(value);
|
||||
|
||||
assert.deepEqual(result.cleaned, {
|
||||
'key 1': 'value',
|
||||
'key 2': ['foo', undefined],
|
||||
});
|
||||
assert.sameMembers(result.pathsChanged, ['root.key 2.1']);
|
||||
});
|
||||
|
||||
it('deeply cleans objects with a prototype of `Object.prototype`', () => {
|
||||
const value = {
|
||||
'key 1': 'value',
|
||||
[Symbol('symbol key')]: 'dropped',
|
||||
'key 2': ['foo', Symbol('nested symbol')],
|
||||
};
|
||||
const result = cleanDataForIpc(value);
|
||||
|
||||
assert.deepEqual(result.cleaned, {
|
||||
'key 1': 'value',
|
||||
'key 2': ['foo', undefined],
|
||||
});
|
||||
assert.sameMembers(result.pathsChanged, ['root.key 2.1']);
|
||||
});
|
||||
|
||||
it('deeply cleans class instances', () => {
|
||||
class Person {
|
||||
public toBeDiscarded = Symbol('to be discarded');
|
||||
|
||||
constructor(public firstName: string, public lastName: string) {}
|
||||
|
||||
get name() {
|
||||
return this.getName();
|
||||
}
|
||||
|
||||
getName() {
|
||||
return `${this.firstName} ${this.lastName}`;
|
||||
}
|
||||
}
|
||||
const person = new Person('Selena', 'Gomez');
|
||||
const result = cleanDataForIpc(person);
|
||||
|
||||
assert.deepEqual(result.cleaned, {
|
||||
firstName: 'Selena',
|
||||
lastName: 'Gomez',
|
||||
toBeDiscarded: undefined,
|
||||
});
|
||||
assert.sameMembers(result.pathsChanged, ['root', 'root.toBeDiscarded']);
|
||||
});
|
||||
});
|
18
ts/test-both/util/assert_test.ts
Normal file
18
ts/test-both/util/assert_test.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as chai from 'chai';
|
||||
|
||||
import { assert } from '../../util/assert';
|
||||
|
||||
describe('assert', () => {
|
||||
it('does nothing if the assertion passes', () => {
|
||||
assert(true, 'foo bar');
|
||||
});
|
||||
|
||||
it("throws because we're in a test environment", () => {
|
||||
chai.assert.throws(() => {
|
||||
assert(false, 'foo bar');
|
||||
}, 'foo bar');
|
||||
});
|
||||
});
|
50
ts/test-both/util/isIterable_test.ts
Normal file
50
ts/test-both/util/isIterable_test.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
|
||||
import { isIterable } from '../../util/isIterable';
|
||||
|
||||
describe('isIterable', () => {
|
||||
it('returns false for non-iterables', () => {
|
||||
assert.isFalse(isIterable(undefined));
|
||||
assert.isFalse(isIterable(null));
|
||||
assert.isFalse(isIterable(123));
|
||||
assert.isFalse(isIterable({ foo: 'bar' }));
|
||||
assert.isFalse(
|
||||
isIterable({
|
||||
length: 2,
|
||||
'0': 'fake',
|
||||
'1': 'array',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns true for iterables', () => {
|
||||
assert.isTrue(isIterable('strings are iterable'));
|
||||
assert.isTrue(isIterable(['arrays too']));
|
||||
assert.isTrue(isIterable(new Set('and sets')));
|
||||
assert.isTrue(isIterable(new Map([['and', 'maps']])));
|
||||
assert.isTrue(
|
||||
isIterable({
|
||||
[Symbol.iterator]() {
|
||||
return {
|
||||
next() {
|
||||
return {
|
||||
value: 'endless iterable',
|
||||
done: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
})
|
||||
);
|
||||
assert.isTrue(
|
||||
isIterable(
|
||||
(function* generators() {
|
||||
yield 123;
|
||||
})()
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
21
ts/util/assert.ts
Normal file
21
ts/util/assert.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { getEnvironment, Environment } from '../environment';
|
||||
import * as log from '../logging/log';
|
||||
|
||||
/**
|
||||
* In production, logs an error and continues. In all other environments, throws an error.
|
||||
*/
|
||||
export function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
const err = new Error(message);
|
||||
if (getEnvironment() !== Environment.Production) {
|
||||
if (getEnvironment() === Environment.Development) {
|
||||
debugger; // eslint-disable-line no-debugger
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
log.error(err);
|
||||
}
|
||||
}
|
9
ts/util/isIterable.ts
Normal file
9
ts/util/isIterable.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
export function isIterable(value: unknown): value is Iterable<unknown> {
|
||||
return (
|
||||
(typeof value === 'object' && value !== null && Symbol.iterator in value) ||
|
||||
typeof value === 'string'
|
||||
);
|
||||
}
|
3
ts/window.d.ts
vendored
3
ts/window.d.ts
vendored
|
@ -30,6 +30,7 @@ import * as Groups from './groups';
|
|||
import * as Crypto from './Crypto';
|
||||
import * as RemoteConfig from './RemoteConfig';
|
||||
import * as OS from './OS';
|
||||
import { getEnvironment } from './environment';
|
||||
import * as zkgroup from './util/zkgroup';
|
||||
import { LocalizerType, BodyRangesType, BodyRangeType } from './types/Util';
|
||||
import * as Attachment from './types/Attachment';
|
||||
|
@ -141,7 +142,7 @@ declare global {
|
|||
getCallSystemNotification: () => Promise<boolean>;
|
||||
getConversations: () => ConversationModelCollectionType;
|
||||
getCountMutedConversations: () => Promise<boolean>;
|
||||
getEnvironment: () => string;
|
||||
getEnvironment: typeof getEnvironment;
|
||||
getExpiration: () => string;
|
||||
getGuid: () => string;
|
||||
getInboxCollection: () => ConversationModelCollectionType;
|
||||
|
|
Loading…
Reference in a new issue