Show challenge when requested by server
This commit is contained in:
parent
03c68da17d
commit
986d8a66bc
42 changed files with 1986 additions and 128 deletions
|
@ -1505,6 +1505,10 @@
|
|||
"message": "Send failed",
|
||||
"description": "Shown on outgoing message if it fails to send"
|
||||
},
|
||||
"sendPaused": {
|
||||
"message": "Send paused",
|
||||
"description": "Shown on outgoing message if it cannot be sent immediately"
|
||||
},
|
||||
"partiallySent": {
|
||||
"message": "Partially sent, click for details",
|
||||
"description": "Shown on outgoing message if it is partially sent"
|
||||
|
@ -5118,5 +5122,41 @@
|
|||
"ContactSpoofingReviewDialog__safe-title": {
|
||||
"message": "Your contact",
|
||||
"description": "Header in the contact spoofing review dialog, shown above the \"safe\" user"
|
||||
},
|
||||
"CaptchaDialog__title": {
|
||||
"message": "Verify to continue messaging",
|
||||
"description": "Header in the captcha dialog"
|
||||
},
|
||||
"CaptchaDialog__first-paragraph": {
|
||||
"message": "To help prevent spam on Signal, please complete verification.",
|
||||
"description": "First paragraph in the captcha dialog"
|
||||
},
|
||||
"CaptchaDialog__second-paragraph": {
|
||||
"message": "After verifying, you can continue messaging. Any paused messages will automatically be sent.",
|
||||
"description": "First paragraph in the captcha dialog"
|
||||
},
|
||||
"CaptchaDialog--can-close__title": {
|
||||
"message": "Continue Without Verifying?",
|
||||
"description": "Header in the captcha dialog that can be closed"
|
||||
},
|
||||
"CaptchaDialog--can-close__body": {
|
||||
"message": "If you choose to skip verification, you may miss messages from other people and your messages may fail to send.",
|
||||
"description": "Body of the captcha dialog that can be closed"
|
||||
},
|
||||
"CaptchaDialog--can_close__skip-verification": {
|
||||
"message": "Skip verification",
|
||||
"description": "Skip button of the captcha dialog that can be closed"
|
||||
},
|
||||
"verificationComplete": {
|
||||
"message": "Verification complete.",
|
||||
"description": "Displayed after successful captcha"
|
||||
},
|
||||
"verificationFailed": {
|
||||
"message": "Verification failed. Please retry later.",
|
||||
"description": "Displayed after unsuccessful captcha"
|
||||
},
|
||||
"deleteForEveryoneFailed": {
|
||||
"message": "Failed to delete message for everyone. Please retry later.",
|
||||
"description": "Displayed when delete-for-everyone has failed to send to all recepients"
|
||||
}
|
||||
}
|
||||
|
|
22
main.js
22
main.js
|
@ -108,8 +108,10 @@ const OS = require('./ts/OS');
|
|||
const { isBeta } = require('./ts/util/version');
|
||||
const {
|
||||
isSgnlHref,
|
||||
isCaptchaHref,
|
||||
isSignalHttpsLink,
|
||||
parseSgnlHref,
|
||||
parseCaptchaHref,
|
||||
parseSignalHttpsLink,
|
||||
} = require('./ts/util/sgnlHref');
|
||||
const {
|
||||
|
@ -120,8 +122,10 @@ const {
|
|||
TitleBarVisibility,
|
||||
} = require('./ts/types/Settings');
|
||||
const { Environment } = require('./ts/environment');
|
||||
const { ChallengeMainHandler } = require('./ts/main/challengeMain');
|
||||
|
||||
const sql = new MainSQL();
|
||||
const challengeHandler = new ChallengeMainHandler();
|
||||
|
||||
let sqlInitTimeStart = 0;
|
||||
let sqlInitTimeEnd = 0;
|
||||
|
@ -193,6 +197,12 @@ if (!process.mas) {
|
|||
|
||||
showWindow();
|
||||
}
|
||||
const incomingCaptchaHref = getIncomingCaptchaHref(argv);
|
||||
if (incomingCaptchaHref) {
|
||||
const { captcha } = parseCaptchaHref(incomingCaptchaHref, logger);
|
||||
challengeHandler.handleCaptcha(captcha);
|
||||
return true;
|
||||
}
|
||||
// Are they trying to open a sgnl:// href?
|
||||
const incomingHref = getIncomingHref(argv);
|
||||
if (incomingHref) {
|
||||
|
@ -1391,11 +1401,19 @@ app.on('web-contents-created', (createEvent, contents) => {
|
|||
});
|
||||
|
||||
app.setAsDefaultProtocolClient('sgnl');
|
||||
app.setAsDefaultProtocolClient('signalcaptcha');
|
||||
app.on('will-finish-launching', () => {
|
||||
// open-url must be set from within will-finish-launching for macOS
|
||||
// https://stackoverflow.com/a/43949291
|
||||
app.on('open-url', (event, incomingHref) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (isCaptchaHref(incomingHref, logger)) {
|
||||
const { captcha } = parseCaptchaHref(incomingHref, logger);
|
||||
challengeHandler.handleCaptcha(captcha);
|
||||
return;
|
||||
}
|
||||
|
||||
handleSgnlHref(incomingHref);
|
||||
});
|
||||
});
|
||||
|
@ -1656,6 +1674,10 @@ function getIncomingHref(argv) {
|
|||
return argv.find(arg => isSgnlHref(arg, logger));
|
||||
}
|
||||
|
||||
function getIncomingCaptchaHref(argv) {
|
||||
return argv.find(arg => isCaptchaHref(arg, logger));
|
||||
}
|
||||
|
||||
function handleSgnlHref(incomingHref) {
|
||||
let command;
|
||||
let args;
|
||||
|
|
|
@ -367,7 +367,8 @@
|
|||
"protocols": {
|
||||
"name": "sgnl-url-scheme",
|
||||
"schemes": [
|
||||
"sgnl"
|
||||
"sgnl",
|
||||
"signalcaptcha"
|
||||
]
|
||||
},
|
||||
"asarUnpack": [
|
||||
|
|
|
@ -172,6 +172,12 @@ try {
|
|||
Whisper.events.trigger('setupAsStandalone');
|
||||
});
|
||||
|
||||
ipc.on('challenge:response', (_event, response) => {
|
||||
Whisper.events.trigger('challengeResponse', response);
|
||||
});
|
||||
window.sendChallengeRequest = request =>
|
||||
ipc.send('challenge:request', request);
|
||||
|
||||
{
|
||||
let isFullScreen = config.isFullScreen === 'true';
|
||||
|
||||
|
|
|
@ -297,6 +297,17 @@
|
|||
);
|
||||
}
|
||||
}
|
||||
.module-message__error--paused {
|
||||
@include light-theme {
|
||||
@include color-svg(
|
||||
'../images/icons/v2/error-outline-24.svg',
|
||||
$color-gray-60
|
||||
);
|
||||
}
|
||||
@include dark-theme {
|
||||
@include color-svg('../images/icons/v2/error-solid-24.svg', $color-gray-45);
|
||||
}
|
||||
}
|
||||
|
||||
.module-message__error--outgoing {
|
||||
left: 8px;
|
||||
|
@ -1265,6 +1276,7 @@
|
|||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.module-message__metadata__status-icon--paused,
|
||||
.module-message__metadata__status-icon--sending {
|
||||
animation: module-message__metadata__status-icon--spinning 4s linear infinite;
|
||||
|
||||
|
@ -5254,6 +5266,10 @@ button.module-image__border-overlay:focus {
|
|||
}
|
||||
}
|
||||
|
||||
.module-spinner__circle--on-captcha {
|
||||
background-color: $color-white-alpha-40;
|
||||
}
|
||||
|
||||
.module-spinner__circle--on-progress-dialog {
|
||||
@include light-theme {
|
||||
background-color: $color-white;
|
||||
|
@ -5268,6 +5284,9 @@ button.module-image__border-overlay:focus {
|
|||
.module-spinner__arc--on-avatar {
|
||||
background-color: $color-white;
|
||||
}
|
||||
.module-spinner__arc--on-captcha {
|
||||
background-color: $color-white;
|
||||
}
|
||||
|
||||
// Module: Highlighted Message Body
|
||||
|
||||
|
@ -7023,6 +7042,21 @@ button.module-image__border-overlay:focus {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
&--paused {
|
||||
@include light-theme {
|
||||
@include color-svg(
|
||||
'../images/icons/v2/error-outline-12.svg',
|
||||
$color-gray-60
|
||||
);
|
||||
}
|
||||
@include dark-theme {
|
||||
@include color-svg(
|
||||
'../images/icons/v2/error-solid-12.svg',
|
||||
$color-gray-45
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__message-search-result-contents {
|
||||
|
|
|
@ -39,20 +39,27 @@
|
|||
height: 24px;
|
||||
width: 24px;
|
||||
|
||||
@include light-theme {
|
||||
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-75);
|
||||
}
|
||||
&::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@include dark-theme {
|
||||
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-15);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
@include keyboard-mode {
|
||||
background-color: $ultramarine-ui-light;
|
||||
@include light-theme {
|
||||
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-75);
|
||||
}
|
||||
@include dark-keyboard-mode {
|
||||
background-color: $ultramarine-ui-dark;
|
||||
|
||||
@include dark-theme {
|
||||
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-15);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
@include keyboard-mode {
|
||||
background-color: $ultramarine-ui-light;
|
||||
}
|
||||
@include dark-keyboard-mode {
|
||||
background-color: $ultramarine-ui-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -93,4 +100,50 @@
|
|||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
// Overrides for a modal with important message
|
||||
&--important {
|
||||
padding: 10px 12px 16px 12px;
|
||||
|
||||
.module-Modal__header {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.module-Modal__body {
|
||||
padding: 0 12px 4px 12px !important;
|
||||
}
|
||||
|
||||
.module-Modal__body p {
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.module-Modal__title {
|
||||
@include font-title-2;
|
||||
text-align: center;
|
||||
margin: 10px 0 22px 0;
|
||||
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--with-x-button {
|
||||
margin-top: 31px;
|
||||
}
|
||||
}
|
||||
|
||||
.module-Modal__footer {
|
||||
justify-content: center;
|
||||
margin-top: 27px;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
.module-Button {
|
||||
flex-grow: 1;
|
||||
max-width: 152px;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import { DataMessageClass } from './textsecure.d';
|
|||
import { MessageAttributesType } from './model-types.d';
|
||||
import { WhatIsThis } from './window.d';
|
||||
import { getTitleBarVisibility, TitleBarVisibility } from './types/Settings';
|
||||
import { ChallengeHandler } from './challenge';
|
||||
import { isWindowDragElement } from './util/isWindowDragElement';
|
||||
import { assert } from './util/assert';
|
||||
import { senderCertificateService } from './services/senderCertificate';
|
||||
|
@ -12,6 +13,7 @@ import { routineProfileRefresh } from './routineProfileRefresh';
|
|||
import { isMoreRecentThan, isOlderThan } from './util/timestamp';
|
||||
import { isValidReactionEmoji } from './reactions/isValidReactionEmoji';
|
||||
import { ConversationModel } from './models/conversations';
|
||||
import { getMessageById } from './models/messages';
|
||||
import { createBatcher } from './util/batcher';
|
||||
import { updateConversationsWithUuidLookup } from './updateConversationsWithUuidLookup';
|
||||
import { initializeAllJobQueues } from './jobs/initializeAllJobQueues';
|
||||
|
@ -1439,7 +1441,62 @@ export async function startApp(): Promise<void> {
|
|||
window.textsecure.messaging.sendRequestKeySyncMessage();
|
||||
}
|
||||
|
||||
let challengeHandler: ChallengeHandler | undefined;
|
||||
|
||||
async function start() {
|
||||
challengeHandler = new ChallengeHandler({
|
||||
storage: window.storage,
|
||||
|
||||
getMessageById,
|
||||
|
||||
requestChallenge(request) {
|
||||
window.sendChallengeRequest(request);
|
||||
},
|
||||
|
||||
async sendChallengeResponse(data) {
|
||||
await window.textsecure.messaging.sendChallengeResponse(data);
|
||||
},
|
||||
|
||||
onChallengeFailed() {
|
||||
// TODO: DESKTOP-1530
|
||||
// Display humanized `retryAfter`
|
||||
window.Whisper.ToastView.show(
|
||||
window.Whisper.CaptchaFailedToast,
|
||||
document.getElementsByClassName('conversation-stack')[0] ||
|
||||
document.body
|
||||
);
|
||||
},
|
||||
|
||||
onChallengeSolved() {
|
||||
window.Whisper.ToastView.show(
|
||||
window.Whisper.CaptchaSolvedToast,
|
||||
document.getElementsByClassName('conversation-stack')[0] ||
|
||||
document.body
|
||||
);
|
||||
},
|
||||
|
||||
setChallengeStatus(challengeStatus) {
|
||||
window.reduxActions.network.setChallengeStatus(challengeStatus);
|
||||
},
|
||||
});
|
||||
window.Whisper.events.on('challengeResponse', response => {
|
||||
if (!challengeHandler) {
|
||||
throw new Error('Expected challenge handler to be there');
|
||||
}
|
||||
|
||||
challengeHandler.onResponse(response);
|
||||
});
|
||||
|
||||
window.storage.onready(async () => {
|
||||
if (!challengeHandler) {
|
||||
throw new Error('Expected challenge handler to be there');
|
||||
}
|
||||
|
||||
await challengeHandler.load();
|
||||
});
|
||||
|
||||
window.Signal.challengeHandler = challengeHandler;
|
||||
|
||||
window.dispatchEvent(new Event('storage_ready'));
|
||||
|
||||
window.log.info('Cleanup: starting...');
|
||||
|
@ -1661,6 +1718,10 @@ export async function startApp(): Promise<void> {
|
|||
// we get an online event. This waits a bit after getting an 'offline' event
|
||||
// before disconnecting the socket manually.
|
||||
disconnectTimer = setTimeout(disconnect, 1000);
|
||||
|
||||
if (challengeHandler) {
|
||||
challengeHandler.onOffline();
|
||||
}
|
||||
}
|
||||
|
||||
function onOnline() {
|
||||
|
@ -2046,6 +2107,13 @@ export async function startApp(): Promise<void> {
|
|||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (!challengeHandler) {
|
||||
throw new Error('Expected challenge handler to be initialized');
|
||||
}
|
||||
|
||||
// Intentionally not awaiting
|
||||
challengeHandler.onOnline();
|
||||
} finally {
|
||||
connecting = false;
|
||||
}
|
||||
|
|
485
ts/challenge.ts
Normal file
485
ts/challenge.ts
Normal file
|
@ -0,0 +1,485 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
|
||||
// `ChallengeHandler` is responsible for:
|
||||
// 1. tracking the messages that failed to send with 428 error and could be
|
||||
// retried when user solves the challenge
|
||||
// 2. presenting the challenge to user and sending the challenge response back
|
||||
// to the server
|
||||
//
|
||||
// The tracked messages are persisted in the database, and are imported back
|
||||
// to the `ChallengeHandler` on `.load()` call (from `ts/background.ts`). They
|
||||
// are not immediately retried, however, until `.onOnline()` is called from
|
||||
// when we are actually online.
|
||||
|
||||
import { MessageModel } from './models/messages';
|
||||
import { assert } from './util/assert';
|
||||
import { isNotNil } from './util/isNotNil';
|
||||
import { isOlderThan } from './util/timestamp';
|
||||
import { parseRetryAfter } from './util/parseRetryAfter';
|
||||
import { getEnvironment, Environment } from './environment';
|
||||
|
||||
export type ChallengeResponse = {
|
||||
readonly captcha: string;
|
||||
};
|
||||
|
||||
export type IPCRequest = {
|
||||
readonly seq: number;
|
||||
};
|
||||
|
||||
export type IPCResponse = {
|
||||
readonly seq: number;
|
||||
readonly data: ChallengeResponse;
|
||||
};
|
||||
|
||||
export enum RetryMode {
|
||||
Retry = 'Retry',
|
||||
NoImmediateRetry = 'NoImmediateRetry',
|
||||
}
|
||||
|
||||
type Handler = {
|
||||
readonly token: string | undefined;
|
||||
|
||||
resolve(response: ChallengeResponse): void;
|
||||
reject(error: Error): void;
|
||||
};
|
||||
|
||||
export type ChallengeData = {
|
||||
readonly type: 'recaptcha';
|
||||
readonly token: string;
|
||||
readonly captcha: string;
|
||||
};
|
||||
|
||||
export type MinimalMessage = Pick<
|
||||
MessageModel,
|
||||
'id' | 'idForLogging' | 'getLastChallengeError' | 'retrySend'
|
||||
> & {
|
||||
isNormalBubble(): boolean;
|
||||
get(name: 'sent_at'): number;
|
||||
on(event: 'sent', callback: () => void): void;
|
||||
off(event: 'sent', callback: () => void): void;
|
||||
};
|
||||
|
||||
export type Options = {
|
||||
readonly storage: {
|
||||
get(key: string): ReadonlyArray<StoredEntity>;
|
||||
put(key: string, value: ReadonlyArray<StoredEntity>): Promise<void>;
|
||||
};
|
||||
|
||||
requestChallenge(request: IPCRequest): void;
|
||||
|
||||
getMessageById(messageId: string): Promise<MinimalMessage | undefined>;
|
||||
|
||||
sendChallengeResponse(data: ChallengeData): Promise<void>;
|
||||
|
||||
setChallengeStatus(challengeStatus: 'idle' | 'required' | 'pending'): void;
|
||||
|
||||
onChallengeSolved(): void;
|
||||
onChallengeFailed(retryAfter?: number): void;
|
||||
|
||||
expireAfter?: number;
|
||||
};
|
||||
|
||||
export type StoredEntity = {
|
||||
readonly messageId: string;
|
||||
readonly createdAt: number;
|
||||
};
|
||||
|
||||
type TrackedEntry = {
|
||||
readonly message: MinimalMessage;
|
||||
readonly createdAt: number;
|
||||
};
|
||||
|
||||
const DEFAULT_EXPIRE_AFTER = 24 * 3600 * 1000; // one day
|
||||
const MAX_RETRIES = 5;
|
||||
const CAPTCHA_URL = 'https://signalcaptchas.org/challenge/generate.html';
|
||||
const CAPTCHA_STAGING_URL =
|
||||
'https://signalcaptchas.org/staging/challenge/generate.html';
|
||||
|
||||
function shouldRetrySend(message: MinimalMessage): boolean {
|
||||
const error = message.getLastChallengeError();
|
||||
if (!error || error.retryAfter <= Date.now()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getChallengeURL(): string {
|
||||
if (getEnvironment() === Environment.Staging) {
|
||||
return CAPTCHA_STAGING_URL;
|
||||
}
|
||||
return CAPTCHA_URL;
|
||||
}
|
||||
|
||||
// Note that even though this is a class - only one instance of
|
||||
// `ChallengeHandler` should be in memory at the same time because they could
|
||||
// overwrite each others storage data.
|
||||
export class ChallengeHandler {
|
||||
private isLoaded = false;
|
||||
|
||||
private challengeToken: string | undefined;
|
||||
|
||||
private seq = 0;
|
||||
|
||||
private isOnline = false;
|
||||
|
||||
private readonly responseHandlers = new Map<number, Handler>();
|
||||
|
||||
private readonly trackedMessages = new Map<string, TrackedEntry>();
|
||||
|
||||
private readonly retryTimers = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
private readonly pendingRetries = new Set<MinimalMessage>();
|
||||
|
||||
private readonly retryCountById = new Map<string, number>();
|
||||
|
||||
constructor(private readonly options: Options) {}
|
||||
|
||||
public async load(): Promise<void> {
|
||||
if (this.isLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoaded = true;
|
||||
const stored: ReadonlyArray<StoredEntity> =
|
||||
this.options.storage.get('challenge:retry-message-ids') || [];
|
||||
|
||||
window.log.info(`challenge: loading ${stored.length} messages`);
|
||||
|
||||
const entityMap = new Map<string, StoredEntity>();
|
||||
for (const entity of stored) {
|
||||
entityMap.set(entity.messageId, entity);
|
||||
}
|
||||
|
||||
const retryIds = new Set<string>(stored.map(({ messageId }) => messageId));
|
||||
|
||||
const maybeMessages: ReadonlyArray<
|
||||
MinimalMessage | undefined
|
||||
> = await Promise.all(
|
||||
Array.from(retryIds).map(async messageId =>
|
||||
this.options.getMessageById(messageId)
|
||||
)
|
||||
);
|
||||
|
||||
const messages: Array<MinimalMessage> = maybeMessages.filter(isNotNil);
|
||||
|
||||
window.log.info(`challenge: loaded ${messages.length} messages`);
|
||||
|
||||
await Promise.all(
|
||||
messages.map(async message => {
|
||||
const entity = entityMap.get(message.id);
|
||||
if (!entity) {
|
||||
window.log.error(
|
||||
'challenge: unexpected missing entity ' +
|
||||
`for ${message.idForLogging()}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const expireAfter = this.options.expireAfter || DEFAULT_EXPIRE_AFTER;
|
||||
if (isOlderThan(entity.createdAt, expireAfter)) {
|
||||
window.log.info(
|
||||
`challenge: expired entity for ${message.idForLogging()}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// The initialization order is following:
|
||||
//
|
||||
// 1. `.load()` when the `window.storage` is ready
|
||||
// 2. `.onOnline()` when we connected to the server
|
||||
//
|
||||
// Wait for `.onOnline()` to trigger the retries instead of triggering
|
||||
// them here immediately (if the message is ready to be retried).
|
||||
await this.register(message, RetryMode.NoImmediateRetry, entity);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public async onOffline(): Promise<void> {
|
||||
this.isOnline = false;
|
||||
|
||||
window.log.info('challenge: offline');
|
||||
}
|
||||
|
||||
public async onOnline(): Promise<void> {
|
||||
this.isOnline = true;
|
||||
|
||||
const pending = Array.from(this.pendingRetries.values());
|
||||
this.pendingRetries.clear();
|
||||
|
||||
window.log.info(`challenge: online, retrying ${pending.length} messages`);
|
||||
|
||||
// Retry messages that matured while we were offline
|
||||
await Promise.all(pending.map(message => this.retryOne(message)));
|
||||
|
||||
await this.retrySend();
|
||||
}
|
||||
|
||||
public async register(
|
||||
message: MinimalMessage,
|
||||
retry = RetryMode.Retry,
|
||||
entity?: StoredEntity
|
||||
): Promise<void> {
|
||||
if (this.isRegistered(message)) {
|
||||
window.log.info(
|
||||
`challenge: message already registered ${message.idForLogging()}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.trackedMessages.set(message.id, {
|
||||
message,
|
||||
createdAt: entity ? entity.createdAt : Date.now(),
|
||||
});
|
||||
await this.persist();
|
||||
|
||||
// Message is already retryable - initiate new send
|
||||
if (retry === RetryMode.Retry && shouldRetrySend(message)) {
|
||||
window.log.info(
|
||||
`challenge: sending message immediately ${message.idForLogging()}`
|
||||
);
|
||||
await this.retryOne(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const error = message.getLastChallengeError();
|
||||
if (!error) {
|
||||
window.log.error('Unexpected message without challenge error');
|
||||
return;
|
||||
}
|
||||
|
||||
const waitTime = Math.max(0, error.retryAfter - Date.now());
|
||||
const oldTimer = this.retryTimers.get(message.id);
|
||||
if (oldTimer) {
|
||||
clearTimeout(oldTimer);
|
||||
}
|
||||
this.retryTimers.set(
|
||||
message.id,
|
||||
setTimeout(() => {
|
||||
this.retryTimers.delete(message.id);
|
||||
|
||||
this.retryOne(message);
|
||||
}, waitTime)
|
||||
);
|
||||
|
||||
window.log.info(
|
||||
`challenge: tracking ${message.idForLogging()} ` +
|
||||
`with waitTime=${waitTime}`
|
||||
);
|
||||
|
||||
if (!error.data.options || !error.data.options.includes('recaptcha')) {
|
||||
window.log.error(
|
||||
`challenge: unexpected options ${JSON.stringify(error.data.options)}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!error.data.token) {
|
||||
window.log.error(
|
||||
`challenge: no token in challenge error ${JSON.stringify(error.data)}`
|
||||
);
|
||||
} else if (message.isNormalBubble()) {
|
||||
// Display challenge dialog only for core messages
|
||||
// (e.g. text, attachment, embedded contact, or sticker)
|
||||
//
|
||||
// Note: not waiting on this call intentionally since it waits for
|
||||
// challenge to be fully completed.
|
||||
this.solve(error.data.token);
|
||||
} else {
|
||||
window.log.info(
|
||||
`challenge: not a bubble message ${message.idForLogging()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public onResponse(response: IPCResponse): void {
|
||||
const handler = this.responseHandlers.get(response.seq);
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.responseHandlers.delete(response.seq);
|
||||
handler.resolve(response.data);
|
||||
}
|
||||
|
||||
public async unregister(message: MinimalMessage): Promise<void> {
|
||||
window.log.info(`challenge: unregistered ${message.idForLogging()}`);
|
||||
this.trackedMessages.delete(message.id);
|
||||
this.pendingRetries.delete(message);
|
||||
|
||||
const timer = this.retryTimers.get(message.id);
|
||||
this.retryTimers.delete(message.id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
assert(
|
||||
this.isLoaded,
|
||||
'ChallengeHandler has to be loaded before persisting new data'
|
||||
);
|
||||
await this.options.storage.put(
|
||||
'challenge:retry-message-ids',
|
||||
Array.from(this.trackedMessages.entries()).map(
|
||||
([messageId, { createdAt }]) => {
|
||||
return { messageId, createdAt };
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private isRegistered(message: MinimalMessage): boolean {
|
||||
return this.trackedMessages.has(message.id);
|
||||
}
|
||||
|
||||
private async retrySend(force = false): Promise<void> {
|
||||
window.log.info(`challenge: retrySend force=${force}`);
|
||||
|
||||
const retries = Array.from(this.trackedMessages.values())
|
||||
.map(({ message }) => message)
|
||||
// Sort messages in `sent_at` order
|
||||
.sort((a, b) => a.get('sent_at') - b.get('sent_at'))
|
||||
.filter(message => force || shouldRetrySend(message))
|
||||
.map(message => this.retryOne(message));
|
||||
|
||||
await Promise.all(retries);
|
||||
}
|
||||
|
||||
private async retryOne(message: MinimalMessage): Promise<void> {
|
||||
// Send is already pending
|
||||
if (!this.isRegistered(message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We are not online
|
||||
if (!this.isOnline) {
|
||||
this.pendingRetries.add(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const retryCount = this.retryCountById.get(message.id) || 0;
|
||||
window.log.info(
|
||||
`challenge: retrying sending ${message.idForLogging()}, ` +
|
||||
`retry count: ${retryCount}`
|
||||
);
|
||||
|
||||
if (retryCount === MAX_RETRIES) {
|
||||
window.log.info(
|
||||
`challenge: dropping message ${message.idForLogging()}, ` +
|
||||
'too many failed retries'
|
||||
);
|
||||
|
||||
// Keep the message registered so that we'll retry sending it on app
|
||||
// restart.
|
||||
return;
|
||||
}
|
||||
|
||||
await this.unregister(message);
|
||||
|
||||
let sent = false;
|
||||
const onSent = () => {
|
||||
sent = true;
|
||||
};
|
||||
message.on('sent', onSent);
|
||||
|
||||
try {
|
||||
await message.retrySend();
|
||||
} catch (error) {
|
||||
window.log.error(
|
||||
`challenge: failed to send ${message.idForLogging()} due to ` +
|
||||
`error: ${error && error.stack}`
|
||||
);
|
||||
} finally {
|
||||
message.off('sent', onSent);
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
window.log.info(`challenge: message ${message.idForLogging()} sent`);
|
||||
this.retryCountById.delete(message.id);
|
||||
if (this.trackedMessages.size === 0) {
|
||||
this.options.setChallengeStatus('idle');
|
||||
}
|
||||
} else {
|
||||
window.log.info(`challenge: message ${message.idForLogging()} not sent`);
|
||||
|
||||
this.retryCountById.set(message.id, retryCount + 1);
|
||||
await this.register(message, RetryMode.NoImmediateRetry);
|
||||
}
|
||||
}
|
||||
|
||||
private async solve(token: string): Promise<void> {
|
||||
const request: IPCRequest = { seq: this.seq };
|
||||
this.seq += 1;
|
||||
|
||||
this.options.setChallengeStatus('required');
|
||||
this.options.requestChallenge(request);
|
||||
|
||||
this.challengeToken = token || '';
|
||||
const response = await new Promise<ChallengeResponse>((resolve, reject) => {
|
||||
this.responseHandlers.set(request.seq, { token, resolve, reject });
|
||||
});
|
||||
|
||||
// Another `.solve()` has completed earlier than us
|
||||
if (this.challengeToken === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastToken = this.challengeToken;
|
||||
this.challengeToken = undefined;
|
||||
|
||||
this.options.setChallengeStatus('pending');
|
||||
|
||||
window.log.info('challenge: sending challenge to server');
|
||||
|
||||
try {
|
||||
await this.sendChallengeResponse({
|
||||
type: 'recaptcha',
|
||||
token: lastToken,
|
||||
captcha: response.captcha,
|
||||
});
|
||||
} catch (error) {
|
||||
window.log.error(
|
||||
`challenge: challenge failure, error: ${error && error.stack}`
|
||||
);
|
||||
this.options.setChallengeStatus('required');
|
||||
return;
|
||||
}
|
||||
|
||||
window.log.info('challenge: challenge success. force sending');
|
||||
|
||||
this.options.setChallengeStatus('idle');
|
||||
|
||||
this.retrySend(true);
|
||||
}
|
||||
|
||||
private async sendChallengeResponse(data: ChallengeData): Promise<void> {
|
||||
try {
|
||||
await this.options.sendChallengeResponse(data);
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof Error) ||
|
||||
error.name !== 'HTTPError' ||
|
||||
error.code !== 413 ||
|
||||
!error.responseHeaders
|
||||
) {
|
||||
this.options.onChallengeFailed();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const retryAfter = parseRetryAfter(
|
||||
error.responseHeaders['retry-after'].toString()
|
||||
);
|
||||
|
||||
window.log.info(`challenge: retry after ${retryAfter}ms`);
|
||||
this.options.onChallengeFailed(retryAfter);
|
||||
return;
|
||||
}
|
||||
|
||||
this.options.onChallengeSolved();
|
||||
}
|
||||
}
|
33
ts/components/CaptchaDialog.stories.tsx
Normal file
33
ts/components/CaptchaDialog.stories.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { boolean } from '@storybook/addon-knobs';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
|
||||
import { CaptchaDialog } from './CaptchaDialog';
|
||||
import { Button } from './Button';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
|
||||
const story = storiesOf('Components/CaptchaDialog', module);
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
story.add('CaptchaDialog', () => {
|
||||
const [isSkipped, setIsSkipped] = useState(false);
|
||||
|
||||
if (isSkipped) {
|
||||
return <Button onClick={() => setIsSkipped(false)}>Show again</Button>;
|
||||
}
|
||||
|
||||
return (
|
||||
<CaptchaDialog
|
||||
i18n={i18n}
|
||||
isPending={boolean('isPending', false)}
|
||||
onContinue={action('onContinue')}
|
||||
onSkip={() => setIsSkipped(true)}
|
||||
/>
|
||||
);
|
||||
});
|
99
ts/components/CaptchaDialog.tsx
Normal file
99
ts/components/CaptchaDialog.tsx
Normal file
|
@ -0,0 +1,99 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { Button, ButtonVariant } from './Button';
|
||||
import { Modal } from './Modal';
|
||||
import { Spinner } from './Spinner';
|
||||
|
||||
type PropsType = {
|
||||
i18n: LocalizerType;
|
||||
isPending: boolean;
|
||||
|
||||
onContinue: () => void;
|
||||
onSkip: () => void;
|
||||
};
|
||||
|
||||
export function CaptchaDialog(props: Readonly<PropsType>): JSX.Element {
|
||||
const { i18n, isPending, onSkip, onContinue } = props;
|
||||
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const onCancelClick = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
setIsClosing(false);
|
||||
};
|
||||
|
||||
const onSkipClick = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
onSkip();
|
||||
};
|
||||
|
||||
if (isClosing && !isPending) {
|
||||
return (
|
||||
<Modal
|
||||
moduleClassName="module-Modal"
|
||||
i18n={i18n}
|
||||
title={i18n('CaptchaDialog--can-close__title')}
|
||||
>
|
||||
<section>
|
||||
<p>{i18n('CaptchaDialog--can-close__body')}</p>
|
||||
</section>
|
||||
<Modal.Footer>
|
||||
<Button onClick={onCancelClick} variant={ButtonVariant.Secondary}>
|
||||
{i18n('cancel')}
|
||||
</Button>
|
||||
<Button onClick={onSkipClick} variant={ButtonVariant.Destructive}>
|
||||
{i18n('CaptchaDialog--can_close__skip-verification')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const onContinueClick = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
onContinue();
|
||||
};
|
||||
|
||||
const updateButtonRef = (button: HTMLButtonElement): void => {
|
||||
buttonRef.current = button;
|
||||
if (button) {
|
||||
button.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
moduleClassName="module-Modal--important"
|
||||
i18n={i18n}
|
||||
title={i18n('CaptchaDialog__title')}
|
||||
hasXButton
|
||||
onClose={() => setIsClosing(true)}
|
||||
>
|
||||
<section>
|
||||
<p>{i18n('CaptchaDialog__first-paragraph')}</p>
|
||||
<p>{i18n('CaptchaDialog__second-paragraph')}</p>
|
||||
</section>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
disabled={isPending}
|
||||
onClick={onContinueClick}
|
||||
ref={updateButtonRef}
|
||||
variant={ButtonVariant.Primary}
|
||||
>
|
||||
{isPending ? (
|
||||
<Spinner size="22px" svgSize="small" direction="on-captcha" />
|
||||
) : (
|
||||
'Continue'
|
||||
)}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
|
@ -4,9 +4,11 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { select } from '@storybook/addon-knobs';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
|
||||
import { LeftPane, LeftPaneMode, PropsType } from './LeftPane';
|
||||
import { CaptchaDialog } from './CaptchaDialog';
|
||||
import { PropsData as ConversationListItemPropsType } from './conversationList/ConversationListItem';
|
||||
import { MessageSearchResult } from './conversationList/MessageSearchResult';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
|
@ -106,6 +108,12 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
modeSpecificProps: defaultModeSpecificProps,
|
||||
openConversationInternal: action('openConversationInternal'),
|
||||
regionCode: 'US',
|
||||
challengeStatus: select(
|
||||
'challengeStatus',
|
||||
['idle', 'required', 'pending'],
|
||||
'idle'
|
||||
),
|
||||
setChallengeStatus: action('setChallengeStatus'),
|
||||
renderExpiredBuildDialog: () => <div />,
|
||||
renderMainHeader: () => <div />,
|
||||
renderMessageSearchResult: (id: string, style: React.CSSProperties) => (
|
||||
|
@ -126,6 +134,14 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
renderNetworkStatus: () => <div />,
|
||||
renderRelinkDialog: () => <div />,
|
||||
renderUpdateDialog: () => <div />,
|
||||
renderCaptchaDialog: () => (
|
||||
<CaptchaDialog
|
||||
i18n={i18n}
|
||||
isPending={overrideProps.challengeStatus === 'pending'}
|
||||
onContinue={action('onCaptchaContinue')}
|
||||
onSkip={action('onCaptchaSkip')}
|
||||
/>
|
||||
),
|
||||
selectedConversationId: undefined,
|
||||
selectedMessageId: undefined,
|
||||
setComposeSearchTerm: action('setComposeSearchTerm'),
|
||||
|
@ -468,3 +484,33 @@ story.add('Compose: some contacts, some groups, with a search term', () => (
|
|||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
// Captcha flow
|
||||
|
||||
story.add('Captcha dialog: required', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Inbox,
|
||||
pinnedConversations,
|
||||
conversations: defaultConversations,
|
||||
archivedConversations: [],
|
||||
},
|
||||
challengeStatus: 'required',
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Captcha dialog: pending', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Inbox,
|
||||
pinnedConversations,
|
||||
conversations: defaultConversations,
|
||||
archivedConversations: [],
|
||||
},
|
||||
challengeStatus: 'pending',
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
|
|
@ -79,6 +79,8 @@ export type PropsType = {
|
|||
selectedConversationId: undefined | string;
|
||||
selectedMessageId: undefined | string;
|
||||
regionCode: string;
|
||||
challengeStatus: 'idle' | 'required' | 'pending';
|
||||
setChallengeStatus: (status: 'idle') => void;
|
||||
|
||||
// Action Creators
|
||||
cantAddContactToGroup: (conversationId: string) => void;
|
||||
|
@ -110,6 +112,7 @@ export type PropsType = {
|
|||
renderNetworkStatus: () => JSX.Element;
|
||||
renderRelinkDialog: () => JSX.Element;
|
||||
renderUpdateDialog: () => JSX.Element;
|
||||
renderCaptchaDialog: (props: { onSkip(): void }) => JSX.Element;
|
||||
};
|
||||
|
||||
export const LeftPane: React.FC<PropsType> = ({
|
||||
|
@ -121,6 +124,8 @@ export const LeftPane: React.FC<PropsType> = ({
|
|||
createGroup,
|
||||
i18n,
|
||||
modeSpecificProps,
|
||||
challengeStatus,
|
||||
setChallengeStatus,
|
||||
openConversationInternal,
|
||||
renderExpiredBuildDialog,
|
||||
renderMainHeader,
|
||||
|
@ -128,6 +133,7 @@ export const LeftPane: React.FC<PropsType> = ({
|
|||
renderNetworkStatus,
|
||||
renderRelinkDialog,
|
||||
renderUpdateDialog,
|
||||
renderCaptchaDialog,
|
||||
selectedConversationId,
|
||||
selectedMessageId,
|
||||
setComposeSearchTerm,
|
||||
|
@ -464,6 +470,12 @@ export const LeftPane: React.FC<PropsType> = ({
|
|||
{footerContents && (
|
||||
<div className="module-left-pane__footer">{footerContents}</div>
|
||||
)}
|
||||
{challengeStatus !== 'idle' &&
|
||||
renderCaptchaDialog({
|
||||
onSkip() {
|
||||
setChallengeStatus('idle');
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -32,6 +32,7 @@ export type PropsType = {
|
|||
isMe?: boolean;
|
||||
name?: string;
|
||||
color?: ColorType;
|
||||
disabled?: boolean;
|
||||
isVerified?: boolean;
|
||||
profileName?: string;
|
||||
title: string;
|
||||
|
@ -339,6 +340,7 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
|||
const {
|
||||
avatarPath,
|
||||
color,
|
||||
disabled,
|
||||
i18n,
|
||||
name,
|
||||
startComposing,
|
||||
|
@ -437,6 +439,7 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
|||
/>
|
||||
)}
|
||||
<input
|
||||
disabled={disabled}
|
||||
type="text"
|
||||
ref={this.inputRef}
|
||||
className={classNames(
|
||||
|
|
|
@ -48,12 +48,22 @@ export function Modal({
|
|||
aria-label={i18n('close')}
|
||||
type="button"
|
||||
className="module-Modal__close-button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{title && <h1 className="module-Modal__title">{title}</h1>}
|
||||
{title && (
|
||||
<h1
|
||||
className={classNames(
|
||||
'module-Modal__title',
|
||||
hasXButton ? 'module-Modal__title--with-x-button' : null
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
|
|
|
@ -19,6 +19,7 @@ const defaultProps = {
|
|||
socketStatus: 0,
|
||||
manualReconnect: action('manual-reconnect'),
|
||||
withinConnectingGracePeriod: false,
|
||||
challengeStatus: 'idle' as const,
|
||||
};
|
||||
|
||||
const permutations = [
|
||||
|
|
|
@ -11,6 +11,7 @@ export const SpinnerDirections = [
|
|||
'outgoing',
|
||||
'incoming',
|
||||
'on-background',
|
||||
'on-captcha',
|
||||
'on-progress-dialog',
|
||||
'on-avatar',
|
||||
] as const;
|
||||
|
|
|
@ -489,6 +489,15 @@ story.add('Error', () => {
|
|||
return renderBothDirections(props);
|
||||
});
|
||||
|
||||
story.add('Paused', () => {
|
||||
const props = createProps({
|
||||
status: 'paused',
|
||||
text: 'I am up to a challenge',
|
||||
});
|
||||
|
||||
return renderBothDirections(props);
|
||||
});
|
||||
|
||||
story.add('Partial Send', () => {
|
||||
const props = createProps({
|
||||
status: 'partial-sent',
|
||||
|
|
|
@ -67,6 +67,7 @@ const THREE_HOURS = 3 * 60 * 60 * 1000;
|
|||
export const MessageStatuses = [
|
||||
'delivered',
|
||||
'error',
|
||||
'paused',
|
||||
'partial-sent',
|
||||
'read',
|
||||
'sending',
|
||||
|
@ -522,8 +523,31 @@ export class Message extends React.Component<Props, State> {
|
|||
const isError = status === 'error' && direction === 'outgoing';
|
||||
const isPartiallySent =
|
||||
status === 'partial-sent' && direction === 'outgoing';
|
||||
const isPaused = status === 'paused';
|
||||
|
||||
if (isError || isPartiallySent || isPaused) {
|
||||
let statusInfo: React.ReactChild;
|
||||
if (isError) {
|
||||
statusInfo = i18n('sendFailed');
|
||||
} else if (isPaused) {
|
||||
statusInfo = i18n('sendPaused');
|
||||
} else {
|
||||
statusInfo = (
|
||||
<button
|
||||
type="button"
|
||||
className="module-message__metadata__tapable"
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
showMessageDetail(id);
|
||||
}}
|
||||
>
|
||||
{i18n('partiallySent')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || isPartiallySent) {
|
||||
return (
|
||||
<span
|
||||
className={classNames({
|
||||
|
@ -533,22 +557,7 @@ export class Message extends React.Component<Props, State> {
|
|||
'module-message__metadata__date--with-image-no-caption': withImageNoCaption,
|
||||
})}
|
||||
>
|
||||
{isError ? (
|
||||
i18n('sendFailed')
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="module-message__metadata__tapable"
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
showMessageDetail(id);
|
||||
}}
|
||||
>
|
||||
{i18n('partiallySent')}
|
||||
</button>
|
||||
)}
|
||||
{statusInfo}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
@ -1232,7 +1241,15 @@ export class Message extends React.Component<Props, State> {
|
|||
public renderError(isCorrectSide: boolean): JSX.Element | null {
|
||||
const { status, direction } = this.props;
|
||||
|
||||
if (!isCorrectSide || (status !== 'error' && status !== 'partial-sent')) {
|
||||
if (!isCorrectSide) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
status !== 'paused' &&
|
||||
status !== 'error' &&
|
||||
status !== 'partial-sent'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -1241,7 +1258,8 @@ export class Message extends React.Component<Props, State> {
|
|||
<div
|
||||
className={classNames(
|
||||
'module-message__error',
|
||||
`module-message__error--${direction}`
|
||||
`module-message__error--${direction}`,
|
||||
`module-message__error--${status}`
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
@ -1446,7 +1464,9 @@ export class Message extends React.Component<Props, State> {
|
|||
const { canDeleteForEveryone } = this.state;
|
||||
|
||||
const showRetry =
|
||||
(status === 'error' || status === 'partial-sent') &&
|
||||
(status === 'paused' ||
|
||||
status === 'error' ||
|
||||
status === 'partial-sent') &&
|
||||
direction === 'outgoing';
|
||||
const multipleAttachments = attachments && attachments.length > 1;
|
||||
|
||||
|
|
|
@ -27,6 +27,7 @@ export const MessageStatuses = [
|
|||
'sent',
|
||||
'delivered',
|
||||
'read',
|
||||
'paused',
|
||||
'error',
|
||||
'partial-sent',
|
||||
] as const;
|
||||
|
|
50
ts/main/challengeMain.ts
Normal file
50
ts/main/challengeMain.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
/* eslint-disable no-restricted-syntax, no-console */
|
||||
|
||||
import { ipcMain as ipc, IpcMainEvent } from 'electron';
|
||||
|
||||
import { IPCRequest, IPCResponse, ChallengeResponse } from '../challenge';
|
||||
|
||||
export class ChallengeMainHandler {
|
||||
private handlers: Array<(response: ChallengeResponse) => void> = [];
|
||||
|
||||
constructor() {
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
public handleCaptcha(captcha: string): void {
|
||||
const response: ChallengeResponse = { captcha };
|
||||
|
||||
const { handlers } = this;
|
||||
this.handlers = [];
|
||||
for (const resolve of handlers) {
|
||||
resolve(response);
|
||||
}
|
||||
}
|
||||
|
||||
private async onRequest(
|
||||
event: IpcMainEvent,
|
||||
request: IPCRequest
|
||||
): Promise<void> {
|
||||
console.log('Received challenge request, waiting for response');
|
||||
|
||||
const data = await new Promise<ChallengeResponse>(resolve => {
|
||||
this.handlers.push(resolve);
|
||||
});
|
||||
|
||||
console.log('Sending challenge response', data);
|
||||
|
||||
const ipcResponse: IPCResponse = {
|
||||
seq: request.seq,
|
||||
data,
|
||||
};
|
||||
event.sender.send('challenge:response', ipcResponse);
|
||||
}
|
||||
|
||||
private initialize(): void {
|
||||
ipc.on('challenge:request', (event, request) => {
|
||||
this.onRequest(event, request);
|
||||
});
|
||||
}
|
||||
}
|
16
ts/model-types.d.ts
vendored
16
ts/model-types.d.ts
vendored
|
@ -13,6 +13,7 @@ import {
|
|||
LastMessageStatus,
|
||||
} from './state/ducks/conversations';
|
||||
import { SendOptionsType } from './textsecure/SendMessage';
|
||||
import { SendMessageChallengeData } from './textsecure/Errors';
|
||||
import {
|
||||
AccessRequiredEnum,
|
||||
MemberRoleEnum,
|
||||
|
@ -42,6 +43,8 @@ type TaskResultType = any;
|
|||
export type CustomError = Error & {
|
||||
identifier?: string;
|
||||
number?: string;
|
||||
data?: object;
|
||||
retryAfter?: number;
|
||||
};
|
||||
|
||||
export type GroupMigrationType = {
|
||||
|
@ -62,6 +65,13 @@ export type QuotedMessageType = {
|
|||
text: string;
|
||||
};
|
||||
|
||||
export type RetryOptions = Readonly<{
|
||||
type: 'session-reset';
|
||||
uuid: string;
|
||||
e164: string;
|
||||
now: number;
|
||||
}>;
|
||||
|
||||
export type MessageAttributesType = {
|
||||
bodyPending: boolean;
|
||||
bodyRanges: BodyRangesType;
|
||||
|
@ -113,6 +123,7 @@ export type MessageAttributesType = {
|
|||
}>;
|
||||
read_by: Array<string | null>;
|
||||
requiredProtocolVersion: number;
|
||||
retryOptions?: RetryOptions;
|
||||
sent: boolean;
|
||||
sourceDevice: string | number;
|
||||
snippet: unknown;
|
||||
|
@ -325,6 +336,11 @@ export type VerificationOptions = {
|
|||
viaSyncMessage?: boolean;
|
||||
};
|
||||
|
||||
export type ShallowChallengeError = CustomError & {
|
||||
readonly retryAfter: number;
|
||||
readonly data: SendMessageChallengeData;
|
||||
};
|
||||
|
||||
export declare class ConversationModelCollectionType extends Backbone.Collection<ConversationModel> {
|
||||
resetLookups(): void;
|
||||
}
|
||||
|
|
|
@ -3028,8 +3028,6 @@ export class ConversationModel extends window.Backbone
|
|||
fromId: window.ConversationController.getOurConversationId(),
|
||||
});
|
||||
|
||||
window.Whisper.Deletes.onDelete(deleteModel);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const destination = this.getSendTarget()!;
|
||||
const recipients = this.getRecipients();
|
||||
|
@ -3108,7 +3106,16 @@ export class ConversationModel extends window.Backbone
|
|||
// anything to the database.
|
||||
message.doNotSave = true;
|
||||
|
||||
return message.send(this.wrapSend(promise));
|
||||
const result = await message.send(this.wrapSend(promise));
|
||||
|
||||
if (!message.hasSuccessfulDelivery()) {
|
||||
// This is handled by `conversation_view` which displays a toast on
|
||||
// send error.
|
||||
throw new Error('No successful delivery for delete for everyone');
|
||||
}
|
||||
window.Whisper.Deletes.onDelete(deleteModel);
|
||||
|
||||
return result;
|
||||
}).catch(error => {
|
||||
window.log.error(
|
||||
'Error sending deleteForEveryone',
|
||||
|
@ -3138,7 +3145,6 @@ export class ConversationModel extends window.Backbone
|
|||
timestamp,
|
||||
fromSync: true,
|
||||
});
|
||||
window.Whisper.Reactions.onReaction(reactionModel);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const destination = this.getSendTarget()!;
|
||||
|
@ -3239,15 +3245,17 @@ export class ConversationModel extends window.Backbone
|
|||
);
|
||||
})();
|
||||
|
||||
return message.send(this.wrapSend(promise));
|
||||
}).catch(error => {
|
||||
window.log.error('Error sending reaction', reaction, target, error);
|
||||
const result = await message.send(this.wrapSend(promise));
|
||||
|
||||
const reverseReaction = reactionModel.clone();
|
||||
reverseReaction.set('remove', !reverseReaction.get('remove'));
|
||||
window.Whisper.Reactions.onReaction(reverseReaction);
|
||||
if (!message.hasSuccessfulDelivery()) {
|
||||
// This is handled by `conversation_view` which displays a toast on
|
||||
// send error.
|
||||
throw new Error('No successful delivery for reaction');
|
||||
}
|
||||
|
||||
throw error;
|
||||
window.Whisper.Reactions.onReaction(reactionModel);
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -4167,25 +4175,17 @@ export class ConversationModel extends window.Backbone
|
|||
const message = window.MessageController.register(model.id, model);
|
||||
this.addSingleMessage(message);
|
||||
|
||||
const options = await this.getSendOptions();
|
||||
message.send(
|
||||
this.wrapSend(
|
||||
// TODO: DESKTOP-724
|
||||
// resetSession returns `Array<void>` which is incompatible with the
|
||||
// expected promise return values. `[]` is truthy and wrapSend assumes
|
||||
// it's a valid callback result type
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
window.textsecure.messaging.resetSession(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
this.get('uuid')!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
this.get('e164')!,
|
||||
now,
|
||||
options
|
||||
)
|
||||
)
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const uuid = this.get('uuid')!;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const e164 = this.get('e164')!;
|
||||
|
||||
message.sendUtilityMessageWithRetry({
|
||||
type: 'session-reset',
|
||||
uuid,
|
||||
e164,
|
||||
now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -4,6 +4,8 @@
|
|||
import {
|
||||
CustomError,
|
||||
MessageAttributesType,
|
||||
RetryOptions,
|
||||
ShallowChallengeError,
|
||||
QuotedMessageType,
|
||||
WhatIsThis,
|
||||
} from '../model-types.d';
|
||||
|
@ -1041,6 +1043,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
const sentTo = this.get('sent_to') || [];
|
||||
|
||||
if (this.hasErrors()) {
|
||||
if (this.getLastChallengeError()) {
|
||||
return 'paused';
|
||||
}
|
||||
if (sent || sentTo.length > 0) {
|
||||
return 'partial-sent';
|
||||
}
|
||||
|
@ -2000,6 +2005,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
'code',
|
||||
'number',
|
||||
'identifier',
|
||||
'retryAfter',
|
||||
'data',
|
||||
'reason'
|
||||
) as Required<Error>;
|
||||
}
|
||||
|
@ -2009,6 +2016,13 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
|
||||
this.set({ errors });
|
||||
|
||||
if (
|
||||
!this.doNotSave &&
|
||||
errors.some(error => error.name === 'SendMessageChallengeError')
|
||||
) {
|
||||
await window.Signal.challengeHandler.register(this);
|
||||
}
|
||||
|
||||
if (!skipSave && !this.doNotSave) {
|
||||
await window.Signal.Data.saveMessage(this.attributes, {
|
||||
Message: window.Whisper.Message,
|
||||
|
@ -2109,7 +2123,13 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
return null;
|
||||
}
|
||||
|
||||
this.set({ errors: undefined });
|
||||
const retryOptions = this.get('retryOptions');
|
||||
|
||||
this.set({ errors: undefined, retryOptions: undefined });
|
||||
|
||||
if (retryOptions) {
|
||||
return this.sendUtilityMessageWithRetry(retryOptions);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const conversation = this.getConversation()!;
|
||||
|
@ -2252,11 +2272,35 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
e.name === 'MessageError' ||
|
||||
e.name === 'OutgoingMessageError' ||
|
||||
e.name === 'SendMessageNetworkError' ||
|
||||
e.name === 'SendMessageChallengeError' ||
|
||||
e.name === 'SignedPreKeyRotationError' ||
|
||||
e.name === 'OutgoingIdentityKeyError'
|
||||
);
|
||||
}
|
||||
|
||||
public getLastChallengeError(): ShallowChallengeError | undefined {
|
||||
const errors: ReadonlyArray<CustomError> | undefined = this.get('errors');
|
||||
if (!errors) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const challengeErrors = errors
|
||||
.filter((error): error is ShallowChallengeError => {
|
||||
return (
|
||||
error.name === 'SendMessageChallengeError' &&
|
||||
_.isNumber(error.retryAfter) &&
|
||||
_.isObject(error.data)
|
||||
);
|
||||
})
|
||||
.sort((a, b) => a.retryAfter - b.retryAfter);
|
||||
|
||||
return challengeErrors.pop();
|
||||
}
|
||||
|
||||
public hasSuccessfulDelivery(): boolean {
|
||||
return (this.get('sent_to') || []).length !== 0;
|
||||
}
|
||||
|
||||
canDeleteForEveryone(): boolean {
|
||||
// is someone else's message
|
||||
if (this.isIncoming()) {
|
||||
|
@ -2423,6 +2467,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
(e.name === 'MessageError' ||
|
||||
e.name === 'OutgoingMessageError' ||
|
||||
e.name === 'SendMessageNetworkError' ||
|
||||
e.name === 'SendMessageChallengeError' ||
|
||||
e.name === 'SignedPreKeyRotationError' ||
|
||||
e.name === 'OutgoingIdentityKeyError')
|
||||
);
|
||||
|
@ -2564,6 +2609,59 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
});
|
||||
}
|
||||
|
||||
// Currently used only for messages that have to be retried when the server
|
||||
// responds with 428 and we have to retry sending the message on challenge
|
||||
// solution.
|
||||
//
|
||||
// Supported types of messages:
|
||||
// * `session-reset` see `endSession` in `ts/models/conversations.ts`
|
||||
async sendUtilityMessageWithRetry(options: RetryOptions): Promise<void> {
|
||||
if (options.type === 'session-reset') {
|
||||
const conv = this.getConversation();
|
||||
if (!conv) {
|
||||
throw new Error(
|
||||
`Failed to find conversation for message: ${this.idForLogging()}`
|
||||
);
|
||||
}
|
||||
if (!window.textsecure.messaging) {
|
||||
throw new Error('Offline');
|
||||
}
|
||||
|
||||
this.set({
|
||||
retryOptions: options,
|
||||
});
|
||||
|
||||
const sendOptions = await conv.getSendOptions();
|
||||
|
||||
// We don't have to check `sent_to` here, because:
|
||||
//
|
||||
// 1. This happens only in private conversations
|
||||
// 2. Messages to different device ids for the same identifier are sent
|
||||
// in a single request to the server. So partial success is not
|
||||
// possible.
|
||||
await this.send(
|
||||
conv.wrapSend(
|
||||
// TODO: DESKTOP-724
|
||||
// resetSession returns `Array<void>` which is incompatible with the
|
||||
// expected promise return values. `[]` is truthy and wrapSend assumes
|
||||
// it's a valid callback result type
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
window.textsecure.messaging.resetSession(
|
||||
options.uuid,
|
||||
options.e164,
|
||||
options.now,
|
||||
sendOptions
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported retriable type: ${options.type}`);
|
||||
}
|
||||
|
||||
async sendSyncMessageOnly(dataMessage: ArrayBuffer): Promise<void> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const conv = this.getConversation()!;
|
||||
|
@ -2590,7 +2688,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
});
|
||||
} catch (result) {
|
||||
const errors = (result && result.errors) || [new Error('Unknown error')];
|
||||
this.set({ errors });
|
||||
this.saveErrors(errors);
|
||||
} finally {
|
||||
await window.Signal.Data.saveMessage(this.attributes, {
|
||||
Message: window.Whisper.Message,
|
||||
|
@ -4094,6 +4192,33 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
}
|
||||
}
|
||||
|
||||
export async function getMessageById(
|
||||
messageId: string
|
||||
): Promise<MessageModel | undefined> {
|
||||
let message = window.MessageController.getById(messageId);
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
|
||||
try {
|
||||
message = await window.Signal.Data.getMessageById(messageId, {
|
||||
Message: window.Whisper.Message,
|
||||
});
|
||||
} catch (error) {
|
||||
window.log.error(
|
||||
`failed to load message with id ${messageId} ` +
|
||||
`due to error ${error && error.stack}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
message = window.MessageController.register(message.id, message);
|
||||
return message;
|
||||
}
|
||||
|
||||
window.Whisper.Message = MessageModel;
|
||||
|
||||
window.Whisper.Message.getLongMessageAttachment = ({
|
||||
|
|
|
@ -42,6 +42,7 @@ export type DBConversationType = {
|
|||
};
|
||||
|
||||
export type LastMessageStatus =
|
||||
| 'paused'
|
||||
| 'error'
|
||||
| 'partial-sent'
|
||||
| 'sending'
|
||||
|
|
|
@ -11,6 +11,7 @@ export type NetworkStateType = {
|
|||
isOnline: boolean;
|
||||
socketStatus: SocketStatus;
|
||||
withinConnectingGracePeriod: boolean;
|
||||
challengeStatus: 'required' | 'pending' | 'idle';
|
||||
};
|
||||
|
||||
// Actions
|
||||
|
@ -18,6 +19,7 @@ export type NetworkStateType = {
|
|||
const CHECK_NETWORK_STATUS = 'network/CHECK_NETWORK_STATUS';
|
||||
const CLOSE_CONNECTING_GRACE_PERIOD = 'network/CLOSE_CONNECTING_GRACE_PERIOD';
|
||||
const RELINK_DEVICE = 'network/RELINK_DEVICE';
|
||||
const SET_CHALLENGE_STATUS = 'network/SET_CHALLENGE_STATUS';
|
||||
|
||||
export type CheckNetworkStatusPayloadType = {
|
||||
isOnline: boolean;
|
||||
|
@ -37,10 +39,18 @@ type RelinkDeviceActionType = {
|
|||
type: 'network/RELINK_DEVICE';
|
||||
};
|
||||
|
||||
type SetChallengeStatusActionType = {
|
||||
type: 'network/SET_CHALLENGE_STATUS';
|
||||
payload: {
|
||||
challengeStatus: NetworkStateType['challengeStatus'];
|
||||
};
|
||||
};
|
||||
|
||||
export type NetworkActionType =
|
||||
| CheckNetworkStatusAction
|
||||
| CloseConnectingGracePeriodActionType
|
||||
| RelinkDeviceActionType;
|
||||
| RelinkDeviceActionType
|
||||
| SetChallengeStatusActionType;
|
||||
|
||||
// Action Creators
|
||||
|
||||
|
@ -67,19 +77,30 @@ function relinkDevice(): RelinkDeviceActionType {
|
|||
};
|
||||
}
|
||||
|
||||
function setChallengeStatus(
|
||||
challengeStatus: NetworkStateType['challengeStatus']
|
||||
): SetChallengeStatusActionType {
|
||||
return {
|
||||
type: SET_CHALLENGE_STATUS,
|
||||
payload: { challengeStatus },
|
||||
};
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
checkNetworkStatus,
|
||||
closeConnectingGracePeriod,
|
||||
relinkDevice,
|
||||
setChallengeStatus,
|
||||
};
|
||||
|
||||
// Reducer
|
||||
|
||||
function getEmptyState(): NetworkStateType {
|
||||
export function getEmptyState(): NetworkStateType {
|
||||
return {
|
||||
isOnline: navigator.onLine,
|
||||
socketStatus: WebSocket.OPEN,
|
||||
withinConnectingGracePeriod: true,
|
||||
challengeStatus: 'idle',
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -105,5 +126,12 @@ export function reducer(
|
|||
};
|
||||
}
|
||||
|
||||
if (action.type === SET_CHALLENGE_STATUS) {
|
||||
return {
|
||||
...state,
|
||||
challengeStatus: action.payload.challengeStatus,
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -22,3 +22,8 @@ export const hasNetworkDialog = createSelector(
|
|||
socketStatus === WebSocket.CLOSED ||
|
||||
socketStatus === WebSocket.CLOSING)
|
||||
);
|
||||
|
||||
export const isChallengePending = createSelector(
|
||||
getNetwork,
|
||||
({ challengeStatus }) => challengeStatus === 'pending'
|
||||
);
|
||||
|
|
26
ts/state/smart/CaptchaDialog.tsx
Normal file
26
ts/state/smart/CaptchaDialog.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
import { mapDispatchToProps } from '../actions';
|
||||
import { CaptchaDialog } from '../../components/CaptchaDialog';
|
||||
import { StateType } from '../reducer';
|
||||
import { getIntl } from '../selectors/user';
|
||||
import { isChallengePending } from '../selectors/network';
|
||||
import { getChallengeURL } from '../../challenge';
|
||||
|
||||
const mapStateToProps = (state: StateType) => {
|
||||
return {
|
||||
...state.updates,
|
||||
isPending: isChallengePending(state),
|
||||
i18n: getIntl(state),
|
||||
|
||||
onContinue() {
|
||||
document.location.href = getChallengeURL();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const smart = connect(mapStateToProps, mapDispatchToProps);
|
||||
|
||||
export const SmartCaptchaDialog = smart(CaptchaDialog);
|
|
@ -41,6 +41,7 @@ import { SmartMessageSearchResult } from './MessageSearchResult';
|
|||
import { SmartNetworkStatus } from './NetworkStatus';
|
||||
import { SmartRelinkDialog } from './RelinkDialog';
|
||||
import { SmartUpdateDialog } from './UpdateDialog';
|
||||
import { SmartCaptchaDialog } from './CaptchaDialog';
|
||||
|
||||
// Workaround: A react component's required properties are filtering up through connect()
|
||||
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363
|
||||
|
@ -69,6 +70,9 @@ function renderRelinkDialog(): JSX.Element {
|
|||
function renderUpdateDialog(): JSX.Element {
|
||||
return <SmartUpdateDialog />;
|
||||
}
|
||||
function renderCaptchaDialog({ onSkip }: { onSkip(): void }): JSX.Element {
|
||||
return <SmartCaptchaDialog onSkip={onSkip} />;
|
||||
}
|
||||
|
||||
const getModeSpecificProps = (
|
||||
state: StateType
|
||||
|
@ -136,12 +140,14 @@ const mapStateToProps = (state: StateType) => {
|
|||
showArchived: getShowArchived(state),
|
||||
i18n: getIntl(state),
|
||||
regionCode: getRegionCode(state),
|
||||
challengeStatus: state.network.challengeStatus,
|
||||
renderExpiredBuildDialog,
|
||||
renderMainHeader,
|
||||
renderMessageSearchResult,
|
||||
renderNetworkStatus,
|
||||
renderRelinkDialog,
|
||||
renderUpdateDialog,
|
||||
renderCaptchaDialog,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -24,6 +24,7 @@ import { getMe, getSelectedConversation } from '../selectors/conversations';
|
|||
|
||||
const mapStateToProps = (state: StateType) => {
|
||||
return {
|
||||
disabled: state.network.challengeStatus !== 'idle',
|
||||
searchTerm: getQuery(state),
|
||||
searchConversationId: getSearchConversationId(state),
|
||||
searchConversationName: getSearchConversationName(state),
|
||||
|
|
382
ts/test-both/challenge_test.ts
Normal file
382
ts/test-both/challenge_test.ts
Normal file
|
@ -0,0 +1,382 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
/* eslint-disable no-await-in-loop, no-restricted-syntax */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { noop } from 'lodash';
|
||||
import * as sinon from 'sinon';
|
||||
|
||||
import { sleep } from '../util/sleep';
|
||||
import { ChallengeHandler, MinimalMessage } from '../challenge';
|
||||
|
||||
type CreateMessageOptions = {
|
||||
readonly sentAt?: number;
|
||||
readonly retryAfter?: number;
|
||||
readonly isNormalBubble?: boolean;
|
||||
};
|
||||
|
||||
type CreateHandlerOptions = {
|
||||
readonly challenge?: boolean;
|
||||
readonly challengeError?: Error;
|
||||
readonly expireAfter?: number;
|
||||
readonly onChallengeSolved?: () => void;
|
||||
readonly onChallengeFailed?: (retryAfter?: number) => void;
|
||||
};
|
||||
|
||||
describe('ChallengeHandler', () => {
|
||||
const storage = new Map<string, any>();
|
||||
const messageStorage = new Map<string, MinimalMessage>();
|
||||
let challengeStatus = 'idle';
|
||||
let sent: Array<string> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
storage.clear();
|
||||
messageStorage.clear();
|
||||
challengeStatus = 'idle';
|
||||
sent = [];
|
||||
});
|
||||
|
||||
const createMessage = (
|
||||
id: string,
|
||||
options: CreateMessageOptions = {}
|
||||
): MinimalMessage => {
|
||||
const {
|
||||
sentAt = 0,
|
||||
isNormalBubble = true,
|
||||
retryAfter = Date.now() + 25,
|
||||
} = options;
|
||||
|
||||
const testLocalSent = sent;
|
||||
|
||||
const events = new Map<string, () => void>();
|
||||
|
||||
return {
|
||||
id,
|
||||
idForLogging: () => id,
|
||||
isNormalBubble() {
|
||||
return isNormalBubble;
|
||||
},
|
||||
getLastChallengeError() {
|
||||
return {
|
||||
name: 'Ignored',
|
||||
message: 'Ignored',
|
||||
retryAfter,
|
||||
data: { token: 'token', options: ['recaptcha'] },
|
||||
};
|
||||
},
|
||||
get(name) {
|
||||
assert.equal(name, 'sent_at');
|
||||
return sentAt;
|
||||
},
|
||||
on(name, handler) {
|
||||
if (events.get(name)) {
|
||||
throw new Error('Duplicate event');
|
||||
}
|
||||
events.set(name, handler);
|
||||
},
|
||||
off(name, handler) {
|
||||
assert.equal(events.get(name), handler);
|
||||
events.delete(name);
|
||||
},
|
||||
async retrySend() {
|
||||
await sleep(5);
|
||||
const handler = events.get('sent');
|
||||
if (!handler) {
|
||||
throw new Error('Expected handler');
|
||||
}
|
||||
handler();
|
||||
testLocalSent.push(this.id);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createHandler = async ({
|
||||
challenge = false,
|
||||
challengeError,
|
||||
expireAfter,
|
||||
onChallengeSolved = noop,
|
||||
onChallengeFailed = noop,
|
||||
}: CreateHandlerOptions = {}): Promise<ChallengeHandler> => {
|
||||
const handler = new ChallengeHandler({
|
||||
expireAfter,
|
||||
|
||||
storage: {
|
||||
get(key) {
|
||||
return storage.get(key);
|
||||
},
|
||||
async put(key, value) {
|
||||
storage.set(key, value);
|
||||
},
|
||||
},
|
||||
|
||||
onChallengeSolved,
|
||||
onChallengeFailed,
|
||||
|
||||
requestChallenge(request) {
|
||||
if (!challenge) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
handler.onResponse({
|
||||
seq: request.seq,
|
||||
data: { captcha: 'captcha' },
|
||||
});
|
||||
}, 5);
|
||||
},
|
||||
|
||||
async getMessageById(messageId) {
|
||||
return messageStorage.get(messageId);
|
||||
},
|
||||
|
||||
async sendChallengeResponse() {
|
||||
if (challengeError) {
|
||||
throw challengeError;
|
||||
}
|
||||
},
|
||||
|
||||
setChallengeStatus(status) {
|
||||
challengeStatus = status;
|
||||
},
|
||||
});
|
||||
await handler.load();
|
||||
await handler.onOnline();
|
||||
return handler;
|
||||
};
|
||||
|
||||
const isInStorage = (messageId: string) => {
|
||||
return (storage.get('challenge:retry-message-ids') || []).some(
|
||||
({ messageId: storageId }: { messageId: string }) => {
|
||||
return storageId === messageId;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
it('should automatically retry after timeout', async () => {
|
||||
const handler = await createHandler();
|
||||
|
||||
const one = createMessage('1');
|
||||
messageStorage.set('1', one);
|
||||
|
||||
await handler.register(one);
|
||||
assert.isTrue(isInStorage(one.id));
|
||||
assert.equal(challengeStatus, 'required');
|
||||
|
||||
await sleep(50);
|
||||
|
||||
assert.deepEqual(sent, ['1']);
|
||||
assert.equal(challengeStatus, 'idle');
|
||||
assert.isFalse(isInStorage(one.id));
|
||||
});
|
||||
|
||||
it('should send challenge response', async () => {
|
||||
const handler = await createHandler({ challenge: true });
|
||||
|
||||
const one = createMessage('1', { retryAfter: Date.now() + 100000 });
|
||||
messageStorage.set('1', one);
|
||||
|
||||
await handler.register(one);
|
||||
assert.equal(challengeStatus, 'required');
|
||||
|
||||
await sleep(50);
|
||||
|
||||
assert.deepEqual(sent, ['1']);
|
||||
assert.isFalse(isInStorage(one.id));
|
||||
assert.equal(challengeStatus, 'idle');
|
||||
});
|
||||
|
||||
it('should send old messages', async () => {
|
||||
const handler = await createHandler();
|
||||
|
||||
const retryAfter = Date.now() + 50;
|
||||
|
||||
// Put messages in reverse order to validate that the send order is correct
|
||||
const messages = [
|
||||
createMessage('3', { sentAt: 3, retryAfter }),
|
||||
createMessage('2', { sentAt: 2, retryAfter }),
|
||||
createMessage('1', { sentAt: 1, retryAfter }),
|
||||
];
|
||||
for (const message of messages) {
|
||||
messageStorage.set(message.id, message);
|
||||
await handler.register(message);
|
||||
}
|
||||
|
||||
assert.equal(challengeStatus, 'required');
|
||||
assert.deepEqual(sent, []);
|
||||
|
||||
assert.equal(challengeStatus, 'required');
|
||||
for (const message of messages) {
|
||||
assert.isTrue(
|
||||
isInStorage(message.id),
|
||||
`${message.id} should be in storage`
|
||||
);
|
||||
}
|
||||
|
||||
await handler.onOffline();
|
||||
|
||||
// Wait for messages to mature
|
||||
await sleep(50);
|
||||
|
||||
// Create new handler to load old messages from storage
|
||||
await createHandler();
|
||||
for (const message of messages) {
|
||||
await handler.unregister(message);
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
assert.isFalse(
|
||||
isInStorage(message.id),
|
||||
`${message.id} should not be in storage`
|
||||
);
|
||||
}
|
||||
|
||||
// The order has to be correct
|
||||
assert.deepEqual(sent, ['1', '2', '3']);
|
||||
assert.equal(challengeStatus, 'idle');
|
||||
});
|
||||
|
||||
it('should send message immediately if it is ready', async () => {
|
||||
const handler = await createHandler();
|
||||
|
||||
const one = createMessage('1', { retryAfter: Date.now() - 100 });
|
||||
await handler.register(one);
|
||||
|
||||
assert.equal(challengeStatus, 'idle');
|
||||
assert.deepEqual(sent, ['1']);
|
||||
});
|
||||
|
||||
it('should not change challenge status on non-bubble messages', async () => {
|
||||
const handler = await createHandler();
|
||||
|
||||
const one = createMessage('1', { isNormalBubble: false });
|
||||
await handler.register(one);
|
||||
|
||||
assert.equal(challengeStatus, 'idle');
|
||||
assert.deepEqual(sent, []);
|
||||
|
||||
await sleep(50);
|
||||
assert.deepEqual(sent, ['1']);
|
||||
});
|
||||
|
||||
it('should not retry expired messages', async () => {
|
||||
const handler = await createHandler();
|
||||
|
||||
const bubble = createMessage('1');
|
||||
messageStorage.set('1', bubble);
|
||||
await handler.register(bubble);
|
||||
assert.isTrue(isInStorage(bubble.id));
|
||||
|
||||
const newHandler = await createHandler({
|
||||
challenge: true,
|
||||
expireAfter: -1,
|
||||
});
|
||||
await handler.unregister(bubble);
|
||||
|
||||
challengeStatus = 'idle';
|
||||
await newHandler.load();
|
||||
|
||||
assert.equal(challengeStatus, 'idle');
|
||||
assert.deepEqual(sent, []);
|
||||
|
||||
await sleep(25);
|
||||
|
||||
assert.equal(challengeStatus, 'idle');
|
||||
assert.deepEqual(sent, []);
|
||||
assert.isFalse(isInStorage(bubble.id));
|
||||
});
|
||||
|
||||
it('should send messages that matured while we were offline', async () => {
|
||||
const handler = await createHandler();
|
||||
|
||||
const one = createMessage('1');
|
||||
messageStorage.set('1', one);
|
||||
await handler.register(one);
|
||||
|
||||
assert.isTrue(isInStorage(one.id));
|
||||
assert.deepEqual(sent, []);
|
||||
assert.equal(challengeStatus, 'required');
|
||||
|
||||
await handler.onOffline();
|
||||
|
||||
// Let messages mature
|
||||
await sleep(50);
|
||||
|
||||
assert.isTrue(isInStorage(one.id));
|
||||
assert.deepEqual(sent, []);
|
||||
assert.equal(challengeStatus, 'required');
|
||||
|
||||
// Go back online
|
||||
await handler.onOnline();
|
||||
|
||||
assert.isFalse(isInStorage(one.id));
|
||||
assert.deepEqual(sent, [one.id]);
|
||||
assert.equal(challengeStatus, 'idle');
|
||||
});
|
||||
|
||||
it('should not retry more than 5 times', async () => {
|
||||
const handler = await createHandler();
|
||||
|
||||
const one = createMessage('1', {
|
||||
retryAfter: Date.now() + 50,
|
||||
});
|
||||
messageStorage.set('1', one);
|
||||
await handler.register(one);
|
||||
|
||||
const retrySend = sinon.stub(one, 'retrySend');
|
||||
|
||||
assert.isTrue(isInStorage(one.id));
|
||||
assert.deepEqual(sent, []);
|
||||
assert.equal(challengeStatus, 'required');
|
||||
|
||||
// Let it spam the server
|
||||
await sleep(100);
|
||||
|
||||
assert.isTrue(isInStorage(one.id));
|
||||
assert.deepEqual(sent, []);
|
||||
assert.equal(challengeStatus, 'required');
|
||||
|
||||
sinon.assert.callCount(retrySend, 5);
|
||||
});
|
||||
|
||||
it('should trigger onChallengeSolved', async () => {
|
||||
const onChallengeSolved = sinon.stub();
|
||||
|
||||
const handler = await createHandler({
|
||||
challenge: true,
|
||||
onChallengeSolved,
|
||||
});
|
||||
|
||||
const one = createMessage('1', {
|
||||
retryAfter: Date.now() + 1,
|
||||
});
|
||||
messageStorage.set('1', one);
|
||||
await handler.register(one);
|
||||
|
||||
// Let the challenge go through
|
||||
await sleep(50);
|
||||
|
||||
sinon.assert.calledOnce(onChallengeSolved);
|
||||
});
|
||||
|
||||
it('should trigger onChallengeFailed', async () => {
|
||||
const onChallengeFailed = sinon.stub();
|
||||
|
||||
const handler = await createHandler({
|
||||
challenge: true,
|
||||
challengeError: new Error('custom failure'),
|
||||
onChallengeFailed,
|
||||
});
|
||||
|
||||
const one = createMessage('1', {
|
||||
retryAfter: Date.now() + 1,
|
||||
});
|
||||
messageStorage.set('1', one);
|
||||
await handler.register(one);
|
||||
|
||||
// Let the challenge go through
|
||||
await sleep(50);
|
||||
|
||||
sinon.assert.calledOnce(onChallengeFailed);
|
||||
});
|
||||
});
|
26
ts/test-both/state/ducks/network_test.ts
Normal file
26
ts/test-both/state/ducks/network_test.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
|
||||
import { actions, getEmptyState, reducer } from '../../../state/ducks/network';
|
||||
|
||||
describe('both/state/ducks/network', () => {
|
||||
describe('setChallengeStatus', () => {
|
||||
const { setChallengeStatus } = actions;
|
||||
|
||||
it('updates whether we need to complete a server challenge', () => {
|
||||
const idleState = reducer(getEmptyState(), setChallengeStatus('idle'));
|
||||
assert.equal(idleState.challengeStatus, 'idle');
|
||||
|
||||
const requiredState = reducer(idleState, setChallengeStatus('required'));
|
||||
assert.equal(requiredState.challengeStatus, 'required');
|
||||
|
||||
const pendingState = reducer(
|
||||
requiredState,
|
||||
setChallengeStatus('pending')
|
||||
);
|
||||
assert.equal(pendingState.challengeStatus, 'pending');
|
||||
});
|
||||
});
|
||||
});
|
22
ts/test-both/util/parseRetryAfter_test.ts
Normal file
22
ts/test-both/util/parseRetryAfter_test.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
|
||||
import { parseRetryAfter } from '../../util/parseRetryAfter';
|
||||
|
||||
describe('parseRetryAfter', () => {
|
||||
it('should return 0 on invalid input', () => {
|
||||
assert.equal(parseRetryAfter('nope'), 1000);
|
||||
assert.equal(parseRetryAfter('1ff'), 1000);
|
||||
});
|
||||
|
||||
it('should return milleseconds on valid input', () => {
|
||||
assert.equal(parseRetryAfter('100'), 100000);
|
||||
});
|
||||
|
||||
it('should return apply minimum value', () => {
|
||||
assert.equal(parseRetryAfter('0'), 1000);
|
||||
assert.equal(parseRetryAfter('-1'), 1000);
|
||||
});
|
||||
});
|
|
@ -7,8 +7,10 @@ import { LoggerType } from '../../types/Logging';
|
|||
|
||||
import {
|
||||
isSgnlHref,
|
||||
isCaptchaHref,
|
||||
isSignalHttpsLink,
|
||||
parseSgnlHref,
|
||||
parseCaptchaHref,
|
||||
parseSignalHttpsLink,
|
||||
} from '../../util/sgnlHref';
|
||||
|
||||
|
@ -26,65 +28,71 @@ const explodingLogger: LoggerType = {
|
|||
};
|
||||
|
||||
describe('sgnlHref', () => {
|
||||
describe('isSgnlHref', () => {
|
||||
it('returns false for non-strings', () => {
|
||||
const logger = {
|
||||
...explodingLogger,
|
||||
warn: Sinon.spy(),
|
||||
};
|
||||
[
|
||||
{ protocol: 'sgnl', check: isSgnlHref, name: 'isSgnlHref' },
|
||||
{ protocol: 'signalcaptcha', check: isCaptchaHref, name: 'isCaptchaHref' },
|
||||
].forEach(({ protocol, check, name }) => {
|
||||
describe(name, () => {
|
||||
it('returns false for non-strings', () => {
|
||||
const logger = {
|
||||
...explodingLogger,
|
||||
warn: Sinon.spy(),
|
||||
};
|
||||
|
||||
const castToString = (value: unknown): string => value as string;
|
||||
const castToString = (value: unknown): string => value as string;
|
||||
|
||||
assert.isFalse(isSgnlHref(castToString(undefined), logger));
|
||||
assert.isFalse(isSgnlHref(castToString(null), logger));
|
||||
assert.isFalse(isSgnlHref(castToString(123), logger));
|
||||
assert.isFalse(check(castToString(undefined), logger));
|
||||
assert.isFalse(check(castToString(null), logger));
|
||||
assert.isFalse(check(castToString(123), logger));
|
||||
|
||||
Sinon.assert.calledThrice(logger.warn);
|
||||
});
|
||||
Sinon.assert.calledThrice(logger.warn);
|
||||
});
|
||||
|
||||
it('returns false for invalid URLs', () => {
|
||||
assert.isFalse(isSgnlHref('', explodingLogger));
|
||||
assert.isFalse(isSgnlHref('sgnl', explodingLogger));
|
||||
assert.isFalse(isSgnlHref('sgnl://::', explodingLogger));
|
||||
});
|
||||
it('returns false for invalid URLs', () => {
|
||||
assert.isFalse(check('', explodingLogger));
|
||||
assert.isFalse(check(protocol, explodingLogger));
|
||||
assert.isFalse(check(`${protocol}://::`, explodingLogger));
|
||||
});
|
||||
|
||||
it('returns false if the protocol is not "sgnl:"', () => {
|
||||
assert.isFalse(isSgnlHref('https://example', explodingLogger));
|
||||
assert.isFalse(
|
||||
isSgnlHref(
|
||||
'https://signal.art/addstickers/?pack_id=abc',
|
||||
explodingLogger
|
||||
)
|
||||
);
|
||||
assert.isFalse(isSgnlHref('signal://example', explodingLogger));
|
||||
});
|
||||
it(`returns false if the protocol is not "${protocol}:"`, () => {
|
||||
assert.isFalse(check('https://example', explodingLogger));
|
||||
assert.isFalse(
|
||||
check('https://signal.art/addstickers/?pack_id=abc', explodingLogger)
|
||||
);
|
||||
assert.isFalse(check('signal://example', explodingLogger));
|
||||
});
|
||||
|
||||
it('returns true if the protocol is "sgnl:"', () => {
|
||||
assert.isTrue(isSgnlHref('sgnl://', explodingLogger));
|
||||
assert.isTrue(isSgnlHref('sgnl://example', explodingLogger));
|
||||
assert.isTrue(isSgnlHref('sgnl://example.com', explodingLogger));
|
||||
assert.isTrue(isSgnlHref('SGNL://example', explodingLogger));
|
||||
assert.isTrue(isSgnlHref('sgnl://example?foo=bar', explodingLogger));
|
||||
assert.isTrue(isSgnlHref('sgnl://example/', explodingLogger));
|
||||
assert.isTrue(isSgnlHref('sgnl://example#', explodingLogger));
|
||||
it(`returns true if the protocol is "${protocol}:"`, () => {
|
||||
assert.isTrue(check(`${protocol}://`, explodingLogger));
|
||||
assert.isTrue(check(`${protocol}://example`, explodingLogger));
|
||||
assert.isTrue(check(`${protocol}://example.com`, explodingLogger));
|
||||
assert.isTrue(
|
||||
check(`${protocol.toUpperCase()}://example`, explodingLogger)
|
||||
);
|
||||
assert.isTrue(check(`${protocol}://example?foo=bar`, explodingLogger));
|
||||
assert.isTrue(check(`${protocol}://example/`, explodingLogger));
|
||||
assert.isTrue(check(`${protocol}://example#`, explodingLogger));
|
||||
|
||||
assert.isTrue(isSgnlHref('sgnl:foo', explodingLogger));
|
||||
assert.isTrue(check(`${protocol}:foo`, explodingLogger));
|
||||
|
||||
assert.isTrue(isSgnlHref('sgnl://user:pass@example', explodingLogger));
|
||||
assert.isTrue(isSgnlHref('sgnl://example.com:1234', explodingLogger));
|
||||
assert.isTrue(
|
||||
isSgnlHref('sgnl://example.com/extra/path/data', explodingLogger)
|
||||
);
|
||||
assert.isTrue(
|
||||
isSgnlHref('sgnl://example/?foo=bar#hash', explodingLogger)
|
||||
);
|
||||
});
|
||||
assert.isTrue(
|
||||
check(`${protocol}://user:pass@example`, explodingLogger)
|
||||
);
|
||||
assert.isTrue(check(`${protocol}://example.com:1234`, explodingLogger));
|
||||
assert.isTrue(
|
||||
check(`${protocol}://example.com/extra/path/data`, explodingLogger)
|
||||
);
|
||||
assert.isTrue(
|
||||
check(`${protocol}://example/?foo=bar#hash`, explodingLogger)
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts URL objects', () => {
|
||||
const invalid = new URL('https://example.com');
|
||||
assert.isFalse(isSgnlHref(invalid, explodingLogger));
|
||||
const valid = new URL('sgnl://example');
|
||||
assert.isTrue(isSgnlHref(valid, explodingLogger));
|
||||
it('accepts URL objects', () => {
|
||||
const invalid = new URL('https://example.com');
|
||||
assert.isFalse(check(invalid, explodingLogger));
|
||||
const valid = new URL(`${protocol}://example`);
|
||||
assert.isTrue(check(valid, explodingLogger));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -255,6 +263,31 @@ describe('sgnlHref', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('parseCaptchaHref', () => {
|
||||
it('throws on invalid URLs', () => {
|
||||
['', 'sgnl', 'https://example/?foo=bar'].forEach(href => {
|
||||
assert.throws(
|
||||
() => parseCaptchaHref(href, explodingLogger),
|
||||
'Not a captcha href'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('parses the command for URLs with no arguments', () => {
|
||||
[
|
||||
'signalcaptcha://foo',
|
||||
'signalcaptcha://foo?x=y',
|
||||
'signalcaptcha://a:b@foo?x=y',
|
||||
'signalcaptcha://foo#hash',
|
||||
'signalcaptcha://foo/',
|
||||
].forEach(href => {
|
||||
assert.deepEqual(parseCaptchaHref(href, explodingLogger), {
|
||||
captcha: 'foo',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSignalHttpsLink', () => {
|
||||
it('returns a null command for invalid URLs', () => {
|
||||
['', 'https', 'https://example/?foo=bar'].forEach(href => {
|
||||
|
|
|
@ -4,6 +4,8 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
import { parseRetryAfter } from '../util/parseRetryAfter';
|
||||
|
||||
function appendStack(newError: Error, originalError: Error) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
newError.stack += `\nOriginal stack:\n${originalError.stack}`;
|
||||
|
@ -104,6 +106,37 @@ export class SendMessageNetworkError extends ReplayableError {
|
|||
}
|
||||
}
|
||||
|
||||
export type SendMessageChallengeData = {
|
||||
readonly token?: string;
|
||||
readonly options?: ReadonlyArray<string>;
|
||||
};
|
||||
|
||||
export class SendMessageChallengeError extends ReplayableError {
|
||||
public identifier: string;
|
||||
|
||||
public readonly data: SendMessageChallengeData | undefined;
|
||||
|
||||
public readonly retryAfter: number;
|
||||
|
||||
constructor(identifier: string, httpError: Error) {
|
||||
super({
|
||||
name: 'SendMessageChallengeError',
|
||||
message: httpError.message,
|
||||
});
|
||||
|
||||
[this.identifier] = identifier.split('.');
|
||||
this.code = httpError.code;
|
||||
this.data = httpError.response;
|
||||
|
||||
const headers = httpError.responseHeaders || {};
|
||||
|
||||
this.retryAfter =
|
||||
Date.now() + parseRetryAfter(headers['retry-after'].toString());
|
||||
|
||||
appendStack(this, httpError);
|
||||
}
|
||||
}
|
||||
|
||||
export class SignedPreKeyRotationError extends ReplayableError {
|
||||
constructor() {
|
||||
super({
|
||||
|
|
|
@ -34,6 +34,7 @@ import {
|
|||
OutgoingIdentityKeyError,
|
||||
OutgoingMessageError,
|
||||
SendMessageNetworkError,
|
||||
SendMessageChallengeError,
|
||||
UnregisteredUserError,
|
||||
} from './Errors';
|
||||
import { isValidNumber } from '../types/PhoneNumber';
|
||||
|
@ -163,12 +164,16 @@ export default class OutgoingMessage {
|
|||
let error = providedError;
|
||||
|
||||
if (!error || (error.name === 'HTTPError' && error.code !== 404)) {
|
||||
error = new OutgoingMessageError(
|
||||
identifier,
|
||||
this.message.toArrayBuffer(),
|
||||
this.timestamp,
|
||||
error
|
||||
);
|
||||
if (error && error.code === 428) {
|
||||
error = new SendMessageChallengeError(identifier, error);
|
||||
} else {
|
||||
error = new OutgoingMessageError(
|
||||
identifier,
|
||||
this.message.toArrayBuffer(),
|
||||
this.timestamp,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
error.reason = reason;
|
||||
|
@ -370,10 +375,14 @@ export default class OutgoingMessage {
|
|||
if (e.name === 'HTTPError' && e.code !== 409 && e.code !== 410) {
|
||||
// 409 and 410 should bubble and be handled by doSendMessage
|
||||
// 404 should throw UnregisteredUserError
|
||||
// 428 should throw SendMessageChallengeError
|
||||
// all other network errors can be retried later.
|
||||
if (e.code === 404) {
|
||||
throw new UnregisteredUserError(identifier, e);
|
||||
}
|
||||
if (e.code === 428) {
|
||||
throw new SendMessageChallengeError(identifier, e);
|
||||
}
|
||||
throw new SendMessageNetworkError(identifier, jsonData, e);
|
||||
}
|
||||
throw e;
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
GroupCredentialsType,
|
||||
GroupLogResponseType,
|
||||
ProxiedRequestOptionsType,
|
||||
ChallengeType,
|
||||
WebAPIType,
|
||||
} from './WebAPI';
|
||||
import createTaskWithTimeout from './TaskWithTimeout';
|
||||
|
@ -1915,4 +1916,10 @@ export default class MessageSender {
|
|||
): Promise<GroupExternalCredentialClass> {
|
||||
return this.server.getGroupExternalCredential(options);
|
||||
}
|
||||
|
||||
public async sendChallengeResponse(
|
||||
challengeResponse: ChallengeType
|
||||
): Promise<void> {
|
||||
return this.server.sendChallengeResponse(challengeResponse);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -306,7 +306,8 @@ function getContentType(response: Response) {
|
|||
return null;
|
||||
}
|
||||
|
||||
type HeaderListType = { [name: string]: string };
|
||||
type FetchHeaderListType = { [name: string]: string };
|
||||
type HeaderListType = { [name: string]: string | ReadonlyArray<string> };
|
||||
type HTTPCodeType = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
|
||||
type RedactUrl = (url: string) => string;
|
||||
|
@ -397,7 +398,7 @@ async function _promiseAjax(
|
|||
'User-Agent': getUserAgent(options.version),
|
||||
'X-Signal-Agent': 'OWD',
|
||||
...options.headers,
|
||||
} as HeaderListType,
|
||||
} as FetchHeaderListType,
|
||||
redirect: options.redirect,
|
||||
agent,
|
||||
ca: options.certificateAuthority,
|
||||
|
@ -500,6 +501,7 @@ async function _promiseAjax(
|
|||
makeHTTPError(
|
||||
'promiseAjax: invalid response',
|
||||
response.status,
|
||||
response.headers.raw(),
|
||||
result,
|
||||
options.stack
|
||||
)
|
||||
|
@ -563,6 +565,7 @@ async function _promiseAjax(
|
|||
makeHTTPError(
|
||||
'promiseAjax: error response',
|
||||
response.status,
|
||||
response.headers.raw(),
|
||||
result,
|
||||
options.stack
|
||||
)
|
||||
|
@ -576,7 +579,7 @@ async function _promiseAjax(
|
|||
window.log.error(options.type, url, 0, 'Error');
|
||||
}
|
||||
const stack = `${e.stack}\nInitial stack:\n${options.stack}`;
|
||||
reject(makeHTTPError('promiseAjax catch', 0, e.toString(), stack));
|
||||
reject(makeHTTPError('promiseAjax catch', 0, {}, e.toString(), stack));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -614,6 +617,7 @@ declare global {
|
|||
interface Error {
|
||||
code?: number | string;
|
||||
response?: any;
|
||||
responseHeaders?: HeaderListType;
|
||||
warn?: boolean;
|
||||
}
|
||||
}
|
||||
|
@ -621,6 +625,7 @@ declare global {
|
|||
function makeHTTPError(
|
||||
message: string,
|
||||
providedCode: number,
|
||||
headers: HeaderListType,
|
||||
response: any,
|
||||
stack?: string
|
||||
) {
|
||||
|
@ -628,6 +633,7 @@ function makeHTTPError(
|
|||
const e = new Error(`${message}; code: ${code}`);
|
||||
e.name = 'HTTPError';
|
||||
e.code = code;
|
||||
e.responseHeaders = headers;
|
||||
if (DEBUG && response) {
|
||||
e.stack += `\nresponse: ${response}`;
|
||||
}
|
||||
|
@ -670,6 +676,7 @@ const URL_CALLS = {
|
|||
supportUnauthenticatedDelivery: 'v1/devices/unauthenticated_delivery',
|
||||
updateDeviceName: 'v1/accounts/name',
|
||||
whoami: 'v1/accounts/whoami',
|
||||
challenge: 'v1/challenge',
|
||||
};
|
||||
|
||||
type InitializeOptionsType = {
|
||||
|
@ -875,6 +882,7 @@ export type WebAPIType = {
|
|||
options: GroupCredentialsType
|
||||
) => Promise<string>;
|
||||
whoami: () => Promise<any>;
|
||||
sendChallengeResponse: (challengeResponse: ChallengeType) => Promise<any>;
|
||||
getConfig: () => Promise<
|
||||
Array<{ name: string; enabled: boolean; value: string | null }>
|
||||
>;
|
||||
|
@ -912,6 +920,12 @@ export type ServerKeysType = {
|
|||
identityKey: ArrayBuffer;
|
||||
};
|
||||
|
||||
export type ChallengeType = {
|
||||
readonly type: 'recaptcha';
|
||||
readonly token: string;
|
||||
readonly captcha: string;
|
||||
};
|
||||
|
||||
export type ProxiedRequestOptionsType = {
|
||||
returnArrayBuffer?: boolean;
|
||||
start?: number;
|
||||
|
@ -1035,6 +1049,7 @@ export function initialize({
|
|||
updateDeviceName,
|
||||
uploadGroupAvatar,
|
||||
whoami,
|
||||
sendChallengeResponse,
|
||||
};
|
||||
|
||||
async function _ajax(param: AjaxOptionsType): Promise<any> {
|
||||
|
@ -1105,6 +1120,14 @@ export function initialize({
|
|||
});
|
||||
}
|
||||
|
||||
async function sendChallengeResponse(challengeResponse: ChallengeType) {
|
||||
return _ajax({
|
||||
call: 'challenge',
|
||||
httpType: 'PUT',
|
||||
jsonData: challengeResponse,
|
||||
});
|
||||
}
|
||||
|
||||
async function getConfig() {
|
||||
type ResType = {
|
||||
config: Array<{ name: string; enabled: boolean; value: string | null }>;
|
||||
|
|
|
@ -14140,6 +14140,41 @@
|
|||
"updated": "2021-01-06T00:47:54.313Z",
|
||||
"reasonDetail": "Needed to render remote video elements. Doesn't interact with the DOM."
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "ts/challenge.js",
|
||||
"line": " async load() {",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2021-05-05T23:11:22.692Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "ts/challenge.js",
|
||||
"line": " // 1. `.load()` when the `window.storage` is ready",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2021-05-05T23:11:22.692Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "ts/challenge.ts",
|
||||
"line": "// to the `ChallengeHandler` on `.load()` call (from `ts/background.ts`). They",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2021-05-05T23:11:22.692Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "ts/challenge.ts",
|
||||
"line": " public async load(): Promise<void> {",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2021-05-05T23:11:22.692Z"
|
||||
},
|
||||
{
|
||||
"rule": "jQuery-load(",
|
||||
"path": "ts/challenge.ts",
|
||||
"line": " // 1. `.load()` when the `window.storage` is ready",
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2021-05-05T23:11:22.692Z"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/AvatarInput.js",
|
||||
|
@ -14212,6 +14247,14 @@
|
|||
"updated": "2020-10-26T19:12:24.410Z",
|
||||
"reasonDetail": "Used to get the local video element for rendering."
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/CaptchaDialog.js",
|
||||
"line": " const buttonRef = react_1.useRef(null);",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2021-05-05T23:11:22.692Z",
|
||||
"reasonDetail": "Used only to set focus"
|
||||
},
|
||||
{
|
||||
"rule": "React-createRef",
|
||||
"path": "ts/components/CaptionEditor.js",
|
||||
|
|
16
ts/util/parseRetryAfter.ts
Normal file
16
ts/util/parseRetryAfter.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { isNormalNumber } from './isNormalNumber';
|
||||
|
||||
const ONE_SECOND = 1000;
|
||||
const MINIMAL_RETRY_AFTER = ONE_SECOND;
|
||||
|
||||
export function parseRetryAfter(value: string): number {
|
||||
let retryAfter = parseInt(value, 10);
|
||||
if (!isNormalNumber(retryAfter) || retryAfter.toString() !== value) {
|
||||
retryAfter = 0;
|
||||
}
|
||||
|
||||
return Math.max(retryAfter * ONE_SECOND, MINIMAL_RETRY_AFTER);
|
||||
}
|
|
@ -23,6 +23,14 @@ export function isSgnlHref(value: string | URL, logger: LoggerType): boolean {
|
|||
return url !== null && url.protocol === 'sgnl:';
|
||||
}
|
||||
|
||||
export function isCaptchaHref(
|
||||
value: string | URL,
|
||||
logger: LoggerType
|
||||
): boolean {
|
||||
const url = parseUrl(value, logger);
|
||||
return url !== null && url.protocol === 'signalcaptcha:';
|
||||
}
|
||||
|
||||
export function isSignalHttpsLink(
|
||||
value: string | URL,
|
||||
logger: LoggerType
|
||||
|
@ -64,6 +72,23 @@ export function parseSgnlHref(
|
|||
};
|
||||
}
|
||||
|
||||
type ParsedCaptchaHref = {
|
||||
readonly captcha: string;
|
||||
};
|
||||
export function parseCaptchaHref(
|
||||
href: URL | string,
|
||||
logger: LoggerType
|
||||
): ParsedCaptchaHref {
|
||||
const url = parseUrl(href, logger);
|
||||
if (!url || !isCaptchaHref(url, logger)) {
|
||||
throw new Error('Not a captcha href');
|
||||
}
|
||||
|
||||
return {
|
||||
captcha: url.host,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSignalHttpsLink(
|
||||
href: string,
|
||||
logger: LoggerType
|
||||
|
|
|
@ -85,6 +85,18 @@ Whisper.BlockedGroupToast = Whisper.ToastView.extend({
|
|||
},
|
||||
});
|
||||
|
||||
Whisper.CaptchaSolvedToast = Whisper.ToastView.extend({
|
||||
render_attributes() {
|
||||
return { toastMessage: window.i18n('verificationComplete') };
|
||||
},
|
||||
});
|
||||
|
||||
Whisper.CaptchaFailedToast = Whisper.ToastView.extend({
|
||||
render_attributes() {
|
||||
return { toastMessage: window.i18n('verificationFailed') };
|
||||
},
|
||||
});
|
||||
|
||||
Whisper.LeftGroupToast = Whisper.ToastView.extend({
|
||||
render_attributes() {
|
||||
return { toastMessage: window.i18n('youLeftTheGroup') };
|
||||
|
@ -237,6 +249,12 @@ Whisper.ReactionFailedToast = Whisper.ToastView.extend({
|
|||
},
|
||||
});
|
||||
|
||||
Whisper.DeleteForEveryoneFailedToast = Whisper.ToastView.extend({
|
||||
render_attributes() {
|
||||
return { toastMessage: window.i18n('deleteForEveryoneFailed') };
|
||||
},
|
||||
});
|
||||
|
||||
Whisper.GroupLinkCopiedToast = Whisper.ToastView.extend({
|
||||
render_attributes() {
|
||||
return { toastMessage: window.i18n('GroupLinkManagement--clipboard') };
|
||||
|
@ -2788,7 +2806,16 @@ Whisper.ConversationView = Whisper.View.extend({
|
|||
message: window.i18n('deleteForEveryoneWarning'),
|
||||
okText: window.i18n('delete'),
|
||||
resolve: async () => {
|
||||
await this.model.sendDeleteForEveryoneMessage(message.get('sent_at'));
|
||||
try {
|
||||
await this.model.sendDeleteForEveryoneMessage(message.get('sent_at'));
|
||||
} catch (error) {
|
||||
window.log.error(
|
||||
'Error sending delete-for-everyone',
|
||||
error,
|
||||
messageId
|
||||
);
|
||||
this.showToast(Whisper.DeleteForEveryoneFailedToast);
|
||||
}
|
||||
this.resetPanel();
|
||||
},
|
||||
});
|
||||
|
|
10
ts/window.d.ts
vendored
10
ts/window.d.ts
vendored
|
@ -18,6 +18,10 @@ import {
|
|||
MessageAttributesType,
|
||||
} from './model-types.d';
|
||||
import { ContactRecordIdentityState, TextSecureType } from './textsecure.d';
|
||||
import {
|
||||
ChallengeHandler,
|
||||
IPCRequest as IPCChallengeRequest,
|
||||
} from './challenge';
|
||||
import { WebAPIConnectType } from './textsecure/WebAPI';
|
||||
import { uploadDebugLogs } from './logging/debuglogs';
|
||||
import { CallingClass } from './services/calling';
|
||||
|
@ -216,6 +220,7 @@ declare global {
|
|||
showWindow: () => void;
|
||||
showSettings: () => void;
|
||||
shutdown: () => void;
|
||||
sendChallengeRequest: (request: IPCChallengeRequest) => void;
|
||||
setAutoHideMenuBar: (value: WhatIsThis) => void;
|
||||
setBadgeCount: (count: number) => void;
|
||||
setMenuBarVisibility: (value: WhatIsThis) => void;
|
||||
|
@ -522,6 +527,7 @@ declare global {
|
|||
getInitialState: () => WhatIsThis;
|
||||
load: () => void;
|
||||
};
|
||||
challengeHandler: ChallengeHandler;
|
||||
};
|
||||
|
||||
ConversationController: ConversationController;
|
||||
|
@ -580,6 +586,7 @@ export type DCodeIOType = {
|
|||
};
|
||||
|
||||
type MessageControllerType = {
|
||||
getById: (id: string) => MessageModel | undefined;
|
||||
findBySender: (sender: string) => MessageModel | null;
|
||||
findBySentAt: (sentAt: number) => MessageModel | null;
|
||||
register: (id: string, model: MessageModel) => MessageModel;
|
||||
|
@ -739,6 +746,8 @@ export type WhisperType = {
|
|||
BlockedGroupToast: typeof window.Whisper.ToastView;
|
||||
BlockedToast: typeof window.Whisper.ToastView;
|
||||
CannotMixImageAndNonImageAttachmentsToast: typeof window.Whisper.ToastView;
|
||||
CaptchaSolvedToast: typeof window.Whisper.ToastView;
|
||||
CaptchaFailedToast: typeof window.Whisper.ToastView;
|
||||
DangerousFileTypeToast: typeof window.Whisper.ToastView;
|
||||
ExpiredToast: typeof window.Whisper.ToastView;
|
||||
FileSavedToast: typeof window.Whisper.ToastView;
|
||||
|
@ -753,6 +762,7 @@ export type WhisperType = {
|
|||
OriginalNotFoundToast: typeof window.Whisper.ToastView;
|
||||
PinnedConversationsFullToast: typeof window.Whisper.ToastView;
|
||||
ReactionFailedToast: typeof window.Whisper.ToastView;
|
||||
DeleteForEveryoneFailedToast: typeof window.Whisper.ToastView;
|
||||
TapToViewExpiredIncomingToast: typeof window.Whisper.ToastView;
|
||||
TapToViewExpiredOutgoingToast: typeof window.Whisper.ToastView;
|
||||
TimerConflictToast: typeof window.Whisper.ToastView;
|
||||
|
|
Loading…
Reference in a new issue