diff --git a/stylesheets/components/Preferences.scss b/stylesheets/components/Preferences.scss index 62a86147e42..2974064a1b7 100644 --- a/stylesheets/components/Preferences.scss +++ b/stylesheets/components/Preferences.scss @@ -1462,3 +1462,9 @@ $secondary-text-color: light-dark( max-width: 360px; } } + +.Preferences__ReadonlySqlPlayground__Textarea { + &__input { + font-family: variables.$monospace; + } +} diff --git a/ts/components/Input.tsx b/ts/components/Input.tsx index eacef3013af..94fa0023fd1 100644 --- a/ts/components/Input.tsx +++ b/ts/components/Input.tsx @@ -37,6 +37,7 @@ export type PropsType = { onFocus?: () => unknown; onEnter?: () => unknown; placeholder: string; + readOnly?: boolean; value?: string; whenToShowRemainingCount?: number; whenToWarnRemainingCount?: number; @@ -84,6 +85,7 @@ export const Input = forwardRef< onFocus, onEnter, placeholder, + readOnly, value = '', whenToShowRemainingCount = Infinity, whenToWarnRemainingCount = Infinity, @@ -226,6 +228,7 @@ export const Input = forwardRef< onKeyDown: handleKeyDown, onPaste: handlePaste, placeholder, + readOnly, ref: refMerger( ref, innerRef diff --git a/ts/components/Preferences.stories.tsx b/ts/components/Preferences.stories.tsx index e96af730651..6f420e5afff 100644 --- a/ts/components/Preferences.stories.tsx +++ b/ts/components/Preferences.stories.tsx @@ -497,6 +497,9 @@ export default { action('generateDonationReceiptBlob')(); return new Blob(); }, + __dangerouslyRunAbitraryReadOnlySqlQuery: async () => { + return Promise.resolve([]); + }, } satisfies PropsType, } satisfies Meta; diff --git a/ts/components/Preferences.tsx b/ts/components/Preferences.tsx index e087703e3f1..2a8af527224 100644 --- a/ts/components/Preferences.tsx +++ b/ts/components/Preferences.tsx @@ -14,6 +14,7 @@ import { isNumber, noop, partition } from 'lodash'; import classNames from 'classnames'; import * as LocaleMatcher from '@formatjs/intl-localematcher'; import type { MutableRefObject, ReactNode } from 'react'; +import type { RowType } from '@signalapp/sqlcipher'; import { Button, ButtonVariant } from './Button'; import { ChatColorPicker } from './ChatColorPicker'; import { Checkbox } from './Checkbox'; @@ -312,6 +313,9 @@ type PropsFunctionType = { onWhoCanSeeMeChange: SelectChangeHandlerType; onWhoCanFindMeChange: SelectChangeHandlerType; onZoomFactorChange: SelectChangeHandlerType; + __dangerouslyRunAbitraryReadOnlySqlQuery: ( + readonlySqlQuery: string + ) => Promise>>; // Localization i18n: LocalizerType; @@ -511,6 +515,7 @@ export function Preferences({ internalAddDonationReceipt, saveAttachmentToDisk, generateDonationReceiptBlob, + __dangerouslyRunAbitraryReadOnlySqlQuery, }: PropsType): JSX.Element { const storiesId = useId(); const themeSelectId = useId(); @@ -2198,6 +2203,9 @@ export function Preferences({ internalAddDonationReceipt={internalAddDonationReceipt} saveAttachmentToDisk={saveAttachmentToDisk} generateDonationReceiptBlob={generateDonationReceiptBlob} + __dangerouslyRunAbitraryReadOnlySqlQuery={ + __dangerouslyRunAbitraryReadOnlySqlQuery + } /> } contentsRef={settingsPaneRef} diff --git a/ts/components/PreferencesInternal.tsx b/ts/components/PreferencesInternal.tsx index 703259a43cb..8d73909be7f 100644 --- a/ts/components/PreferencesInternal.tsx +++ b/ts/components/PreferencesInternal.tsx @@ -1,10 +1,11 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useRef } from 'react'; import classNames from 'classnames'; import { v4 as uuid } from 'uuid'; +import type { RowType } from '@signalapp/sqlcipher'; import type { LocalizerType } from '../types/I18N'; import { toLogFormat } from '../types/errors'; import { formatFileSize } from '../util/formatFileSize'; @@ -19,6 +20,7 @@ import type { DonationReceipt } from '../types/Donations'; import { createLogger } from '../logging/log'; import { isStagingServer } from '../util/isStagingServer'; import { getHumanDonationAmount } from '../util/currency'; +import { AutoSizeTextArea } from './AutoSizeTextArea'; const log = createLogger('PreferencesInternal'); @@ -32,6 +34,7 @@ export function PreferencesInternal({ internalAddDonationReceipt, saveAttachmentToDisk, generateDonationReceiptBlob, + __dangerouslyRunAbitraryReadOnlySqlQuery, }: { i18n: LocalizerType; exportLocalBackup: () => Promise; @@ -51,6 +54,9 @@ export function PreferencesInternal({ receipt: DonationReceipt, i18n: LocalizerType ) => Promise; + __dangerouslyRunAbitraryReadOnlySqlQuery: ( + readonlySqlQuery: string + ) => Promise>>; }): JSX.Element { const [isExportPending, setIsExportPending] = useState(false); const [exportResult, setExportResult] = useState< @@ -68,6 +74,11 @@ export function PreferencesInternal({ BackupValidationResultType | undefined >(); + const [readOnlySqlInput, setReadOnlySqlInput] = useState(''); + const [readOnlySqlResults, setReadOnlySqlResults] = useState + > | null>(null); + const validateBackup = useCallback(async () => { setIsValidationPending(true); setValidationResult(undefined); @@ -184,6 +195,34 @@ export function PreferencesInternal({ [i18n, saveAttachmentToDisk, generateDonationReceiptBlob] ); + const handleReadonlySqlInputChange = useCallback( + (newReadonlySqlInput: string) => { + setReadOnlySqlInput(newReadonlySqlInput); + }, + [] + ); + + const prevAbortControlerRef = useRef(null); + + const handleReadOnlySqlInputSubmit = useCallback(async () => { + const controller = new AbortController(); + const { signal } = controller; + + prevAbortControlerRef.current?.abort(); + prevAbortControlerRef.current = controller; + + setReadOnlySqlResults(null); + + const result = + await __dangerouslyRunAbitraryReadOnlySqlQuery(readOnlySqlInput); + + if (signal.aborted) { + return; + } + + setReadOnlySqlResults(result); + }, [readOnlySqlInput, __dangerouslyRunAbitraryReadOnlySqlQuery]); + return (
)} + + + + + + {readOnlySqlResults != null && ( + null} + readOnly + placeholder="" + moduleClassName="Preferences__ReadonlySqlPlayground__Textarea" + /> + )} + +
); } diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 9ffc81c1ef1..ec6f4b089ae 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -1,7 +1,7 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { Database } from '@signalapp/sqlcipher'; +import type { Database, RowType } from '@signalapp/sqlcipher'; import type { ReadonlyDeep } from 'type-fest'; import { strictAssert } from '../util/assert'; @@ -894,6 +894,10 @@ type ReadableInterface = { getMessageSampleForSchemaVersion: ( version: number ) => Array; + + __dangerouslyRunAbitraryReadOnlySqlQuery: ( + readOnlySqlQuery: string + ) => ReadonlyArray>; }; type WritableInterface = { diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index b763c262cd5..d0cf38ce259 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -4,6 +4,7 @@ /* eslint-disable camelcase */ // TODO(indutny): format queries +import type { RowType } from '@signalapp/sqlcipher'; import SQL, { setLogger as setSqliteLogger } from '@signalapp/sqlcipher'; import { randomBytes } from 'crypto'; import { mkdirSync, rmSync } from 'node:fs'; @@ -477,6 +478,8 @@ export const DataReader: ServerReadableInterface = { finishPageMessages, getKnownDownloads, getKnownConversationAttachments, + + __dangerouslyRunAbitraryReadOnlySqlQuery, }; export const DataWriter: ServerWritableInterface = { @@ -8717,3 +8720,17 @@ function ensureMessageInsertTriggersAreEnabled(db: WritableDB): void { } })(); } + +function __dangerouslyRunAbitraryReadOnlySqlQuery( + db: ReadableDB, + readOnlySqlQuery: string +): ReadonlyArray> { + let results: ReadonlyArray>; + try { + db.pragma('query_only = on'); + results = db.prepare(readOnlySqlQuery).all(); + } finally { + db.pragma('query_only = off'); + } + return results; +} diff --git a/ts/state/smart/Preferences.tsx b/ts/state/smart/Preferences.tsx index 9ae834b5026..c580b23a2e2 100644 --- a/ts/state/smart/Preferences.tsx +++ b/ts/state/smart/Preferences.tsx @@ -1,7 +1,7 @@ // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { StrictMode, useEffect } from 'react'; +import React, { StrictMode, useCallback, useEffect } from 'react'; import { useSelector } from 'react-redux'; import type { AudioDevice } from '@signalapp/ringrtc'; @@ -686,6 +686,15 @@ export function SmartPreferences(): JSX.Element | null { } ); + const __dangerouslyRunAbitraryReadOnlySqlQuery = useCallback( + (readOnlySqlQuery: string) => { + return DataReader.__dangerouslyRunAbitraryReadOnlySqlQuery( + readOnlySqlQuery + ); + }, + [] + ); + if (currentLocation.tab !== NavTab.Settings) { return null; } @@ -883,6 +892,9 @@ export function SmartPreferences(): JSX.Element | null { internalAddDonationReceipt={internalAddDonationReceipt} saveAttachmentToDisk={window.Signal.Migrations.saveAttachmentToDisk} generateDonationReceiptBlob={generateDonationReceiptBlob} + __dangerouslyRunAbitraryReadOnlySqlQuery={ + __dangerouslyRunAbitraryReadOnlySqlQuery + } /> ); diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index c2326746e18..1bc948a4672 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -1501,6 +1501,13 @@ "updated": "2025-05-30T22:48:14.420Z", "reasonDetail": "For focusing the settings backup key viewer textarea" }, + { + "rule": "React-useRef", + "path": "ts/components/PreferencesInternal.tsx", + "line": " const prevAbortControlerRef = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2025-08-20T18:18:34.081Z" + }, { "rule": "React-useRef", "path": "ts/components/ProfileEditor.tsx",