signal-desktop/ts/RemoteConfig.ts

245 lines
6.9 KiB
TypeScript
Raw Normal View History

2023-01-03 19:55:46 +00:00
// Copyright 2020 Signal Messenger, LLC
2020-10-30 20:34:04 +00:00
// SPDX-License-Identifier: AGPL-3.0-only
2020-05-27 21:37:06 +00:00
import { get, throttle } from 'lodash';
import type { WebAPIType } from './textsecure/WebAPI';
import * as log from './logging/log';
import type { UUIDStringType } from './types/UUID';
import { parseIntOrThrow } from './util/parseIntOrThrow';
import { SECOND, HOUR } from './util/durations';
2023-07-18 23:57:38 +00:00
import { uuidToBytes } from './util/uuidToBytes';
import * as Bytes from './Bytes';
import { HashType } from './types/Crypto';
import { getCountryCode } from './types/PhoneNumber';
2020-05-27 21:37:06 +00:00
2021-03-03 20:09:58 +00:00
export type ConfigKeyType =
2023-07-19 00:03:39 +00:00
| 'cds.disableCompatibilityMode'
| 'desktop.announcementGroup'
| 'desktop.calling.audioLevelForSpeaking'
2022-08-19 00:31:12 +00:00
| 'desktop.cdsi.returnAcisWithoutUaks'
| 'desktop.clientExpiration'
| 'desktop.editMessageSend'
| 'desktop.contactManagement.beta'
| 'desktop.contactManagement'
| 'desktop.groupCallOutboundRing2.beta'
| 'desktop.groupCallOutboundRing2'
| 'desktop.internalUser'
| 'desktop.mandatoryProfileSharing'
2021-06-25 16:08:16 +00:00
| 'desktop.mediaQuality.levels'
| 'desktop.messageCleanup'
| 'desktop.messageRequests'
2022-08-17 22:06:17 +00:00
| 'desktop.pnp'
2021-06-08 21:51:58 +00:00
| 'desktop.retryRespondMaxAge'
| 'desktop.safetyNumberAci'
| 'desktop.safetyNumberAci.beta'
| 'desktop.senderKey.retry'
| 'desktop.senderKey.send'
| 'desktop.senderKeyMaxAge'
| 'desktop.sendSenderKey3'
| 'desktop.showUserBadges.beta'
| 'desktop.showUserBadges2'
2022-11-08 20:55:51 +00:00
| 'desktop.stories2.beta'
| 'desktop.stories2'
| 'desktop.textFormatting.spoilerSend'
| 'desktop.textFormatting'
| 'desktop.usernames'
| 'global.attachments.maxBytes'
| 'global.calling.maxGroupCallRingSize'
| 'global.groupsv2.groupSizeHardLimit'
2022-10-18 17:12:02 +00:00
| 'global.groupsv2.maxGroupSize'
| 'global.nicknames.max'
| 'global.nicknames.min'
| 'global.safetyNumberAci';
2022-10-07 18:34:27 +00:00
2020-05-27 21:37:06 +00:00
type ConfigValueType = {
name: ConfigKeyType;
enabled: boolean;
enabledAt?: number;
value?: unknown;
2020-05-27 21:37:06 +00:00
};
export type ConfigMapType = {
[key in ConfigKeyType]?: ConfigValueType;
};
2020-05-27 21:37:06 +00:00
type ConfigListenerType = (value: ConfigValueType) => unknown;
type ConfigListenersMapType = {
[key: string]: Array<ConfigListenerType>;
};
let config: ConfigMapType = {};
const listeners: ConfigListenersMapType = {};
export async function initRemoteConfig(server: WebAPIType): Promise<void> {
2020-05-27 21:37:06 +00:00
config = window.storage.get('remoteConfig') || {};
await maybeRefreshRemoteConfig(server);
2020-05-27 21:37:06 +00:00
}
2020-09-11 19:37:01 +00:00
export function onChange(
key: ConfigKeyType,
fn: ConfigListenerType
): () => void {
2020-05-27 21:37:06 +00:00
const keyListeners: Array<ConfigListenerType> = get(listeners, key, []);
keyListeners.push(fn);
listeners[key] = keyListeners;
return () => {
listeners[key] = listeners[key].filter(l => l !== fn);
};
}
export const refreshRemoteConfig = async (
server: WebAPIType
): Promise<void> => {
2020-05-27 21:37:06 +00:00
const now = Date.now();
const { config: newConfig, serverEpochTime } = await server.getConfig();
const serverTimeSkew = serverEpochTime * SECOND - now;
if (Math.abs(serverTimeSkew) > HOUR) {
log.warn(
'Remote Config: sever clock skew detected. ' +
`Server time ${serverEpochTime * SECOND}, local time ${now}`
);
}
2020-05-27 21:37:06 +00:00
// Process new configuration in light of the old configuration
// The old configuration is not set as the initial value in reduce because
// flags may have been deleted
const oldConfig = config;
config = newConfig.reduce((acc, { name, enabled, value }) => {
2020-05-27 21:37:06 +00:00
const previouslyEnabled: boolean = get(oldConfig, [name, 'enabled'], false);
const previousValue: unknown = get(oldConfig, [name, 'value'], undefined);
2020-09-11 19:37:01 +00:00
// If a flag was previously not enabled and is now enabled,
// record the time it was enabled
2020-05-27 21:37:06 +00:00
const enabledAt: number | undefined =
previouslyEnabled && enabled ? now : get(oldConfig, [name, 'enabledAt']);
const configValue = {
2020-05-27 21:37:06 +00:00
name: name as ConfigKeyType,
enabled,
enabledAt,
value,
2020-05-27 21:37:06 +00:00
};
const hasChanged =
previouslyEnabled !== enabled || previousValue !== configValue.value;
2020-05-27 21:37:06 +00:00
// If enablement changes at all, notify listeners
const currentListeners = listeners[name] || [];
if (hasChanged) {
log.info(`Remote Config: Flag ${name} has changed`);
2020-05-27 21:37:06 +00:00
currentListeners.forEach(listener => {
listener(configValue);
2020-05-27 21:37:06 +00:00
});
}
// Return new configuration object
return {
...acc,
[name]: configValue,
2020-05-27 21:37:06 +00:00
};
}, {});
await window.storage.put('remoteConfig', config);
await window.storage.put('serverTimeSkew', serverTimeSkew);
2020-05-27 21:37:06 +00:00
};
export const maybeRefreshRemoteConfig = throttle(
refreshRemoteConfig,
// Only fetch remote configuration if the last fetch was more than two hours ago
2 * 60 * 60 * 1000,
{ trailing: false }
);
export function isEnabled(name: ConfigKeyType): boolean {
return get(config, [name, 'enabled'], false);
}
export function getValue(name: ConfigKeyType): string | undefined {
return get(config, [name, 'value'], undefined);
}
// See isRemoteConfigBucketEnabled in selectors/items.ts
export function isBucketValueEnabled(
name: ConfigKeyType,
e164: string | undefined,
uuid: UUIDStringType | undefined
): boolean {
return innerIsBucketValueEnabled(name, getValue(name), e164, uuid);
}
export function innerIsBucketValueEnabled(
name: ConfigKeyType,
flagValue: unknown,
e164: string | undefined,
uuid: UUIDStringType | undefined
): boolean {
if (e164 == null || uuid == null) {
return false;
}
const countryCode = getCountryCode(e164);
if (countryCode == null) {
return false;
}
if (typeof flagValue !== 'string') {
return false;
}
const remoteConfigValue = getCountryCodeValue(countryCode, flagValue, name);
if (remoteConfigValue == null) {
return false;
}
const bucketValue = getBucketValue(uuid, name);
return bucketValue < remoteConfigValue;
}
export function getCountryCodeValue(
countryCode: number,
flagValue: string,
flagName: string
): number | undefined {
const logId = `getCountryCodeValue/${flagName}`;
if (flagValue.length === 0) {
return undefined;
}
const countryCodeString = countryCode.toString();
const items = flagValue.split(',');
let wildcard: number | undefined;
for (const item of items) {
const [code, value] = item.split(':');
if (code == null || value == null) {
log.warn(`${logId}: '${code}:${value}' entry was invalid`);
continue;
}
const parsedValue = parseIntOrThrow(
value,
`${logId}: Country code '${code}' had an invalid number '${value}'`
);
if (code === '*') {
wildcard = parsedValue;
} else if (countryCodeString === code) {
return parsedValue;
}
}
return wildcard;
}
export function getBucketValue(uuid: UUIDStringType, flagName: string): number {
const hashInput = Bytes.concatenate([
Bytes.fromString(`${flagName}.`),
uuidToBytes(uuid),
]);
2023-07-18 23:57:38 +00:00
const hashResult = window.SignalContext.crypto.hash(
HashType.size256,
hashInput
);
2023-07-18 23:57:38 +00:00
return Number(Bytes.readBigUint64BE(hashResult.slice(0, 8)) % 1_000_000n);
}