From 76d8b5e375ac3d4d5a96e089767431bbdeae6d4b Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Wed, 27 Oct 2021 10:54:16 -0700 Subject: [PATCH] Get rid of `electron.remote` --- ACKNOWLEDGMENTS.md | 24 - app/attachment_channel.ts | 2 +- app/attachments.ts | 329 +------------- app/main.ts | 31 ++ js/modules/debug.js | 2 +- package.json | 1 - preload.js | 13 +- preload_test.js | 1 - sticker-creator/preload.js | 6 +- .../test-electron/windows/attachments_test.ts | 412 ++++++++---------- ts/updater/common.ts | 2 +- ts/util/attachments.ts | 50 +++ ts/util/createIPCEvents.ts | 5 +- ts/window.d.ts | 4 +- ts/windows/attachments.ts | 268 ++++++++++++ ts/windows/context.ts | 4 + 16 files changed, 563 insertions(+), 591 deletions(-) rename test/app/attachments_test.js => ts/test-electron/windows/attachments_test.ts (69%) create mode 100644 ts/util/attachments.ts create mode 100644 ts/windows/attachments.ts diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index 9495e53a1..a8f3a13df 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -3299,30 +3299,6 @@ Signal Desktop makes use of the following open source projects. (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -## tmp - - The MIT License (MIT) - - Copyright (c) 2014 KARASZI István - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - ## typeface-inter Copyright (c) 2016-2018 The Inter Project Authors (me@rsms.me) diff --git a/app/attachment_channel.ts b/app/attachment_channel.ts index 35efb1de5..e33702f08 100644 --- a/app/attachment_channel.ts +++ b/app/attachment_channel.ts @@ -8,7 +8,7 @@ import { getStickersPath, getTempPath, getDraftPath, -} from './attachments'; +} from '../ts/util/attachments'; let initialized = false; diff --git a/app/attachments.ts b/app/attachments.ts index dfb5c02df..322182b85 100644 --- a/app/attachments.ts +++ b/app/attachments.ts @@ -1,43 +1,24 @@ -// Copyright 2018-2020 Signal Messenger, LLC +// Copyright 2018-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { randomBytes } from 'crypto'; -import { basename, join, normalize, relative } from 'path'; -import { app, dialog, shell, remote } from 'electron'; +import { join, relative } from 'path'; import fastGlob from 'fast-glob'; import glob from 'glob'; import pify from 'pify'; import fse from 'fs-extra'; -import { map, isTypedArray, isString } from 'lodash'; +import { map } from 'lodash'; import normalizePath from 'normalize-path'; -import getGuid from 'uuid/v4'; -import { isPathInside } from '../ts/util/isPathInside'; -import { isWindows } from '../ts/OS'; -import { writeWindowsZoneIdentifier } from '../ts/util/windowsZoneIdentifier'; +import { + getPath, + getStickersPath, + getDraftPath, + getTempPath, + createDeleter, +} from '../ts/util/attachments'; -type FSAttrType = { - set: (path: string, attribute: string, value: string) => Promise; -}; - -let xattr: FSAttrType | undefined; - -try { - // eslint-disable-next-line max-len - // eslint-disable-next-line global-require, import/no-extraneous-dependencies, import/no-unresolved - xattr = require('fs-xattr'); -} catch (e) { - console.log('x-attr dependency did not load successfully'); -} - -const PATH = 'attachments.noindex'; -const AVATAR_PATH = 'avatars.noindex'; -const STICKER_PATH = 'stickers.noindex'; -const TEMP_PATH = 'temp'; -const DRAFT_PATH = 'drafts.noindex'; - -const getApp = () => app || remote.app; +export * from '../ts/util/attachments'; export const getAllAttachments = async ( userDataPath: string @@ -79,274 +60,11 @@ export const getBuiltInImages = async (): Promise> => { return map(files, file => relative(dir, file)); }; -const createPathGetter = (subpath: string) => ( - userDataPath: string -): string => { - if (!isString(userDataPath)) { - throw new TypeError("'userDataPath' must be a string"); - } - return join(userDataPath, subpath); -}; - -export const getAvatarsPath = createPathGetter(AVATAR_PATH); -export const getDraftPath = createPathGetter(DRAFT_PATH); -export const getPath = createPathGetter(PATH); -export const getStickersPath = createPathGetter(STICKER_PATH); -export const getTempPath = createPathGetter(TEMP_PATH); - export const clearTempPath = (userDataPath: string): Promise => { const tempPath = getTempPath(userDataPath); return fse.emptyDir(tempPath); }; -export const createReader = ( - root: string -): ((relativePath: string) => Promise) => { - if (!isString(root)) { - throw new TypeError("'root' must be a path"); - } - - return async (relativePath: string): Promise => { - if (!isString(relativePath)) { - throw new TypeError("'relativePath' must be a string"); - } - - const absolutePath = join(root, relativePath); - const normalized = normalize(absolutePath); - if (!isPathInside(normalized, root)) { - throw new Error('Invalid relative path'); - } - return fse.readFile(normalized); - }; -}; - -export const createDoesExist = ( - root: string -): ((relativePath: string) => Promise) => { - if (!isString(root)) { - throw new TypeError("'root' must be a path"); - } - - return async (relativePath: string): Promise => { - if (!isString(relativePath)) { - throw new TypeError("'relativePath' must be a string"); - } - - const absolutePath = join(root, relativePath); - const normalized = normalize(absolutePath); - if (!isPathInside(normalized, root)) { - throw new Error('Invalid relative path'); - } - try { - await fse.access(normalized, fse.constants.F_OK); - return true; - } catch (error) { - return false; - } - }; -}; - -export const copyIntoAttachmentsDirectory = ( - root: string -): ((sourcePath: string) => Promise<{ path: string; size: number }>) => { - if (!isString(root)) { - throw new TypeError("'root' must be a path"); - } - - const userDataPath = getApp().getPath('userData'); - - return async ( - sourcePath: string - ): Promise<{ path: string; size: number }> => { - if (!isString(sourcePath)) { - throw new TypeError('sourcePath must be a string'); - } - - if (!isPathInside(sourcePath, userDataPath)) { - throw new Error( - "'sourcePath' must be relative to the user config directory" - ); - } - - const name = createName(); - const relativePath = getRelativePath(name); - const absolutePath = join(root, relativePath); - const normalized = normalize(absolutePath); - if (!isPathInside(normalized, root)) { - throw new Error('Invalid relative path'); - } - - await fse.ensureFile(normalized); - await fse.copy(sourcePath, normalized); - const { size } = await fse.stat(normalized); - - return { - path: relativePath, - size, - }; - }; -}; - -async function writeWithAttributes( - target: string, - data: Uint8Array -): Promise { - await fse.writeFile(target, Buffer.from(data)); - - if (process.platform === 'darwin' && xattr) { - // kLSQuarantineTypeInstantMessageAttachment - const type = '0003'; - - // Hexadecimal seconds since epoch - const timestamp = Math.trunc(Date.now() / 1000).toString(16); - - const appName = 'Signal'; - const guid = getGuid(); - - // https://ilostmynotes.blogspot.com/2012/06/gatekeeper-xprotect-and-quarantine.html - const attrValue = `${type};${timestamp};${appName};${guid}`; - - await xattr.set(target, 'com.apple.quarantine', attrValue); - } else if (isWindows()) { - // This operation may fail (see the function's comments), which is not a show-stopper. - try { - await writeWindowsZoneIdentifier(target); - } catch (err) { - console.warn('Failed to write Windows Zone.Identifier file; continuing'); - } - } -} - -export const openFileInDownloads = async (name: string): Promise => { - const shellToUse = shell || remote.shell; - const appToUse = getApp(); - - const downloadsPath = - appToUse.getPath('downloads') || appToUse.getPath('home'); - const target = join(downloadsPath, name); - - const normalized = normalize(target); - if (!isPathInside(normalized, downloadsPath)) { - throw new Error('Invalid filename!'); - } - - shellToUse.showItemInFolder(normalized); -}; - -export const saveAttachmentToDisk = async ({ - data, - name, -}: { - data: Uint8Array; - name: string; -}): Promise => { - const dialogToUse = dialog || remote.dialog; - const browserWindow = remote.getCurrentWindow(); - - const { canceled, filePath } = await dialogToUse.showSaveDialog( - browserWindow, - { - defaultPath: name, - } - ); - - if (canceled || !filePath) { - return null; - } - - await writeWithAttributes(filePath, data); - - const fileBasename = basename(filePath); - - return { - fullPath: filePath, - name: fileBasename, - }; -}; - -export const openFileInFolder = async (target: string): Promise => { - const shellToUse = shell || remote.shell; - - shellToUse.showItemInFolder(target); -}; - -export const createWriterForNew = ( - root: string -): ((bytes: Uint8Array) => Promise) => { - if (!isString(root)) { - throw new TypeError("'root' must be a path"); - } - - return async (bytes: Uint8Array) => { - if (!isTypedArray(bytes)) { - throw new TypeError("'bytes' must be a typed array"); - } - - const name = createName(); - const relativePath = getRelativePath(name); - return createWriterForExisting(root)({ - data: bytes, - path: relativePath, - }); - }; -}; - -export const createWriterForExisting = ( - root: string -): ((options: { data: Uint8Array; path: string }) => Promise) => { - if (!isString(root)) { - throw new TypeError("'root' must be a path"); - } - - return async ({ - data: bytes, - path: relativePath, - }: { - data: Uint8Array; - path: string; - }): Promise => { - if (!isString(relativePath)) { - throw new TypeError("'relativePath' must be a path"); - } - - if (!isTypedArray(bytes)) { - throw new TypeError("'arrayBuffer' must be an array buffer"); - } - - const buffer = Buffer.from(bytes); - const absolutePath = join(root, relativePath); - const normalized = normalize(absolutePath); - if (!isPathInside(normalized, root)) { - throw new Error('Invalid relative path'); - } - - await fse.ensureFile(normalized); - await fse.writeFile(normalized, buffer); - return relativePath; - }; -}; - -export const createDeleter = ( - root: string -): ((relativePath: string) => Promise) => { - if (!isString(root)) { - throw new TypeError("'root' must be a path"); - } - - return async (relativePath: string): Promise => { - if (!isString(relativePath)) { - throw new TypeError("'relativePath' must be a string"); - } - - const absolutePath = join(root, relativePath); - const normalized = normalize(absolutePath); - if (!isPathInside(normalized, root)) { - throw new Error('Invalid relative path'); - } - await fse.remove(absolutePath); - }; -}; - export const deleteAll = async ({ userDataPath, attachments, @@ -400,28 +118,3 @@ export const deleteAllDraftAttachments = async ({ console.log(`deleteAllDraftAttachments: deleted ${attachments.length} files`); }; - -export const createName = (): string => { - const buffer = randomBytes(32); - return buffer.toString('hex'); -}; - -export const getRelativePath = (name: string): string => { - if (!isString(name)) { - throw new TypeError("'name' must be a string"); - } - - const prefix = name.slice(0, 2); - return join(prefix, name); -}; - -export const createAbsolutePathGetter = (rootPath: string) => ( - relativePath: string -): string => { - const absolutePath = join(rootPath, relativePath); - const normalized = normalize(absolutePath); - if (!isPathInside(normalized, rootPath)) { - throw new Error('Invalid relative path'); - } - return normalized; -}; diff --git a/app/main.ts b/app/main.ts index 853d39bcd..97ff5f77a 100644 --- a/app/main.ts +++ b/app/main.ts @@ -310,6 +310,9 @@ function prepareUrl( serverPublicParams: config.get('serverPublicParams'), serverTrustRoot: config.get('serverTrustRoot'), appStartInitialSpellcheckSetting, + userDataPath: app.getPath('userData'), + downloadsPath: app.getPath('downloads'), + homePath: app.getPath('home'), ...moreKeys, }).href; } @@ -2063,3 +2066,31 @@ async function ensureFilePermissions(onlyFiles?: Array) { getLogger().info(`Finish ensuring permissions in ${Date.now() - start}ms`); } + +ipc.handle('get-auto-launch', async () => { + return app.getLoginItemSettings().openAtLogin; +}); + +ipc.handle('set-auto-launch', async (_event, value) => { + app.setLoginItemSettings({ openAtLogin: Boolean(value) }); +}); + +ipc.on('show-message-box', (_event, { type, message }) => { + dialog.showMessageBox({ type, message }); +}); + +ipc.on('show-item-in-folder', (_event, folder) => { + shell.showItemInFolder(folder); +}); + +ipc.handle('show-save-dialog', async (_event, { defaultPath }) => { + if (!mainWindow) { + getLogger().warn('show-save-dialog: no main window'); + + return { canceled: true }; + } + + return dialog.showSaveDialog(mainWindow, { + defaultPath, + }); +}); diff --git a/js/modules/debug.js b/js/modules/debug.js index f386e8d92..90f26e399 100644 --- a/js/modules/debug.js +++ b/js/modules/debug.js @@ -17,7 +17,7 @@ const { sample, } = require('lodash'); -const Attachments = require('../../app/attachments'); +const Attachments = require('../../ts/windows/attachments'); const Message = require('./types/message'); const { sleep } = require('../../ts/util/sleep'); diff --git a/package.json b/package.json index a97ec03d9..871db13f3 100644 --- a/package.json +++ b/package.json @@ -161,7 +161,6 @@ "sharp": "0.28.1", "split2": "4.0.0", "testcheck": "1.0.0-rc.2", - "tmp": "0.0.33", "typeface-inter": "3.10.0", "underscore": "1.12.1", "uuid": "3.3.2", diff --git a/preload.js b/preload.js index 27ecc86fb..cfb0a611c 100644 --- a/preload.js +++ b/preload.js @@ -21,9 +21,6 @@ try { const { getEnvironment, Environment } = require('./ts/environment'); const ipc = electron.ipcRenderer; - const { remote } = electron; - const { app } = remote; - const config = require('url').parse(window.location.toString(), true).query; const log = require('./ts/logging/log'); @@ -72,9 +69,11 @@ try { window.getServerPublicParams = () => config.serverPublicParams; window.getSfuUrl = () => config.sfuUrl; window.isBehindProxy = () => Boolean(config.proxyUrl); - window.getAutoLaunch = () => app.getLoginItemSettings().openAtLogin; + window.getAutoLaunch = () => { + return ipc.invoke('get-auto-launch'); + }; window.setAutoLaunch = value => { - app.setLoginItemSettings({ openAtLogin: Boolean(value) }); + return ipc.invoke('set-auto-launch', value); }; window.isBeforeVersion = (toCheck, baseVersion) => { @@ -405,7 +404,7 @@ try { window.PQueue = require('p-queue').default; const Signal = require('./js/modules/signal'); - const Attachments = require('./app/attachments'); + const Attachments = require('./ts/windows/attachments'); const { locale } = config; window.i18n = SignalContext.i18n; @@ -418,7 +417,7 @@ try { }); window.moment.locale(locale); - const userDataPath = app.getPath('userData'); + const userDataPath = SignalContext.getPath('userData'); window.baseAttachmentsPath = Attachments.getPath(userDataPath); window.baseStickersPath = Attachments.getStickersPath(userDataPath); window.baseTempPath = Attachments.getTempPath(userDataPath); diff --git a/preload_test.js b/preload_test.js index 0c8c6e856..13cbd00ea 100644 --- a/preload_test.js +++ b/preload_test.js @@ -27,7 +27,6 @@ window.test = { fastGlob, normalizePath: require('normalize-path'), fse: require('fs-extra'), - tmp: require('tmp'), path: require('path'), basePath: __dirname, attachmentsPath: window.Signal.Migrations.attachmentsPath, diff --git a/sticker-creator/preload.js b/sticker-creator/preload.js index f3a380300..921e031ce 100644 --- a/sticker-creator/preload.js +++ b/sticker-creator/preload.js @@ -2,7 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only /* global window */ -const { ipcRenderer: ipc, remote } = require('electron'); +const { ipcRenderer: ipc } = require('electron'); const sharp = require('sharp'); const pify = require('pify'); const { readFile } = require('fs'); @@ -23,8 +23,6 @@ const { SignalService: Proto } = require('../ts/protobuf'); const { getEnvironment } = require('../ts/environment'); const { createSetting } = require('../ts/util/preload'); -const { dialog } = remote; - const STICKER_SIZE = 512; const MIN_STICKER_DIMENSION = 10; const MAX_STICKER_DIMENSION = STICKER_SIZE; @@ -171,7 +169,7 @@ window.encryptAndUpload = async ( 'StickerCreator--Authentication--error' ]; - dialog.showMessageBox({ + ipc.send('show-message-box', { type: 'warning', message, }); diff --git a/test/app/attachments_test.js b/ts/test-electron/windows/attachments_test.ts similarity index 69% rename from test/app/attachments_test.js rename to ts/test-electron/windows/attachments_test.ts index ce0569540..4a7afb588 100644 --- a/test/app/attachments_test.js +++ b/ts/test-electron/windows/attachments_test.ts @@ -1,15 +1,13 @@ -// Copyright 2018-2021 Signal Messenger, LLC +// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -const fs = require('fs'); -const fse = require('fs-extra'); -const path = require('path'); -const tmp = require('tmp'); -const { assert } = require('chai'); -const { app } = require('electron'); - -const Attachments = require('../../app/attachments'); -const Bytes = require('../../ts/Bytes'); +import { assert } from 'chai'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; +import fse from 'fs-extra'; +import * as Attachments from '../../windows/attachments'; +import * as Bytes from '../../Bytes'; const PREFIX_LENGTH = 2; const NUM_SEPARATORS = 1; @@ -17,45 +15,128 @@ const NAME_LENGTH = 64; const PATH_LENGTH = PREFIX_LENGTH + NUM_SEPARATORS + NAME_LENGTH; describe('Attachments', () => { - describe('createWriterForNew', () => { - let tempRootDirectory = null; + const USER_DATA = window.SignalContext.getPath('userData'); + + let tempRootDirectory: string; + + before(() => { + tempRootDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'Signal')); + }); + + after(async () => { + await fse.remove(tempRootDirectory); + }); + + describe('createReader', () => { + it('should read file from disk', async () => { + const tempDirectory = path.join( + tempRootDirectory, + 'Attachments_createReader' + ); + + const relativePath = Attachments.getRelativePath( + Attachments.createName() + ); + const fullPath = path.join(tempDirectory, relativePath); + const input = Bytes.fromString('test string'); + + const inputBuffer = Buffer.from(input); + await fse.ensureFile(fullPath); + await fse.writeFile(fullPath, inputBuffer); + const output = await Attachments.createReader(tempDirectory)( + relativePath + ); + + assert.deepEqual(input, output); + }); + + it('throws if relative path goes higher than root', async () => { + const tempDirectory = path.join( + tempRootDirectory, + 'Attachments_createReader' + ); + + const relativePath = '../../parent'; + + await assert.isRejected( + Attachments.createReader(tempDirectory)(relativePath), + 'Invalid relative path' + ); + }); + }); + + describe('copyIntoAttachmentsDirectory', () => { + let filesToRemove: Array; + + const getFakeAttachmentsDirectory = () => { + const result = path.join( + USER_DATA, + `fake-attachments-${Date.now()}-${Math.random() + .toString() + .substring(2)}` + ); + filesToRemove.push(result); + return result; + }; + + // These tests use the `userData` path. In `electron-mocha`, these are temporary + // directories; no need to be concerned about messing with the "real" directory. before(() => { - tempRootDirectory = tmp.dirSync().name; + filesToRemove = []; }); after(async () => { - await fse.remove(tempRootDirectory); + await Promise.all(filesToRemove.map(toRemove => fse.remove(toRemove))); + filesToRemove = []; }); - it('should write file to disk and return path', async () => { - const input = Bytes.fromString('test string'); - const tempDirectory = path.join( - tempRootDirectory, - 'Attachments_createWriterForNew' + it('throws if passed a non-string', () => { + assert.throws(() => { + Attachments.copyIntoAttachmentsDirectory((1234 as unknown) as string); + }, TypeError); + assert.throws(() => { + Attachments.copyIntoAttachmentsDirectory((null as unknown) as string); + }, TypeError); + }); + + it('returns a function that rejects if the source path is not a string', async () => { + const copier = Attachments.copyIntoAttachmentsDirectory( + await getFakeAttachmentsDirectory() + ); + await assert.isRejected(copier((123 as unknown) as string)); + }); + + it('returns a function that rejects if the source path is not in the user config directory', async () => { + const copier = Attachments.copyIntoAttachmentsDirectory( + await getFakeAttachmentsDirectory() + ); + await assert.isRejected( + copier(path.join(tempRootDirectory, 'hello.txt')), + "'sourcePath' must be relative to the user config directory" + ); + }); + + it('returns a function that copies the source path into the attachments directory and returns its path and size', async () => { + const attachmentsPath = await getFakeAttachmentsDirectory(); + const someOtherPath = path.join(USER_DATA, 'somethingElse'); + await fse.outputFile(someOtherPath, 'hello world'); + filesToRemove.push(someOtherPath); + + const copier = Attachments.copyIntoAttachmentsDirectory(attachmentsPath); + const { path: relativePath, size } = await copier(someOtherPath); + + const absolutePath = path.join(attachmentsPath, relativePath); + assert.notEqual(someOtherPath, absolutePath); + assert.strictEqual( + await fs.promises.readFile(absolutePath, 'utf8'), + 'hello world' ); - const outputPath = await Attachments.createWriterForNew(tempDirectory)( - input - ); - const output = await fse.readFile(path.join(tempDirectory, outputPath)); - - assert.lengthOf(outputPath, PATH_LENGTH); - - const inputBuffer = Buffer.from(input); - assert.deepEqual(inputBuffer, output); + assert.strictEqual(size, 'hello world'.length); }); }); describe('createWriterForExisting', () => { - let tempRootDirectory = null; - before(() => { - tempRootDirectory = tmp.dirSync().name; - }); - - after(async () => { - await fse.remove(tempRootDirectory); - }); - it('should write file to disk on given path and return path', async () => { const input = Bytes.fromString('test string'); const tempDirectory = path.join( @@ -104,205 +185,23 @@ describe('Attachments', () => { }); }); - describe('createReader', () => { - let tempRootDirectory = null; - before(() => { - tempRootDirectory = tmp.dirSync().name; - }); - - after(async () => { - await fse.remove(tempRootDirectory); - }); - - it('should read file from disk', async () => { + describe('createWriterForNew', () => { + it('should write file to disk and return path', async () => { + const input = Bytes.fromString('test string'); const tempDirectory = path.join( tempRootDirectory, - 'Attachments_createReader' + 'Attachments_createWriterForNew' ); - const relativePath = Attachments.getRelativePath( - Attachments.createName() + const outputPath = await Attachments.createWriterForNew(tempDirectory)( + input ); - const fullPath = path.join(tempDirectory, relativePath); - const input = Bytes.fromString('test string'); + const output = await fse.readFile(path.join(tempDirectory, outputPath)); + + assert.lengthOf(outputPath, PATH_LENGTH); const inputBuffer = Buffer.from(input); - await fse.ensureFile(fullPath); - await fse.writeFile(fullPath, inputBuffer); - const output = await Attachments.createReader(tempDirectory)( - relativePath - ); - - assert.deepEqual(input, output); - }); - - it('throws if relative path goes higher than root', async () => { - const tempDirectory = path.join( - tempRootDirectory, - 'Attachments_createReader' - ); - - const relativePath = '../../parent'; - - try { - await Attachments.createReader(tempDirectory)(relativePath); - } catch (error) { - assert.strictEqual(error.message, 'Invalid relative path'); - return; - } - - throw new Error('Expected an error'); - }); - }); - - describe('copyIntoAttachmentsDirectory', () => { - // These tests use the `userData` path. In `electron-mocha`, these are temporary - // directories; no need to be concerned about messing with the "real" directory. - before(function thisNeeded() { - this.filesToRemove = []; - this.getFakeAttachmentsDirectory = () => { - const result = path.join( - app.getPath('userData'), - `fake-attachments-${Date.now()}-${Math.random() - .toString() - .substring(2)}` - ); - this.filesToRemove.push(result); - return result; - }; - this.getTempFile = () => { - const result = tmp.fileSync().name; - this.filesToRemove.push(result); - return result; - }; - }); - - after(async function thisNeeded() { - await Promise.all( - this.filesToRemove.map(toRemove => fse.remove(toRemove)) - ); - }); - - it('throws if passed a non-string', () => { - assert.throws(() => { - Attachments.copyIntoAttachmentsDirectory(1234); - }, TypeError); - assert.throws(() => { - Attachments.copyIntoAttachmentsDirectory(null); - }, TypeError); - }); - - it('returns a function that rejects if the source path is not a string', async function thisNeeded() { - const copier = Attachments.copyIntoAttachmentsDirectory( - await this.getFakeAttachmentsDirectory() - ); - return copier(123) - .then(() => { - assert.fail('This should never be run'); - }) - .catch(err => { - assert.instanceOf(err, TypeError); - }); - }); - - it('returns a function that rejects if the source path is not in the user config directory', async function thisNeeded() { - const copier = Attachments.copyIntoAttachmentsDirectory( - await this.getFakeAttachmentsDirectory() - ); - return copier(this.getTempFile()) - .then(() => { - assert.fail('This should never be run'); - }) - .catch(err => { - assert.instanceOf(err, Error); - assert.strictEqual( - err.message, - "'sourcePath' must be relative to the user config directory" - ); - }); - }); - - it('returns a function that copies the source path into the attachments directory and returns its path and size', async function thisNeeded() { - const attachmentsPath = await this.getFakeAttachmentsDirectory(); - const someOtherPath = path.join(app.getPath('userData'), 'somethingElse'); - await fse.outputFile(someOtherPath, 'hello world'); - this.filesToRemove.push(someOtherPath); - - const copier = Attachments.copyIntoAttachmentsDirectory(attachmentsPath); - const { path: relativePath, size } = await copier(someOtherPath); - - const absolutePath = path.join(attachmentsPath, relativePath); - assert.notEqual(someOtherPath, absolutePath); - assert.strictEqual( - await fs.promises.readFile(absolutePath, 'utf8'), - 'hello world' - ); - - assert.strictEqual(size, 'hello world'.length); - }); - }); - - describe('createDeleter', () => { - let tempRootDirectory = null; - before(() => { - tempRootDirectory = tmp.dirSync().name; - }); - - after(async () => { - await fse.remove(tempRootDirectory); - }); - - it('should delete file from disk', async () => { - const tempDirectory = path.join( - tempRootDirectory, - 'Attachments_createDeleter' - ); - - const relativePath = Attachments.getRelativePath( - Attachments.createName() - ); - const fullPath = path.join(tempDirectory, relativePath); - const input = Bytes.fromString('test string'); - - const inputBuffer = Buffer.from(input); - await fse.ensureFile(fullPath); - await fse.writeFile(fullPath, inputBuffer); - await Attachments.createDeleter(tempDirectory)(relativePath); - - const existsFile = await fse.exists(fullPath); - assert.isFalse(existsFile); - }); - - it('throws if relative path goes higher than root', async () => { - const tempDirectory = path.join( - tempRootDirectory, - 'Attachments_createDeleter' - ); - - const relativePath = '../../parent'; - - try { - await Attachments.createDeleter(tempDirectory)(relativePath); - } catch (error) { - assert.strictEqual(error.message, 'Invalid relative path'); - return; - } - - throw new Error('Expected an error'); - }); - }); - - describe('createName', () => { - it('should return random file name with correct length', () => { - assert.lengthOf(Attachments.createName(), NAME_LENGTH); - }); - }); - - describe('getRelativePath', () => { - it('should return correct path', () => { - const name = - '608ce3bc536edbf7637a6aeb6040bdfec49349140c0dd43e97c7ce263b15ff7e'; - assert.lengthOf(Attachments.getRelativePath(name), PATH_LENGTH); + assert.deepEqual(inputBuffer, output); }); }); @@ -336,4 +235,59 @@ describe('Attachments', () => { throw new Error('Expected an error'); }); }); + + describe('createName', () => { + it('should return random file name with correct length', () => { + assert.lengthOf(Attachments.createName(), NAME_LENGTH); + }); + }); + + describe('getRelativePath', () => { + it('should return correct path', () => { + const name = + '608ce3bc536edbf7637a6aeb6040bdfec49349140c0dd43e97c7ce263b15ff7e'; + assert.lengthOf(Attachments.getRelativePath(name), PATH_LENGTH); + }); + }); + + describe('createDeleter', () => { + it('should delete file from disk', async () => { + const tempDirectory = path.join( + tempRootDirectory, + 'Attachments_createDeleter' + ); + + const relativePath = Attachments.getRelativePath( + Attachments.createName() + ); + const fullPath = path.join(tempDirectory, relativePath); + const input = Bytes.fromString('test string'); + + const inputBuffer = Buffer.from(input); + await fse.ensureFile(fullPath); + await fse.writeFile(fullPath, inputBuffer); + await Attachments.createDeleter(tempDirectory)(relativePath); + + const existsFile = await fse.pathExists(fullPath); + assert.isFalse(existsFile); + }); + + it('throws if relative path goes higher than root', async () => { + const tempDirectory = path.join( + tempRootDirectory, + 'Attachments_createDeleter' + ); + + const relativePath = '../../parent'; + + try { + await Attachments.createDeleter(tempDirectory)(relativePath); + } catch (error) { + assert.strictEqual(error.message, 'Invalid relative path'); + return; + } + + throw new Error('Expected an error'); + }); + }); }); diff --git a/ts/updater/common.ts b/ts/updater/common.ts index 65b91609b..6ba0aafa9 100644 --- a/ts/updater/common.ts +++ b/ts/updater/common.ts @@ -26,7 +26,7 @@ import rimraf from 'rimraf'; import type { BrowserWindow } from 'electron'; import { app, ipcMain } from 'electron'; -import { getTempPath } from '../../app/attachments'; +import { getTempPath } from '../util/attachments'; import { DialogType } from '../types/Dialogs'; import { getUserAgent } from '../util/getUserAgent'; import { isAlpha, isBeta } from '../util/version'; diff --git a/ts/util/attachments.ts b/ts/util/attachments.ts new file mode 100644 index 000000000..430eea029 --- /dev/null +++ b/ts/util/attachments.ts @@ -0,0 +1,50 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { isString } from 'lodash'; +import { join, normalize } from 'path'; +import fse from 'fs-extra'; + +import { isPathInside } from './isPathInside'; + +const PATH = 'attachments.noindex'; +const AVATAR_PATH = 'avatars.noindex'; +const STICKER_PATH = 'stickers.noindex'; +const TEMP_PATH = 'temp'; +const DRAFT_PATH = 'drafts.noindex'; + +const createPathGetter = (subpath: string) => ( + userDataPath: string +): string => { + if (!isString(userDataPath)) { + throw new TypeError("'userDataPath' must be a string"); + } + return join(userDataPath, subpath); +}; + +export const getAvatarsPath = createPathGetter(AVATAR_PATH); +export const getDraftPath = createPathGetter(DRAFT_PATH); +export const getPath = createPathGetter(PATH); +export const getStickersPath = createPathGetter(STICKER_PATH); +export const getTempPath = createPathGetter(TEMP_PATH); + +export const createDeleter = ( + root: string +): ((relativePath: string) => Promise) => { + if (!isString(root)) { + throw new TypeError("'root' must be a path"); + } + + return async (relativePath: string): Promise => { + if (!isString(relativePath)) { + throw new TypeError("'relativePath' must be a string"); + } + + const absolutePath = join(root, relativePath); + const normalized = normalize(absolutePath); + if (!isPathInside(normalized, root)) { + throw new Error('Invalid relative path'); + } + await fse.remove(absolutePath); + }; +}; diff --git a/ts/util/createIPCEvents.ts b/ts/util/createIPCEvents.ts index df143061a..b4f355a1e 100644 --- a/ts/util/createIPCEvents.ts +++ b/ts/util/createIPCEvents.ts @@ -114,7 +114,7 @@ export type IPCEventsCallbacksType = { type ValuesWithGetters = Omit< IPCEventsValuesType, // Optional - 'mediaPermissions' | 'mediaCameraPermissions' + 'mediaPermissions' | 'mediaCameraPermissions' | 'autoLaunch' >; type ValuesWithSetters = Omit< @@ -146,6 +146,7 @@ export type IPCEventsGettersType = { } & { getMediaPermissions?: () => Promise; getMediaCameraPermissions?: () => Promise; + getAutoLaunch?: () => Promise; }; export type IPCEventsSettersType = { @@ -330,7 +331,7 @@ export function createIPCEvents( getAutoLaunch: () => window.getAutoLaunch(), setAutoLaunch: async (value: boolean) => { - window.setAutoLaunch(value); + return window.setAutoLaunch(value); }, isPhoneNumberSharingEnabled: () => isPhoneNumberSharingEnabled(), diff --git a/ts/window.d.ts b/ts/window.d.ts index facf4ddb5..285a7439c 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -170,8 +170,8 @@ declare global { imageToBlurHash: typeof imageToBlurHash; loadImage: any; isBehindProxy: () => boolean; - getAutoLaunch: () => boolean; - setAutoLaunch: (value: boolean) => void; + getAutoLaunch: () => Promise; + setAutoLaunch: (value: boolean) => Promise; PQueue: typeof PQueue; PQueueType: PQueue; diff --git a/ts/windows/attachments.ts b/ts/windows/attachments.ts new file mode 100644 index 000000000..a7a4d19a0 --- /dev/null +++ b/ts/windows/attachments.ts @@ -0,0 +1,268 @@ +// Copyright 2018-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { ipcRenderer } from 'electron'; +import { isString, isTypedArray } from 'lodash'; +import { join, normalize, basename } from 'path'; +import fse from 'fs-extra'; +import getGuid from 'uuid/v4'; + +import { getRandomBytes } from '../Crypto'; +import * as Bytes from '../Bytes'; + +import { isPathInside } from '../util/isPathInside'; +import { writeWindowsZoneIdentifier } from '../util/windowsZoneIdentifier'; +import { isWindows } from '../OS'; + +export * from '../util/attachments'; + +type FSAttrType = { + set: (path: string, attribute: string, value: string) => Promise; +}; + +let xattr: FSAttrType | undefined; + +try { + // eslint-disable-next-line max-len + // eslint-disable-next-line global-require, import/no-extraneous-dependencies, import/no-unresolved + xattr = require('fs-xattr'); +} catch (e) { + window.SignalContext.log?.info('x-attr dependency did not load successfully'); +} + +export const createReader = ( + root: string +): ((relativePath: string) => Promise) => { + if (!isString(root)) { + throw new TypeError("'root' must be a path"); + } + + return async (relativePath: string): Promise => { + if (!isString(relativePath)) { + throw new TypeError("'relativePath' must be a string"); + } + + const absolutePath = join(root, relativePath); + const normalized = normalize(absolutePath); + if (!isPathInside(normalized, root)) { + throw new Error('Invalid relative path'); + } + return fse.readFile(normalized); + }; +}; + +export const getRelativePath = (name: string): string => { + if (!isString(name)) { + throw new TypeError("'name' must be a string"); + } + + const prefix = name.slice(0, 2); + return join(prefix, name); +}; + +export const createName = (): string => { + const buffer = getRandomBytes(32); + return Bytes.toHex(buffer); +}; + +export const copyIntoAttachmentsDirectory = ( + root: string +): ((sourcePath: string) => Promise<{ path: string; size: number }>) => { + if (!isString(root)) { + throw new TypeError("'root' must be a path"); + } + + const userDataPath = window.SignalContext.getPath('userData'); + + return async ( + sourcePath: string + ): Promise<{ path: string; size: number }> => { + if (!isString(sourcePath)) { + throw new TypeError('sourcePath must be a string'); + } + + if (!isPathInside(sourcePath, userDataPath)) { + throw new Error( + "'sourcePath' must be relative to the user config directory" + ); + } + + const name = createName(); + const relativePath = getRelativePath(name); + const absolutePath = join(root, relativePath); + const normalized = normalize(absolutePath); + if (!isPathInside(normalized, root)) { + throw new Error('Invalid relative path'); + } + + await fse.ensureFile(normalized); + await fse.copy(sourcePath, normalized); + const { size } = await fse.stat(normalized); + + return { + path: relativePath, + size, + }; + }; +}; + +export const createWriterForNew = ( + root: string +): ((bytes: Uint8Array) => Promise) => { + if (!isString(root)) { + throw new TypeError("'root' must be a path"); + } + + return async (bytes: Uint8Array) => { + if (!isTypedArray(bytes)) { + throw new TypeError("'bytes' must be a typed array"); + } + + const name = createName(); + const relativePath = getRelativePath(name); + return createWriterForExisting(root)({ + data: bytes, + path: relativePath, + }); + }; +}; + +export const createWriterForExisting = ( + root: string +): ((options: { data: Uint8Array; path: string }) => Promise) => { + if (!isString(root)) { + throw new TypeError("'root' must be a path"); + } + + return async ({ + data: bytes, + path: relativePath, + }: { + data: Uint8Array; + path: string; + }): Promise => { + if (!isString(relativePath)) { + throw new TypeError("'relativePath' must be a path"); + } + + if (!isTypedArray(bytes)) { + throw new TypeError("'arrayBuffer' must be an array buffer"); + } + + const buffer = Buffer.from(bytes); + const absolutePath = join(root, relativePath); + const normalized = normalize(absolutePath); + if (!isPathInside(normalized, root)) { + throw new Error('Invalid relative path'); + } + + await fse.ensureFile(normalized); + await fse.writeFile(normalized, buffer); + return relativePath; + }; +}; + +export const createAbsolutePathGetter = (rootPath: string) => ( + relativePath: string +): string => { + const absolutePath = join(rootPath, relativePath); + const normalized = normalize(absolutePath); + if (!isPathInside(normalized, rootPath)) { + throw new Error('Invalid relative path'); + } + return normalized; +}; + +export const createDoesExist = ( + root: string +): ((relativePath: string) => Promise) => { + if (!isString(root)) { + throw new TypeError("'root' must be a path"); + } + + return async (relativePath: string): Promise => { + if (!isString(relativePath)) { + throw new TypeError("'relativePath' must be a string"); + } + + const absolutePath = join(root, relativePath); + const normalized = normalize(absolutePath); + if (!isPathInside(normalized, root)) { + throw new Error('Invalid relative path'); + } + try { + await fse.access(normalized, fse.constants.F_OK); + return true; + } catch (error) { + return false; + } + }; +}; + +export const openFileInFolder = async (target: string): Promise => { + ipcRenderer.send('show-item-in-folder', target); +}; + +const showSaveDialog = ( + defaultPath: string +): Promise<{ + canceled: boolean; + filePath?: string; +}> => { + return ipcRenderer.invoke('show-save-dialog', { defaultPath }); +}; + +async function writeWithAttributes( + target: string, + data: Uint8Array +): Promise { + await fse.writeFile(target, Buffer.from(data)); + + if (process.platform === 'darwin' && xattr) { + // kLSQuarantineTypeInstantMessageAttachment + const type = '0003'; + + // Hexadecimal seconds since epoch + const timestamp = Math.trunc(Date.now() / 1000).toString(16); + + const appName = 'Signal'; + const guid = getGuid(); + + // https://ilostmynotes.blogspot.com/2012/06/gatekeeper-xprotect-and-quarantine.html + const attrValue = `${type};${timestamp};${appName};${guid}`; + + await xattr.set(target, 'com.apple.quarantine', attrValue); + } else if (isWindows()) { + // This operation may fail (see the function's comments), which is not a show-stopper. + try { + await writeWindowsZoneIdentifier(target); + } catch (err) { + window.SignalContext.log?.warn( + 'Failed to write Windows Zone.Identifier file; continuing' + ); + } + } +} + +export const saveAttachmentToDisk = async ({ + data, + name, +}: { + data: Uint8Array; + name: string; +}): Promise => { + const { canceled, filePath } = await showSaveDialog(name); + + if (canceled || !filePath) { + return null; + } + + await writeWithAttributes(filePath, data); + + const fileBasename = basename(filePath); + + return { + fullPath: filePath, + name: fileBasename, + }; +}; diff --git a/ts/windows/context.ts b/ts/windows/context.ts index 7923d3a6d..c5d2b085b 100644 --- a/ts/windows/context.ts +++ b/ts/windows/context.ts @@ -53,6 +53,7 @@ export type SignalContextType = { getEnvironment: () => string; getNodeVersion: () => string; getVersion: () => string; + getPath: (name: 'userData' | 'home' | 'downloads') => string; i18n: LocalizerType; log: LoggerType; renderWindow?: () => void; @@ -71,6 +72,9 @@ export const SignalContext: SignalContextType = { getEnvironment, getNodeVersion: (): string => String(config.node_version), getVersion: (): string => String(config.version), + getPath: (name: 'userData' | 'home' | 'downloads'): string => { + return String(config[`${name}Path`]); + }, i18n: setupI18n(locale, localeMessages), log: window.SignalContext.log, nativeThemeListener: createNativeThemeListener(ipcRenderer, window),