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';
|
2021-07-23 17:23:50 +00:00
|
|
|
|
|
|
|
import type { WebAPIType } from './textsecure/WebAPI';
|
2021-09-17 18:27:53 +00:00
|
|
|
import * as log from './logging/log';
|
2023-08-10 16:43:33 +00:00
|
|
|
import type { AciString } from './types/ServiceId';
|
2022-10-20 21:02:22 +00:00
|
|
|
import { parseIntOrThrow } from './util/parseIntOrThrow';
|
2023-07-13 19:06:42 +00:00
|
|
|
import { SECOND, HOUR } from './util/durations';
|
2022-10-20 21:02:22 +00:00
|
|
|
import * as Bytes from './Bytes';
|
2023-08-09 00:53:06 +00:00
|
|
|
import { uuidToBytes } from './util/uuidToBytes';
|
2023-11-02 18:14:38 +00:00
|
|
|
import { dropNull } from './util/dropNull';
|
2022-10-20 21:02:22 +00:00
|
|
|
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 =
|
2024-02-22 21:19:50 +00:00
|
|
|
| 'desktop.calling.adhoc'
|
2024-06-10 15:23:43 +00:00
|
|
|
| 'desktop.calling.adhoc.create'
|
2024-06-11 23:45:28 +00:00
|
|
|
| 'desktop.calling.raiseHand'
|
2020-10-16 18:31:57 +00:00
|
|
|
| 'desktop.clientExpiration'
|
2024-04-22 21:25:56 +00:00
|
|
|
| 'desktop.backup.credentialFetch'
|
2021-08-05 17:00:33 +00:00
|
|
|
| 'desktop.internalUser'
|
2021-06-25 16:08:16 +00:00
|
|
|
| 'desktop.mediaQuality.levels'
|
2022-02-11 21:09:35 +00:00
|
|
|
| 'desktop.messageCleanup'
|
2021-06-08 21:51:58 +00:00
|
|
|
| 'desktop.retryRespondMaxAge'
|
2021-08-04 01:02:35 +00:00
|
|
|
| 'desktop.senderKey.retry'
|
2022-02-11 21:09:35 +00:00
|
|
|
| 'desktop.senderKeyMaxAge'
|
2024-08-06 21:21:15 +00:00
|
|
|
| 'desktop.experimentalTransport.enableAuth'
|
2024-03-14 21:08:51 +00:00
|
|
|
| 'desktop.experimentalTransportEnabled.alpha'
|
|
|
|
| 'desktop.experimentalTransportEnabled.beta'
|
2024-04-18 18:46:48 +00:00
|
|
|
| 'desktop.experimentalTransportEnabled.prod'
|
2024-03-27 22:56:43 +00:00
|
|
|
| 'desktop.cdsiViaLibsignal'
|
2022-04-13 17:47:39 +00:00
|
|
|
| 'global.attachments.maxBytes'
|
2023-10-30 16:24:28 +00:00
|
|
|
| 'global.attachments.maxReceiveBytes'
|
2021-08-17 14:01:27 +00:00
|
|
|
| 'global.calling.maxGroupCallRingSize'
|
2021-07-15 23:48:09 +00:00
|
|
|
| 'global.groupsv2.groupSizeHardLimit'
|
2022-10-18 17:12:02 +00:00
|
|
|
| 'global.groupsv2.maxGroupSize'
|
|
|
|
| 'global.nicknames.max'
|
2023-12-18 18:14:59 +00:00
|
|
|
| 'global.nicknames.min'
|
|
|
|
| 'global.textAttachmentLimitBytes';
|
2022-10-07 18:34:27 +00:00
|
|
|
|
2020-05-27 21:37:06 +00:00
|
|
|
type ConfigValueType = {
|
|
|
|
name: ConfigKeyType;
|
|
|
|
enabled: boolean;
|
|
|
|
enabledAt?: number;
|
2023-11-02 18:14:38 +00:00
|
|
|
value?: string;
|
2020-05-27 21:37:06 +00:00
|
|
|
};
|
2021-11-01 19:13:35 +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 = {};
|
|
|
|
|
2024-08-07 23:36:11 +00:00
|
|
|
export function restoreRemoteConfigFromStorage(): void {
|
2020-05-27 21:37:06 +00:00
|
|
|
config = window.storage.get('remoteConfig') || {};
|
2024-08-07 23:36:11 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
export async function initRemoteConfig(server: WebAPIType): Promise<void> {
|
|
|
|
restoreRemoteConfigFromStorage();
|
2021-07-23 17:23:50 +00:00
|
|
|
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);
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2021-07-23 17:23:50 +00:00
|
|
|
export const refreshRemoteConfig = async (
|
|
|
|
server: WebAPIType
|
|
|
|
): Promise<void> => {
|
2020-05-27 21:37:06 +00:00
|
|
|
const now = Date.now();
|
2023-07-13 19:06:42 +00:00
|
|
|
const { config: newConfig, serverEpochTime } = await server.getConfig();
|
2024-02-06 19:25:58 +00:00
|
|
|
|
2023-07-13 19:06:42 +00:00
|
|
|
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;
|
2020-09-09 22:50:44 +00:00
|
|
|
config = newConfig.reduce((acc, { name, enabled, value }) => {
|
2020-05-27 21:37:06 +00:00
|
|
|
const previouslyEnabled: boolean = get(oldConfig, [name, 'enabled'], false);
|
2023-11-02 18:14:38 +00:00
|
|
|
const previousValue: string | undefined = 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']);
|
|
|
|
|
2020-09-09 22:50:44 +00:00
|
|
|
const configValue = {
|
2020-05-27 21:37:06 +00:00
|
|
|
name: name as ConfigKeyType,
|
|
|
|
enabled,
|
|
|
|
enabledAt,
|
2023-11-02 18:14:38 +00:00
|
|
|
value: dropNull(value),
|
2020-05-27 21:37:06 +00:00
|
|
|
};
|
|
|
|
|
2020-09-09 22:50:44 +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] || [];
|
2020-09-09 22:50:44 +00:00
|
|
|
if (hasChanged) {
|
2021-09-17 18:27:53 +00:00
|
|
|
log.info(`Remote Config: Flag ${name} has changed`);
|
2020-05-27 21:37:06 +00:00
|
|
|
currentListeners.forEach(listener => {
|
2020-09-09 22:50:44 +00:00
|
|
|
listener(configValue);
|
2020-05-27 21:37:06 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
// Return new configuration object
|
|
|
|
return {
|
2020-09-09 22:50:44 +00:00
|
|
|
...acc,
|
|
|
|
[name]: configValue,
|
2020-05-27 21:37:06 +00:00
|
|
|
};
|
|
|
|
}, {});
|
|
|
|
|
2024-02-06 19:25:58 +00:00
|
|
|
// If remote configuration fetch worked - we are not expired anymore.
|
|
|
|
if (
|
|
|
|
!getValue('desktop.clientExpiration') &&
|
|
|
|
window.storage.get('remoteBuildExpiration') != null
|
|
|
|
) {
|
|
|
|
log.warn('Remote Config: clearing remote expiration on successful fetch');
|
|
|
|
await window.storage.remove('remoteBuildExpiration');
|
|
|
|
}
|
|
|
|
|
2022-12-21 18:41:48 +00:00
|
|
|
await window.storage.put('remoteConfig', config);
|
2023-07-13 19:06:42 +00:00
|
|
|
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);
|
|
|
|
}
|
2020-12-01 16:42:35 +00:00
|
|
|
|
|
|
|
export function getValue(name: ConfigKeyType): string | undefined {
|
|
|
|
return get(config, [name, 'value'], undefined);
|
|
|
|
}
|
2022-10-20 21:02:22 +00:00
|
|
|
|
|
|
|
// See isRemoteConfigBucketEnabled in selectors/items.ts
|
|
|
|
export function isBucketValueEnabled(
|
|
|
|
name: ConfigKeyType,
|
|
|
|
e164: string | undefined,
|
2023-08-10 16:43:33 +00:00
|
|
|
aci: AciString | undefined
|
2022-10-20 21:02:22 +00:00
|
|
|
): boolean {
|
2023-08-10 16:43:33 +00:00
|
|
|
return innerIsBucketValueEnabled(name, getValue(name), e164, aci);
|
2022-10-20 21:02:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
export function innerIsBucketValueEnabled(
|
|
|
|
name: ConfigKeyType,
|
|
|
|
flagValue: unknown,
|
|
|
|
e164: string | undefined,
|
2023-08-10 16:43:33 +00:00
|
|
|
aci: AciString | undefined
|
2022-10-20 21:02:22 +00:00
|
|
|
): boolean {
|
2023-08-10 16:43:33 +00:00
|
|
|
if (e164 == null || aci == null) {
|
2022-10-20 21:02:22 +00:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2023-08-10 16:43:33 +00:00
|
|
|
const bucketValue = getBucketValue(aci, name);
|
2022-10-20 21:02:22 +00:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2023-08-10 16:43:33 +00:00
|
|
|
export function getBucketValue(aci: AciString, flagName: string): number {
|
2022-10-20 21:02:22 +00:00
|
|
|
const hashInput = Bytes.concatenate([
|
|
|
|
Bytes.fromString(`${flagName}.`),
|
2023-08-10 16:43:33 +00:00
|
|
|
uuidToBytes(aci),
|
2022-10-20 21:02:22 +00:00
|
|
|
]);
|
2023-07-18 23:57:38 +00:00
|
|
|
const hashResult = window.SignalContext.crypto.hash(
|
|
|
|
HashType.size256,
|
|
|
|
hashInput
|
|
|
|
);
|
2022-10-20 21:02:22 +00:00
|
|
|
|
2023-07-18 23:57:38 +00:00
|
|
|
return Number(Bytes.readBigUint64BE(hashResult.slice(0, 8)) % 1_000_000n);
|
2022-10-20 21:02:22 +00:00
|
|
|
}
|