diff --git a/_locales/en/messages.json b/_locales/en/messages.json index cb7462098..e00737b26 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2956,9 +2956,13 @@ } } }, - "MessageRequests--block-and-delete": { - "message": "Block and Delete", - "description": "Shown as a button to let the user block and delete a message request" + "MessageRequests--block-and-report-spam": { + "message": "Report Spam and Block", + "description": "Shown as a button to let the user block a message request and report spam" + }, + "MessageRequests--block-and-report-spam-success-toast": { + "message": "Reported as spam and blocked.", + "description": "Shown in a toast when you successfully block a user and report them as spam" }, "MessageRequests--block-direct-confirm-title": { "message": "Block $name$?", diff --git a/stylesheets/components/Modal.scss b/stylesheets/components/Modal.scss index 79984596c..626d7881a 100644 --- a/stylesheets/components/Modal.scss +++ b/stylesheets/components/Modal.scss @@ -101,6 +101,11 @@ margin-left: 8px; margin-top: 8px; } + + &--one-button-per-line { + flex-direction: column; + align-items: flex-end; + } } // Overrides for a modal with important message diff --git a/test/backup_test.js b/test/backup_test.js index bdd419a43..6e51b3d9f 100644 --- a/test/backup_test.js +++ b/test/backup_test.js @@ -636,7 +636,7 @@ describe('Backup', () => { 'Backup test: Check that all attachments were successfully imported' ); const messageWithAttachmentsFromDB = await loadAllFilesFromDisk( - messageFromDB + omitUndefinedKeys(messageFromDB) ); const expectedMessageWithAttachments = await loadAllFilesFromDisk( omitUndefinedKeys(message) diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index 7ccd85529..6dcd46db5 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import { get, throttle } from 'lodash'; -import { WebAPIType } from './textsecure/WebAPI'; +import { connectToServerWithStoredCredentials } from './util/connectToServerWithStoredCredentials'; export type ConfigKeyType = | 'desktop.clientExpiration' @@ -29,17 +29,6 @@ type ConfigListenersMapType = { [key: string]: Array; }; -function getServer(): WebAPIType { - const OLD_USERNAME = window.storage.get('number_id'); - const USERNAME = window.storage.get('uuid_id'); - const PASSWORD = window.storage.get('password'); - - return window.WebAPI.connect({ - username: (USERNAME || OLD_USERNAME) as string, - password: PASSWORD as string, - }); -} - let config: ConfigMapType = {}; const listeners: ConfigListenersMapType = {}; @@ -63,7 +52,10 @@ export function onChange( export const refreshRemoteConfig = async (): Promise => { const now = Date.now(); - const server = getServer(); + const server = connectToServerWithStoredCredentials( + window.WebAPI, + window.storage + ); const newConfig = await server.getConfig(); // Process new configuration in light of the old configuration diff --git a/ts/background.ts b/ts/background.ts index b1f0439c9..5061c385d 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -22,6 +22,7 @@ import { ourProfileKeyService } from './services/ourProfileKey'; import { shouldRespondWithProfileKey } from './util/shouldRespondWithProfileKey'; import { setToExpire } from './services/MessageUpdater'; import { LatestQueue } from './util/LatestQueue'; +import { connectToServerWithStoredCredentials } from './util/connectToServerWithStoredCredentials'; const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000; @@ -2006,10 +2007,10 @@ export async function startApp(): Promise { const udSupportKey = 'hasRegisterSupportForUnauthenticatedDelivery'; if (!window.storage.get(udSupportKey)) { - const server = window.WebAPI.connect({ - username: USERNAME || OLD_USERNAME, - password: PASSWORD, - }); + const server = connectToServerWithStoredCredentials( + window.WebAPI, + window.storage + ); try { await server.registerSupportForUnauthenticatedDelivery(); window.storage.put(udSupportKey, true); @@ -2050,10 +2051,10 @@ export async function startApp(): Promise { } if (connectCount === 1) { - const server = window.WebAPI.connect({ - username: USERNAME || OLD_USERNAME, - password: PASSWORD, - }); + const server = connectToServerWithStoredCredentials( + window.WebAPI, + window.storage + ); try { // Note: we always have to register our capabilities all at once, so we do this // after connect on every startup @@ -3151,6 +3152,7 @@ export async function startApp(): Promise { sourceUuid: data.sourceUuid, sourceDevice: data.sourceDevice, sent_at: data.timestamp, + serverGuid: data.serverGuid, serverTimestamp: data.serverTimestamp, received_at: data.receivedAtCounter, received_at_ms: data.receivedAtDate, diff --git a/ts/components/CompositionArea.stories.tsx b/ts/components/CompositionArea.stories.tsx index 5baf4f8d1..7baf2fb6a 100644 --- a/ts/components/CompositionArea.stories.tsx +++ b/ts/components/CompositionArea.stories.tsx @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; @@ -61,7 +61,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ conversationType: 'direct', onAccept: action('onAccept'), onBlock: action('onBlock'), - onBlockAndDelete: action('onBlockAndDelete'), + onBlockAndReportSpam: action('onBlockAndReportSpam'), onDelete: action('onDelete'), onUnblock: action('onUnblock'), messageRequestsEnabled: boolean( diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index 8f9c23f3e..546b62cbf 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -1,4 +1,4 @@ -// Copyright 2019-2020 Signal Messenger, LLC +// Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; @@ -147,7 +147,7 @@ export const CompositionArea = ({ name, onAccept, onBlock, - onBlockAndDelete, + onBlockAndReportSpam, onDelete, onUnblock, phoneNumber, @@ -374,7 +374,7 @@ export const CompositionArea = ({ conversationType={conversationType} isBlocked={isBlocked} onBlock={onBlock} - onBlockAndDelete={onBlockAndDelete} + onBlockAndReportSpam={onBlockAndReportSpam} onUnblock={onUnblock} onDelete={onDelete} onAccept={onAccept} @@ -428,7 +428,7 @@ export const CompositionArea = ({ i18n={i18n} conversationType={conversationType} onBlock={onBlock} - onBlockAndDelete={onBlockAndDelete} + onBlockAndReportSpam={onBlockAndReportSpam} onDelete={onDelete} onAccept={onAccept} name={name} diff --git a/ts/components/Modal.tsx b/ts/components/Modal.tsx index 6e371eee2..a0ac67978 100644 --- a/ts/components/Modal.tsx +++ b/ts/components/Modal.tsx @@ -9,6 +9,7 @@ import { LocalizerType } from '../types/Util'; import { ModalHost } from './ModalHost'; import { Theme } from '../util/theme'; import { getClassNamesFor } from '../util/getClassNamesFor'; +import { useHasWrapped } from '../util/hooks'; type PropsType = { children: ReactNode; @@ -85,19 +86,29 @@ export function Modal({ ); } -Modal.ButtonFooter = ({ +Modal.ButtonFooter = function ButtonFooter({ children, moduleClassName, }: Readonly<{ children: ReactNode; moduleClassName?: string; -}>): ReactElement => ( -
- {children} -
-); +}>): ReactElement { + const [ref, hasWrapped] = useHasWrapped(); + + const className = getClassNamesFor( + BASE_CLASS_NAME, + moduleClassName + )('__button-footer'); + + return ( +
+ {children} +
+ ); +}; diff --git a/ts/components/conversation/ContactSpoofingReviewDialog.stories.tsx b/ts/components/conversation/ContactSpoofingReviewDialog.stories.tsx index e63fb8644..d7942de56 100644 --- a/ts/components/conversation/ContactSpoofingReviewDialog.stories.tsx +++ b/ts/components/conversation/ContactSpoofingReviewDialog.stories.tsx @@ -21,7 +21,7 @@ story.add('Default', () => ( unknown; - onBlockAndDelete: () => unknown; + onBlockAndReportSpam: () => unknown; onClose: () => void; onDelete: () => unknown; onShowContactModal: (contactId: string) => unknown; @@ -30,7 +30,7 @@ type PropsType = { export const ContactSpoofingReviewDialog: FunctionComponent = ({ i18n, onBlock, - onBlockAndDelete, + onBlockAndReportSpam, onClose, onDelete, onShowContactModal, @@ -56,7 +56,7 @@ export const ContactSpoofingReviewDialog: FunctionComponent = ({ & Pick< MessageRequestActionsConfirmationProps, - 'conversationType' | 'onBlock' | 'onBlockAndDelete' | 'onDelete' + 'conversationType' | 'onBlock' | 'onBlockAndReportSpam' | 'onDelete' >; export const MandatoryProfileSharingActions = ({ @@ -29,7 +29,7 @@ export const MandatoryProfileSharingActions = ({ name, onAccept, onBlock, - onBlockAndDelete, + onBlockAndReportSpam, onDelete, phoneNumber, profileName, @@ -43,7 +43,7 @@ export const MandatoryProfileSharingActions = ({ { throw new Error( 'Should not be able to unblock from MandatoryProfileSharingActions' diff --git a/ts/components/conversation/MessageRequestActions.stories.tsx b/ts/components/conversation/MessageRequestActions.stories.tsx index f51c8f81b..18831832e 100644 --- a/ts/components/conversation/MessageRequestActions.stories.tsx +++ b/ts/components/conversation/MessageRequestActions.stories.tsx @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; @@ -27,7 +27,7 @@ const getBaseProps = (isGroup = false): MessageRequestActionsProps => ({ : text('name', 'Cayce Bollard'), onBlock: action('block'), onDelete: action('delete'), - onBlockAndDelete: action('blockAndDelete'), + onBlockAndReportSpam: action('blockAndReportSpam'), onUnblock: action('unblock'), onAccept: action('accept'), }); diff --git a/ts/components/conversation/MessageRequestActions.tsx b/ts/components/conversation/MessageRequestActions.tsx index 1f1f92a1b..6e0e9c455 100644 --- a/ts/components/conversation/MessageRequestActions.tsx +++ b/ts/components/conversation/MessageRequestActions.tsx @@ -30,7 +30,7 @@ export const MessageRequestActions = ({ name, onAccept, onBlock, - onBlockAndDelete, + onBlockAndReportSpam, onDelete, onUnblock, phoneNumber, @@ -45,7 +45,7 @@ export const MessageRequestActions = ({ } actions={[ + ...(conversationType === 'direct' + ? [ + { + text: i18n('MessageRequests--block-and-report-spam'), + action: onBlockAndReportSpam, + style: 'negative' as const, + }, + ] + : []), { text: i18n('MessageRequests--block'), action: onBlock, style: 'negative', }, - { - text: i18n('MessageRequests--block-and-delete'), - action: onBlockAndDelete, - style: 'negative', - }, ]} > {i18n(`MessageRequests--block-${conversationType}-confirm-body`)} diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 0661dcb2a..b1589c2dd 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -280,7 +280,7 @@ const actions = () => ({ ), onBlock: action('onBlock'), - onBlockAndDelete: action('onBlockAndDelete'), + onBlockAndReportSpam: action('onBlockAndReportSpam'), onDelete: action('onDelete'), onUnblock: action('onUnblock'), diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index b8074def9..b719986e8 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -110,7 +110,7 @@ type PropsActionsType = { loadNewestMessages: (messageId: string, setFocus?: boolean) => unknown; markMessageRead: (messageId: string) => unknown; onBlock: () => unknown; - onBlockAndDelete: () => unknown; + onBlockAndReportSpam: () => unknown; onDelete: () => unknown; onUnblock: () => unknown; selectMessage: (messageId: string, conversationId: string) => unknown; @@ -1168,7 +1168,7 @@ export class Timeline extends React.PureComponent { isGroupV1AndDisabled, items, onBlock, - onBlockAndDelete, + onBlockAndReportSpam, onDelete, onUnblock, showContactModal, @@ -1314,7 +1314,7 @@ export class Timeline extends React.PureComponent { ; + getMessageServerGuidsForSpam: ( + conversationId: string + ) => Promise>; + jobQueue: Pick; +}>): Promise { + assert( + conversation.type === 'direct', + 'addReportSpamJob: cannot report spam for non-direct conversations' + ); + + const { e164 } = conversation; + if (!e164) { + log.info( + 'addReportSpamJob got a conversation with no E164, which the server does not support. Doing nothing' + ); + return; + } + + const serverGuids = await getMessageServerGuidsForSpam(conversation.id); + if (!serverGuids.length) { + // This can happen under normal conditions. We haven't always stored server GUIDs, so + // a user might try to report spam for a conversation that doesn't have them. (It + // may also indicate developer error, but that's not necessarily the case.) + log.info( + 'addReportSpamJob got no server GUIDs from the database. Doing nothing' + ); + return; + } + + await jobQueue.add({ e164, serverGuids }); +} diff --git a/ts/jobs/initializeAllJobQueues.ts b/ts/jobs/initializeAllJobQueues.ts index 5ab8a0652..4e25bb110 100644 --- a/ts/jobs/initializeAllJobQueues.ts +++ b/ts/jobs/initializeAllJobQueues.ts @@ -2,10 +2,12 @@ // SPDX-License-Identifier: AGPL-3.0-only import { removeStorageKeyJobQueue } from './removeStorageKeyJobQueue'; +import { reportSpamJobQueue } from './reportSpamJobQueue'; /** * Start all of the job queues. Should be called when the database is ready. */ export function initializeAllJobQueues(): void { removeStorageKeyJobQueue.streamJobs(); + reportSpamJobQueue.streamJobs(); } diff --git a/ts/jobs/reportSpamJobQueue.ts b/ts/jobs/reportSpamJobQueue.ts new file mode 100644 index 000000000..b54f69130 --- /dev/null +++ b/ts/jobs/reportSpamJobQueue.ts @@ -0,0 +1,119 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as z from 'zod'; +import * as moment from 'moment'; +import { waitForOnline } from '../util/waitForOnline'; +import { isDone as isDeviceLinked } from '../util/registration'; +import * as log from '../logging/log'; +import { connectToServerWithStoredCredentials } from '../util/connectToServerWithStoredCredentials'; +import { map } from '../util/iterables'; +import { sleep } from '../util/sleep'; + +import { JobQueue } from './JobQueue'; +import { jobQueueDatabaseStore } from './JobQueueDatabaseStore'; +import { parseIntWithFallback } from '../util/parseIntWithFallback'; + +const RETRY_WAIT_TIME = moment.duration(1, 'minute').asMilliseconds(); +const RETRYABLE_4XX_FAILURE_STATUSES = new Set([ + 404, + 408, + 410, + 412, + 413, + 414, + 417, + 423, + 424, + 425, + 426, + 428, + 429, + 431, + 449, +]); + +const is4xxStatus = (code: number): boolean => code >= 400 && code <= 499; +const is5xxStatus = (code: number): boolean => code >= 500 && code <= 599; +const isRetriable4xxStatus = (code: number): boolean => + RETRYABLE_4XX_FAILURE_STATUSES.has(code); + +const reportSpamJobDataSchema = z.object({ + e164: z.string().min(1), + serverGuids: z.string().array().min(1).max(1000), +}); + +export type ReportSpamJobData = z.infer; + +export const reportSpamJobQueue = new JobQueue({ + store: jobQueueDatabaseStore, + + queueType: 'report spam', + + maxAttempts: 25, + + parseData(data: unknown): ReportSpamJobData { + return reportSpamJobDataSchema.parse(data); + }, + + async run({ data }: Readonly<{ data: ReportSpamJobData }>): Promise { + const { e164, serverGuids } = data; + + await new Promise(resolve => { + window.storage.onready(resolve); + }); + + if (!isDeviceLinked()) { + log.info("reportSpamJobQueue: skipping this job because we're unlinked"); + return; + } + + await waitForOnline(window.navigator, window); + + const server = connectToServerWithStoredCredentials( + window.WebAPI, + window.storage + ); + + try { + await Promise.all( + map(serverGuids, serverGuid => server.reportMessage(e164, serverGuid)) + ); + } catch (err: unknown) { + if (!(err instanceof Error)) { + throw err; + } + + const code = parseIntWithFallback(err.code, -1); + + // This is an unexpected case, except for -1, which can happen for network failures. + if (code < 400) { + throw err; + } + + if (code === 508) { + log.info( + 'reportSpamJobQueue: server responded with 508. Giving up on this job' + ); + return; + } + + if (isRetriable4xxStatus(code) || is5xxStatus(code)) { + log.info( + `reportSpamJobQueue: server responded with ${code} status code. Sleeping before our next attempt` + ); + await sleep(RETRY_WAIT_TIME); + throw err; + } + + if (is4xxStatus(code)) { + log.error( + `reportSpamJobQueue: server responded with ${code} status code. Giving up on this job` + ); + return; + } + + throw err; + } + }, +}); diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 0a1a83b27..5cf8b2129 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -171,6 +171,9 @@ export type MessageAttributesType = { // background, when we were still in IndexedDB, before attachments had gone to disk // We set this so that the idle message upgrade process doesn't pick this message up schemaVersion: number; + // This should always be set for new messages, but older messages may not have them. We + // may not have these for outbound messages, either, as we have not needed them. + serverGuid?: string; serverTimestamp?: number; source?: string; sourceUuid?: string; diff --git a/ts/services/senderCertificate.ts b/ts/services/senderCertificate.ts index 63a7e9c80..5f45dc2c7 100644 --- a/ts/services/senderCertificate.ts +++ b/ts/services/senderCertificate.ts @@ -12,6 +12,7 @@ import { assert } from '../util/assert'; import { missingCaseError } from '../util/missingCaseError'; import { waitForOnline } from '../util/waitForOnline'; import * as log from '../logging/log'; +import { connectToServerWithStoredCredentials } from '../util/connectToServerWithStoredCredentials'; // We define a stricter storage here that returns `unknown` instead of `any`. type Storage = { @@ -200,20 +201,7 @@ export class SenderCertificateService { 'Sender certificate service method was called before it was initialized' ); - const username = storage.get('uuid_id') || storage.get('number_id'); - const password = storage.get('password'); - if (typeof username !== 'string') { - throw new Error( - 'Sender certificate service: username in storage was not a string. Cannot connect' - ); - } - if (typeof password !== 'string') { - throw new Error( - 'Sender certificate service: password in storage was not a string. Cannot connect' - ); - } - - const server = WebAPI.connect({ username, password }); + const server = connectToServerWithStoredCredentials(WebAPI, storage); const omitE164 = mode === SenderCertificateMode.WithoutE164; const { certificate } = await server.getSenderCertificate(omitE164); return certificate; diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index 5c5211886..0cc234e2c 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -235,6 +235,7 @@ const dataInterface: ClientInterface = { getMessagesNeedingUpgrade, getMessagesWithVisualMediaAttachments, getMessagesWithFileAttachments, + getMessageServerGuidsForSpam, getJobsInQueue, insertJob, @@ -1531,6 +1532,12 @@ async function getMessagesWithFileAttachments( }); } +function getMessageServerGuidsForSpam( + conversationId: string +): Promise> { + return channels.getMessageServerGuidsForSpam(conversationId); +} + function getJobsInQueue(queueType: string): Promise> { return channels.getJobsInQueue(queueType); } diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 1362bbcd0..5b843de09 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -136,6 +136,7 @@ export type UnprocessedType = { source?: string; sourceUuid?: string; sourceDevice?: number; + serverGuid?: string; serverTimestamp?: number; decrypted?: string; }; @@ -144,6 +145,7 @@ export type UnprocessedUpdateType = { source?: string; sourceUuid?: string; sourceDevice?: string; + serverGuid?: string; serverTimestamp?: number; decrypted?: string; }; @@ -301,6 +303,9 @@ export type DataInterface = { conversationId: string, options: { limit: number } ) => Promise>; + getMessageServerGuidsForSpam: ( + conversationId: string + ) => Promise>; getJobsInQueue(queueType: string): Promise>; insertJob(job: Readonly): Promise; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 90fe67893..e0288155a 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -225,6 +225,7 @@ const dataInterface: ServerInterface = { getMessagesNeedingUpgrade, getMessagesWithVisualMediaAttachments, getMessagesWithFileAttachments, + getMessageServerGuidsForSpam, getJobsInQueue, insertJob, @@ -1834,6 +1835,25 @@ function updateToSchemaVersion31(currentVersion: number, db: Database): void { console.log('updateToSchemaVersion31: success!'); } +function updateToSchemaVersion32(currentVersion: number, db: Database) { + if (currentVersion >= 32) { + return; + } + + db.transaction(() => { + db.exec(` + ALTER TABLE messages + ADD COLUMN serverGuid STRING NULL; + + ALTER TABLE unprocessed + ADD COLUMN serverGuid STRING NULL; + `); + + db.pragma('user_version = 32'); + })(); + console.log('updateToSchemaVersion32: success!'); +} + const SCHEMA_VERSIONS = [ updateToSchemaVersion1, updateToSchemaVersion2, @@ -1866,6 +1886,7 @@ const SCHEMA_VERSIONS = [ updateToSchemaVersion29, updateToSchemaVersion30, updateToSchemaVersion31, + updateToSchemaVersion32, ]; function updateSchema(db: Database): void { @@ -2934,6 +2955,7 @@ function saveMessageSync( received_at, schemaVersion, sent_at, + serverGuid, source, sourceUuid, sourceDevice, @@ -2959,6 +2981,7 @@ function saveMessageSync( isViewOnce: isViewOnce ? 1 : 0, received_at: received_at || null, schemaVersion, + serverGuid: serverGuid || null, sent_at: sent_at || null, source: source || null, sourceUuid: sourceUuid || null, @@ -2987,6 +3010,7 @@ function saveMessageSync( isViewOnce = $isViewOnce, received_at = $received_at, schemaVersion = $schemaVersion, + serverGuid = $serverGuid, sent_at = $sent_at, source = $source, sourceUuid = $sourceUuid, @@ -3024,6 +3048,7 @@ function saveMessageSync( isViewOnce, received_at, schemaVersion, + serverGuid, sent_at, source, sourceUuid, @@ -3046,6 +3071,7 @@ function saveMessageSync( $isViewOnce, $received_at, $schemaVersion, + $serverGuid, $sent_at, $source, $sourceUuid, @@ -4012,6 +4038,7 @@ function saveUnprocessedSync(data: UnprocessedType): string { source, sourceUuid, sourceDevice, + serverGuid, serverTimestamp, decrypted, } = data; @@ -4031,6 +4058,7 @@ function saveUnprocessedSync(data: UnprocessedType): string { source, sourceUuid, sourceDevice, + serverGuid, serverTimestamp, decrypted ) values ( @@ -4042,6 +4070,7 @@ function saveUnprocessedSync(data: UnprocessedType): string { $source, $sourceUuid, $sourceDevice, + $serverGuid, $serverTimestamp, $decrypted ); @@ -4055,6 +4084,7 @@ function saveUnprocessedSync(data: UnprocessedType): string { source: source || null, sourceUuid: sourceUuid || null, sourceDevice: sourceDevice || null, + serverGuid: serverGuid || null, serverTimestamp: serverTimestamp || null, decrypted: decrypted || null, }); @@ -4084,7 +4114,14 @@ function updateUnprocessedWithDataSync( data: UnprocessedUpdateType ): void { const db = getInstance(); - const { source, sourceUuid, sourceDevice, serverTimestamp, decrypted } = data; + const { + source, + sourceUuid, + sourceDevice, + serverGuid, + serverTimestamp, + decrypted, + } = data; prepare( db, @@ -4093,6 +4130,7 @@ function updateUnprocessedWithDataSync( source = $source, sourceUuid = $sourceUuid, sourceDevice = $sourceDevice, + serverGuid = $serverGuid, serverTimestamp = $serverTimestamp, decrypted = $decrypted WHERE id = $id; @@ -4102,6 +4140,7 @@ function updateUnprocessedWithDataSync( source: source || null, sourceUuid: sourceUuid || null, sourceDevice: sourceDevice || null, + serverGuid: serverGuid || null, serverTimestamp: serverTimestamp || null, decrypted: decrypted || null, }); @@ -4910,6 +4949,29 @@ async function getMessagesWithFileAttachments( return map(rows, row => jsonToObject(row.json)); } +async function getMessageServerGuidsForSpam( + conversationId: string +): Promise> { + const db = getInstance(); + + // The server's maximum is 3, which is why you see `LIMIT 3` in this query. Note that we + // use `pluck` here to only get the first column! + return db + .prepare( + ` + SELECT serverGuid + FROM messages + WHERE conversationId = $conversationId + AND type = 'incoming' + AND serverGuid IS NOT NULL + ORDER BY received_at DESC, sent_at DESC + LIMIT 3; + ` + ) + .pluck(true) + .all({ conversationId }); +} + function getExternalFilesForMessage(message: MessageType): Array { const { attachments, contact, quote, preview, sticker } = message; const files: Array = []; diff --git a/ts/test-both/util/connectToServerWithStoredCredentials_test.ts b/ts/test-both/util/connectToServerWithStoredCredentials_test.ts new file mode 100644 index 000000000..b6b8ffca2 --- /dev/null +++ b/ts/test-both/util/connectToServerWithStoredCredentials_test.ts @@ -0,0 +1,90 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { assert } from 'chai'; +import * as sinon from 'sinon'; + +import { connectToServerWithStoredCredentials } from '../../util/connectToServerWithStoredCredentials'; + +describe('connectToServerWithStoredCredentials', () => { + let fakeWebApi: any; + let fakeStorage: { get: sinon.SinonStub }; + let fakeWebApiConnect: { connect: sinon.SinonStub }; + + beforeEach(() => { + fakeWebApi = {}; + fakeStorage = { get: sinon.stub() }; + fakeWebApiConnect = { connect: sinon.stub().returns(fakeWebApi) }; + }); + + it('throws if no ID is in storage', () => { + fakeStorage.get.withArgs('password').returns('swordfish'); + + assert.throws(() => { + connectToServerWithStoredCredentials(fakeWebApiConnect, fakeStorage); + }); + }); + + it('throws if the ID in storage is not a string', () => { + fakeStorage.get.withArgs('uuid_id').returns(1234); + fakeStorage.get.withArgs('password').returns('swordfish'); + + assert.throws(() => { + connectToServerWithStoredCredentials(fakeWebApiConnect, fakeStorage); + }); + }); + + it('throws if no password is in storage', () => { + fakeStorage.get.withArgs('uuid_id').returns('foo'); + + assert.throws(() => { + connectToServerWithStoredCredentials(fakeWebApiConnect, fakeStorage); + }); + }); + + it('throws if the password in storage is not a string', () => { + fakeStorage.get.withArgs('uuid_id').returns('foo'); + fakeStorage.get.withArgs('password').returns(1234); + + assert.throws(() => { + connectToServerWithStoredCredentials(fakeWebApiConnect, fakeStorage); + }); + }); + + it('connects with the UUID ID (if available) and password', () => { + fakeStorage.get.withArgs('uuid_id').returns('foo'); + fakeStorage.get.withArgs('number_id').returns('should not be used'); + fakeStorage.get.withArgs('password').returns('swordfish'); + + connectToServerWithStoredCredentials(fakeWebApiConnect, fakeStorage); + + sinon.assert.calledWith(fakeWebApiConnect.connect, { + username: 'foo', + password: 'swordfish', + }); + }); + + it('connects with the number ID (if UUID ID not available) and password', () => { + fakeStorage.get.withArgs('number_id').returns('bar'); + fakeStorage.get.withArgs('password').returns('swordfish'); + + connectToServerWithStoredCredentials(fakeWebApiConnect, fakeStorage); + + sinon.assert.calledWith(fakeWebApiConnect.connect, { + username: 'bar', + password: 'swordfish', + }); + }); + + it('returns the connected WebAPI', () => { + fakeStorage.get.withArgs('uuid_id').returns('foo'); + fakeStorage.get.withArgs('password').returns('swordfish'); + + assert.strictEqual( + connectToServerWithStoredCredentials(fakeWebApiConnect, fakeStorage), + fakeWebApi + ); + }); +}); diff --git a/ts/test-node/jobs/helpers/addReportSpamJob_test.ts b/ts/test-node/jobs/helpers/addReportSpamJob_test.ts new file mode 100644 index 000000000..04d7bbbe8 --- /dev/null +++ b/ts/test-node/jobs/helpers/addReportSpamJob_test.ts @@ -0,0 +1,73 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as sinon from 'sinon'; +import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; +import { Job } from '../../../jobs/Job'; + +import { addReportSpamJob } from '../../../jobs/helpers/addReportSpamJob'; + +describe('addReportSpamJob', () => { + let getMessageServerGuidsForSpam: sinon.SinonStub; + let jobQueue: { add: sinon.SinonStub }; + + beforeEach(() => { + getMessageServerGuidsForSpam = sinon.stub().resolves(['abc', 'xyz']); + jobQueue = { + add: sinon + .stub() + .callsFake( + async data => + new Job( + 'fake-job-id', + Date.now(), + 'fake job queue type', + data, + Promise.resolve() + ) + ), + }; + }); + + it('does nothing if the conversation lacks an E164', async () => { + await addReportSpamJob({ + conversation: getDefaultConversation({ e164: undefined }), + getMessageServerGuidsForSpam, + jobQueue, + }); + + sinon.assert.notCalled(getMessageServerGuidsForSpam); + sinon.assert.notCalled(jobQueue.add); + }); + + it("doesn't enqueue a job if there are no messages with server GUIDs", async () => { + getMessageServerGuidsForSpam.resolves([]); + + await addReportSpamJob({ + conversation: getDefaultConversation(), + getMessageServerGuidsForSpam, + jobQueue, + }); + + sinon.assert.notCalled(jobQueue.add); + }); + + it('enqueues a job', async () => { + const conversation = getDefaultConversation(); + + await addReportSpamJob({ + conversation, + getMessageServerGuidsForSpam, + jobQueue, + }); + + sinon.assert.calledOnce(getMessageServerGuidsForSpam); + sinon.assert.calledWith(getMessageServerGuidsForSpam, conversation.id); + + sinon.assert.calledOnce(jobQueue.add); + sinon.assert.calledWith(jobQueue.add, { + e164: conversation.e164, + serverGuids: ['abc', 'xyz'], + }); + }); +}); diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts index d71be78ab..838dd3d44 100644 --- a/ts/textsecure.d.ts +++ b/ts/textsecure.d.ts @@ -20,6 +20,7 @@ export type UnprocessedType = { envelope?: string; id: string; timestamp: number; + serverGuid?: string; serverTimestamp?: number; source?: string; sourceDevice?: number; diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 60aeb4209..d25d9afc8 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -838,6 +838,7 @@ class MessageReceiverInner extends EventTarget { source: envelope.source, sourceUuid: envelope.sourceUuid, sourceDevice: envelope.sourceDevice, + serverGuid: envelope.serverGuid, serverTimestamp: envelope.serverTimestamp, decrypted: MessageReceiverInner.arrayBufferToStringBase64( plaintext @@ -1561,6 +1562,7 @@ class MessageReceiverInner extends EventTarget { sourceUuid: envelope.sourceUuid, sourceDevice: envelope.sourceDevice, timestamp: envelope.timestamp.toNumber(), + serverGuid: envelope.serverGuid, serverTimestamp: envelope.serverTimestamp, unidentifiedDeliveryReceived: envelope.unidentifiedDeliveryReceived, message, diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 118ff1d3b..070f0f6e5 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -733,6 +733,7 @@ const URL_CALLS = { profile: 'v1/profile', registerCapabilities: 'v1/devices/capabilities', removeSignalingKey: 'v1/accounts/signaling_key', + reportMessage: 'v1/messages/report', signed: 'v2/keys/signed', storageManifest: 'v1/storage/manifest', storageModify: 'v1/storage/', @@ -926,6 +927,7 @@ export type WebAPIType = { registerKeys: (genKeys: KeysType) => Promise; registerSupportForUnauthenticatedDelivery: () => Promise; removeSignalingKey: () => Promise; + reportMessage: (senderE164: string, serverGuid: string) => Promise; requestVerificationSMS: (number: string) => Promise; requestVerificationVoice: (number: string) => Promise; sendMessages: ( @@ -1115,6 +1117,7 @@ export function initialize({ registerKeys, registerSupportForUnauthenticatedDelivery, removeSignalingKey, + reportMessage, requestVerificationSMS, requestVerificationVoice, sendMessages, @@ -1404,6 +1407,18 @@ export function initialize({ }); } + async function reportMessage( + senderE164: string, + serverGuid: string + ): Promise { + await _ajax({ + call: 'reportMessage', + httpType: 'POST', + urlParameters: `/${senderE164}/${serverGuid}`, + responseType: 'arraybuffer', + }); + } + async function requestVerificationSMS(number: string) { return _ajax({ call: 'accounts', diff --git a/ts/util/connectToServerWithStoredCredentials.ts b/ts/util/connectToServerWithStoredCredentials.ts new file mode 100644 index 000000000..8968646e5 --- /dev/null +++ b/ts/util/connectToServerWithStoredCredentials.ts @@ -0,0 +1,30 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { WebAPIConnectType, WebAPIType } from '../textsecure/WebAPI'; + +// We define a stricter storage here that returns `unknown` instead of `any`. +type Storage = { + get(key: string): unknown; +}; + +export function connectToServerWithStoredCredentials( + WebAPI: WebAPIConnectType, + storage: Storage +): WebAPIType { + const username = storage.get('uuid_id') || storage.get('number_id'); + if (typeof username !== 'string') { + throw new Error( + 'Username in storage was not a string. Cannot connect to WebAPI' + ); + } + + const password = storage.get('password'); + if (typeof password !== 'string') { + throw new Error( + 'Password in storage was not a string. Cannot connect to WebAPI' + ); + } + + return WebAPI.connect({ username, password }); +} diff --git a/ts/util/hooks.ts b/ts/util/hooks.ts index 04ff591f9..a6f3f534a 100644 --- a/ts/util/hooks.ts +++ b/ts/util/hooks.ts @@ -4,6 +4,7 @@ import * as React from 'react'; import { ActionCreatorsMapObject, bindActionCreators } from 'redux'; import { useDispatch } from 'react-redux'; +import { first, last, noop } from 'lodash'; export function usePrevious(initialValue: T, currentValue: T): T { const previousValueRef = React.useRef(initialValue); @@ -132,3 +133,57 @@ export function useIntersectionObserver(): [ return [setRef, intersectionObserverEntry]; } + +function getTop(element: Readonly): number { + return element.getBoundingClientRect().top; +} + +function isWrapped(element: Readonly): boolean { + if (!element) { + return false; + } + + const { children } = element; + const firstChild = first(children); + const lastChild = last(children); + + return Boolean( + firstChild && + lastChild && + firstChild !== lastChild && + getTop(firstChild) !== getTop(lastChild) + ); +} + +/** + * A hook that returns a ref (to put on your element) and a boolean. The boolean will be + * `true` if the element's children have different `top`s, and `false` otherwise. + */ +export function useHasWrapped(): [ + React.Ref, + boolean +] { + const [element, setElement] = React.useState(null); + + const [hasWrapped, setHasWrapped] = React.useState(isWrapped(element)); + + React.useEffect(() => { + if (!element) { + return noop; + } + + // We can remove this `any` when we upgrade to TypeScript 4.2+, which adds + // `ResizeObserver` type definitions. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const observer = new (window as any).ResizeObserver(() => { + setHasWrapped(isWrapped(element)); + }); + observer.observe(element); + + return () => { + observer.disconnect(); + }; + }, [element]); + + return [setElement, hasWrapped]; +} diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index af20d57e4..85042dff1 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -12,6 +12,8 @@ import { MessageModel } from '../models/messages'; import { MessageType } from '../state/ducks/conversations'; import { assert } from '../util/assert'; import { maybeParseUrl } from '../util/url'; +import { addReportSpamJob } from '../jobs/helpers/addReportSpamJob'; +import { reportSpamJobQueue } from '../jobs/reportSpamJobQueue'; type GetLinkPreviewImageResult = { data: ArrayBuffer; @@ -317,6 +319,11 @@ Whisper.AlreadyRequestedToJoinToast = Whisper.ToastView.extend({ template: () => window.i18n('GroupV2--join--already-awaiting-approval'), }); +const ReportedSpamAndBlockedToast = Whisper.ToastView.extend({ + template: () => + window.i18n('MessageRequests--block-and-report-spam-success-toast'), +}); + Whisper.ConversationLoadingScreen = Whisper.View.extend({ template: () => $('#conversation-loading-screen').html(), className: 'conversation-loading-screen', @@ -638,11 +645,8 @@ Whisper.ConversationView = Whisper.View.extend({ onDelete: () => { this.syncMessageRequestResponse('onDelete', messageRequestEnum.DELETE); }, - onBlockAndDelete: () => { - this.syncMessageRequestResponse( - 'onBlockAndDelete', - messageRequestEnum.BLOCK_AND_DELETE - ); + onBlockAndReportSpam: () => { + this.blockAndReportSpam(); }, onStartGroupMigration: () => this.startMigrationToGV2(), onCancelJoinRequest: async () => { @@ -963,11 +967,8 @@ Whisper.ConversationView = Whisper.View.extend({ onBlock: () => { this.syncMessageRequestResponse('onBlock', messageRequestEnum.BLOCK); }, - onBlockAndDelete: () => { - this.syncMessageRequestResponse( - 'onBlockAndDelete', - messageRequestEnum.BLOCK_AND_DELETE - ); + onBlockAndReportSpam: () => { + this.blockAndReportSpam(); }, onDelete: () => { this.syncMessageRequestResponse( @@ -1462,6 +1463,28 @@ Whisper.ConversationView = Whisper.View.extend({ }); }, + blockAndReportSpam(): Promise { + const messageRequestEnum = + window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type; + const { model }: { model: ConversationModel } = this; + + return this.longRunningTaskWrapper({ + name: 'blockAndReportSpam', + task: async () => { + await Promise.all([ + model.syncMessageRequestResponse(messageRequestEnum.BLOCK), + addReportSpamJob({ + conversation: model.format(), + getMessageServerGuidsForSpam: + window.Signal.Data.getMessageServerGuidsForSpam, + jobQueue: reportSpamJobQueue, + }), + ]); + this.showToast(ReportedSpamAndBlockedToast); + }, + }); + }, + getPropsForAttachmentList() { const draftAttachments = this.model.get('draftAttachments') || [];