Show challenge when requested by server

This commit is contained in:
Fedor Indutny 2021-05-05 17:09:29 -07:00 committed by GitHub
parent 03c68da17d
commit 986d8a66bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 1986 additions and 128 deletions

View File

@ -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
View File

@ -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;

View File

@ -367,7 +367,8 @@
"protocols": {
"name": "sgnl-url-scheme",
"schemes": [
"sgnl"
"sgnl",
"signalcaptcha"
]
},
"asarUnpack": [

View File

@ -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';

View File

@ -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 {

View File

@ -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;
}
}
}
}
}

View File

@ -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
View 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();
}
}

View 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)}
/>
);
});

View 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>
);
}

View File

@ -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',
})}
/>
));

View File

@ -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>
);
};

View File

@ -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(

View File

@ -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

View File

@ -19,6 +19,7 @@ const defaultProps = {
socketStatus: 0,
manualReconnect: action('manual-reconnect'),
withinConnectingGracePeriod: false,
challengeStatus: 'idle' as const,
};
const permutations = [

View File

@ -11,6 +11,7 @@ export const SpinnerDirections = [
'outgoing',
'incoming',
'on-background',
'on-captcha',
'on-progress-dialog',
'on-avatar',
] as const;

View File

@ -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',

View File

@ -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;

View File

@ -27,6 +27,7 @@ export const MessageStatuses = [
'sent',
'delivered',
'read',
'paused',
'error',
'partial-sent',
] as const;

50
ts/main/challengeMain.ts Normal file
View 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
View File

@ -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;
}

View File

@ -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,
});
}
}

View File

@ -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 = ({

View File

@ -42,6 +42,7 @@ export type DBConversationType = {
};
export type LastMessageStatus =
| 'paused'
| 'error'
| 'partial-sent'
| 'sending'

View File

@ -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;
}

View File

@ -22,3 +22,8 @@ export const hasNetworkDialog = createSelector(
socketStatus === WebSocket.CLOSED ||
socketStatus === WebSocket.CLOSING)
);
export const isChallengePending = createSelector(
getNetwork,
({ challengeStatus }) => challengeStatus === 'pending'
);

View 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);

View File

@ -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,
};
};

View File

@ -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),

View 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);
});
});

View 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');
});
});
});

View 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);
});
});

View File

@ -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 => {

View File

@ -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({

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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 }>;

View File

@ -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",

View 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);
}

View File

@ -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

View File

@ -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
View File

@ -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;