From 8f06df9f2a5ce5f3a9069deae8a7666a1c4e794b Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Fri, 12 Jul 2024 11:44:26 -0700 Subject: [PATCH] Fix "copy image" context menu --- app/attachment_channel.ts | 243 +++++++++++++++++++++----------------- app/spell_check.ts | 40 ++++--- 2 files changed, 161 insertions(+), 122 deletions(-) diff --git a/app/attachment_channel.ts b/app/attachment_channel.ts index a2693b462..e9f4bb7cd 100644 --- a/app/attachment_channel.ts +++ b/app/attachment_channel.ts @@ -30,6 +30,7 @@ import { isPathInside } from '../ts/util/isPathInside'; import { missingCaseError } from '../ts/util/missingCaseError'; import { safeParseInteger } from '../ts/util/numbers'; import { drop } from '../ts/util/drop'; +import { strictAssert } from '../ts/util/assert'; import { decryptAttachmentV2ToSink } from '../ts/AttachmentCrypto'; let initialized = false; @@ -200,6 +201,12 @@ function deleteOrphanedAttachments({ void runSafe(); } +let attachmentsDir: string | undefined; +let stickersDir: string | undefined; +let tempDir: string | undefined; +let draftDir: string | undefined; +let avatarDataDir: string | undefined; + export function initialize({ configDir, sql, @@ -212,16 +219,28 @@ export function initialize({ } initialized = true; - const attachmentsDir = getPath(configDir); - const stickersDir = getStickersPath(configDir); - const tempDir = getTempPath(configDir); - const draftDir = getDraftPath(configDir); - const avatarDataDir = getAvatarsPath(configDir); + attachmentsDir = getPath(configDir); + stickersDir = getStickersPath(configDir); + tempDir = getTempPath(configDir); + draftDir = getDraftPath(configDir); + avatarDataDir = getAvatarsPath(configDir); - ipcMain.handle(ERASE_TEMP_KEY, () => rimraf.sync(tempDir)); - ipcMain.handle(ERASE_ATTACHMENTS_KEY, () => rimraf.sync(attachmentsDir)); - ipcMain.handle(ERASE_STICKERS_KEY, () => rimraf.sync(stickersDir)); - ipcMain.handle(ERASE_DRAFTS_KEY, () => rimraf.sync(draftDir)); + ipcMain.handle(ERASE_TEMP_KEY, () => { + strictAssert(tempDir != null, 'not initialized'); + rimraf.sync(tempDir); + }); + ipcMain.handle(ERASE_ATTACHMENTS_KEY, () => { + strictAssert(attachmentsDir != null, 'not initialized'); + rimraf.sync(attachmentsDir); + }); + ipcMain.handle(ERASE_STICKERS_KEY, () => { + strictAssert(stickersDir != null, 'not initialized'); + rimraf.sync(stickersDir); + }); + ipcMain.handle(ERASE_DRAFTS_KEY, () => { + strictAssert(draftDir != null, 'not initialized'); + rimraf.sync(draftDir); + }); ipcMain.handle(CLEANUP_ORPHANED_ATTACHMENTS_KEY, async () => { const start = Date.now(); @@ -230,111 +249,119 @@ export function initialize({ console.log(`cleanupOrphanedAttachments: took ${duration}ms`); }); - protocol.handle('attachment', async req => { - const url = new URL(req.url); - if (url.host !== 'v1' && url.host !== 'v2') { - return new Response('Unknown host', { status: 404 }); + protocol.handle('attachment', handleAttachmentRequest); +} + +export async function handleAttachmentRequest(req: Request): Promise { + const url = new URL(req.url); + if (url.host !== 'v1' && url.host !== 'v2') { + return new Response('Unknown host', { status: 404 }); + } + + // Disposition + let disposition: z.infer = 'attachment'; + const dispositionParam = url.searchParams.get('disposition'); + if (dispositionParam != null) { + disposition = dispositionSchema.parse(dispositionParam); + } + + strictAssert(attachmentsDir != null, 'not initialized'); + strictAssert(tempDir != null, 'not initialized'); + strictAssert(draftDir != null, 'not initialized'); + strictAssert(stickersDir != null, 'not initialized'); + strictAssert(avatarDataDir != null, 'not initialized'); + + let parentDir: string; + switch (disposition) { + case 'attachment': + parentDir = attachmentsDir; + break; + case 'temporary': + parentDir = tempDir; + break; + case 'draft': + parentDir = draftDir; + break; + case 'sticker': + parentDir = stickersDir; + break; + case 'avatarData': + parentDir = avatarDataDir; + break; + default: + throw missingCaseError(disposition); + } + + // Remove first slash + const path = normalize( + join(parentDir, ...url.pathname.slice(1).split(/\//g)) + ); + if (!isPathInside(path, parentDir)) { + return new Response('Access denied', { status: 401 }); + } + + // Get attachment size to trim the padding + const sizeParam = url.searchParams.get('size'); + let maybeSize: number | undefined; + if (sizeParam != null) { + const intValue = safeParseInteger(sizeParam); + if (intValue != null) { + maybeSize = intValue; } + } - // Disposition - let disposition: z.infer = 'attachment'; - const dispositionParam = url.searchParams.get('disposition'); - if (dispositionParam != null) { - disposition = dispositionSchema.parse(dispositionParam); - } - - let parentDir: string; - switch (disposition) { - case 'attachment': - parentDir = attachmentsDir; - break; - case 'temporary': - parentDir = tempDir; - break; - case 'draft': - parentDir = draftDir; - break; - case 'sticker': - parentDir = stickersDir; - break; - case 'avatarData': - parentDir = avatarDataDir; - break; - default: - throw missingCaseError(disposition); - } - - // Remove first slash - const path = normalize( - join(parentDir, ...url.pathname.slice(1).split(/\//g)) - ); - if (!isPathInside(path, parentDir)) { - return new Response('Access denied', { status: 401 }); - } - - // Get attachment size to trim the padding - const sizeParam = url.searchParams.get('size'); - let maybeSize: number | undefined; - if (sizeParam != null) { - const intValue = safeParseInteger(sizeParam); - if (intValue != null) { - maybeSize = intValue; - } - } - - // Legacy plaintext attachments - if (url.host === 'v1') { - return handleRangeRequest({ - request: req, - size: maybeSize, - plaintext: createReadStream(path), - }); - } - - // Encrypted attachments - - // Get AES+MAC key - const maybeKeysBase64 = url.searchParams.get('key'); - if (maybeKeysBase64 == null) { - return new Response('Missing key', { status: 400 }); - } - - // Size is required for trimming padding. - if (maybeSize == null) { - return new Response('Missing size', { status: 400 }); - } - - // Pacify typescript - const size = maybeSize; - const keysBase64 = maybeKeysBase64; - - const plaintext = new PassThrough(); - - async function runSafe(): Promise { - try { - await decryptAttachmentV2ToSink( - { - ciphertextPath: path, - idForLogging: 'attachment_channel', - keysBase64, - size, - - isLocal: true, - }, - plaintext - ); - } catch (error) { - plaintext.emit('error', error); - } - } - - drop(runSafe()); - + // Legacy plaintext attachments + if (url.host === 'v1') { return handleRangeRequest({ request: req, size: maybeSize, - plaintext, + plaintext: createReadStream(path), }); + } + + // Encrypted attachments + + // Get AES+MAC key + const maybeKeysBase64 = url.searchParams.get('key'); + if (maybeKeysBase64 == null) { + return new Response('Missing key', { status: 400 }); + } + + // Size is required for trimming padding. + if (maybeSize == null) { + return new Response('Missing size', { status: 400 }); + } + + // Pacify typescript + const size = maybeSize; + const keysBase64 = maybeKeysBase64; + + const plaintext = new PassThrough(); + + async function runSafe(): Promise { + try { + await decryptAttachmentV2ToSink( + { + ciphertextPath: path, + idForLogging: 'attachment_channel', + keysBase64, + size, + + isLocal: true, + }, + plaintext + ); + } catch (error) { + plaintext.emit('error', error); + } + } + + drop(runSafe()); + + return handleRangeRequest({ + request: req, + size: maybeSize, + plaintext, }); } diff --git a/app/spell_check.ts b/app/spell_check.ts index bb80e8e96..d644f58c1 100644 --- a/app/spell_check.ts +++ b/app/spell_check.ts @@ -3,7 +3,6 @@ import type { BrowserWindow } from 'electron'; import { Menu, clipboard, nativeImage } from 'electron'; -import { fileURLToPath } from 'url'; import * as LocaleMatcher from '@formatjs/intl-localematcher'; import { maybeParseUrl } from '../ts/util/url'; @@ -12,6 +11,7 @@ import type { MenuListType } from '../ts/types/menu'; import type { LocalizerType } from '../ts/types/Util'; import { strictAssert } from '../ts/util/assert'; import type { LoggerType } from '../ts/types/Logging'; +import { handleAttachmentRequest } from './attachment_channel'; export const FAKE_DEFAULT_LOCALE = 'en-x-ignore'; // -x- is an extension space for attaching other metadata to the locale @@ -151,23 +151,35 @@ export const setup = ( }; label = i18n('icu:contextMenuCopyLink'); } else if (isImage) { - const urlIsViewOnce = - params.srcURL?.includes('/temp/') || - params.srcURL?.includes('\\temp\\'); - if (urlIsViewOnce) { - return; - } - - click = () => { + click = async () => { const parsedSrcUrl = maybeParseUrl(params.srcURL); - if (!parsedSrcUrl || parsedSrcUrl.protocol !== 'file:') { + if (!parsedSrcUrl || parsedSrcUrl.protocol !== 'attachment:') { return; } - const image = nativeImage.createFromPath( - fileURLToPath(params.srcURL) - ); - clipboard.writeImage(image); + const urlIsViewOnce = + parsedSrcUrl.searchParams.get('disposition') === 'temporary'; + if (urlIsViewOnce) { + return; + } + + const req = new Request(parsedSrcUrl, { + method: 'GET', + }); + + try { + const res = await handleAttachmentRequest(req); + if (!res.ok) { + return; + } + + const image = nativeImage.createFromBuffer( + Buffer.from(await res.arrayBuffer()) + ); + clipboard.writeImage(image); + } catch (error) { + logger.error('Failed to load image', error); + } }; label = i18n('icu:contextMenuCopyImage'); } else {