Fix "copy image" context menu
This commit is contained in:
parent
b40dd2dd9c
commit
8f06df9f2a
2 changed files with 161 additions and 122 deletions
|
@ -30,6 +30,7 @@ import { isPathInside } from '../ts/util/isPathInside';
|
||||||
import { missingCaseError } from '../ts/util/missingCaseError';
|
import { missingCaseError } from '../ts/util/missingCaseError';
|
||||||
import { safeParseInteger } from '../ts/util/numbers';
|
import { safeParseInteger } from '../ts/util/numbers';
|
||||||
import { drop } from '../ts/util/drop';
|
import { drop } from '../ts/util/drop';
|
||||||
|
import { strictAssert } from '../ts/util/assert';
|
||||||
import { decryptAttachmentV2ToSink } from '../ts/AttachmentCrypto';
|
import { decryptAttachmentV2ToSink } from '../ts/AttachmentCrypto';
|
||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
|
@ -200,6 +201,12 @@ function deleteOrphanedAttachments({
|
||||||
void runSafe();
|
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({
|
export function initialize({
|
||||||
configDir,
|
configDir,
|
||||||
sql,
|
sql,
|
||||||
|
@ -212,16 +219,28 @@ export function initialize({
|
||||||
}
|
}
|
||||||
initialized = true;
|
initialized = true;
|
||||||
|
|
||||||
const attachmentsDir = getPath(configDir);
|
attachmentsDir = getPath(configDir);
|
||||||
const stickersDir = getStickersPath(configDir);
|
stickersDir = getStickersPath(configDir);
|
||||||
const tempDir = getTempPath(configDir);
|
tempDir = getTempPath(configDir);
|
||||||
const draftDir = getDraftPath(configDir);
|
draftDir = getDraftPath(configDir);
|
||||||
const avatarDataDir = getAvatarsPath(configDir);
|
avatarDataDir = getAvatarsPath(configDir);
|
||||||
|
|
||||||
ipcMain.handle(ERASE_TEMP_KEY, () => rimraf.sync(tempDir));
|
ipcMain.handle(ERASE_TEMP_KEY, () => {
|
||||||
ipcMain.handle(ERASE_ATTACHMENTS_KEY, () => rimraf.sync(attachmentsDir));
|
strictAssert(tempDir != null, 'not initialized');
|
||||||
ipcMain.handle(ERASE_STICKERS_KEY, () => rimraf.sync(stickersDir));
|
rimraf.sync(tempDir);
|
||||||
ipcMain.handle(ERASE_DRAFTS_KEY, () => rimraf.sync(draftDir));
|
});
|
||||||
|
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 () => {
|
ipcMain.handle(CLEANUP_ORPHANED_ATTACHMENTS_KEY, async () => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
@ -230,111 +249,119 @@ export function initialize({
|
||||||
console.log(`cleanupOrphanedAttachments: took ${duration}ms`);
|
console.log(`cleanupOrphanedAttachments: took ${duration}ms`);
|
||||||
});
|
});
|
||||||
|
|
||||||
protocol.handle('attachment', async req => {
|
protocol.handle('attachment', handleAttachmentRequest);
|
||||||
const url = new URL(req.url);
|
}
|
||||||
if (url.host !== 'v1' && url.host !== 'v2') {
|
|
||||||
return new Response('Unknown host', { status: 404 });
|
export async function handleAttachmentRequest(req: Request): Promise<Response> {
|
||||||
|
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<typeof dispositionSchema> = '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
|
// Legacy plaintext attachments
|
||||||
let disposition: z.infer<typeof dispositionSchema> = 'attachment';
|
if (url.host === 'v1') {
|
||||||
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<void> {
|
|
||||||
try {
|
|
||||||
await decryptAttachmentV2ToSink(
|
|
||||||
{
|
|
||||||
ciphertextPath: path,
|
|
||||||
idForLogging: 'attachment_channel',
|
|
||||||
keysBase64,
|
|
||||||
size,
|
|
||||||
|
|
||||||
isLocal: true,
|
|
||||||
},
|
|
||||||
plaintext
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
plaintext.emit('error', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
drop(runSafe());
|
|
||||||
|
|
||||||
return handleRangeRequest({
|
return handleRangeRequest({
|
||||||
request: req,
|
request: req,
|
||||||
size: maybeSize,
|
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<void> {
|
||||||
|
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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
|
|
||||||
import type { BrowserWindow } from 'electron';
|
import type { BrowserWindow } from 'electron';
|
||||||
import { Menu, clipboard, nativeImage } from 'electron';
|
import { Menu, clipboard, nativeImage } from 'electron';
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import * as LocaleMatcher from '@formatjs/intl-localematcher';
|
import * as LocaleMatcher from '@formatjs/intl-localematcher';
|
||||||
|
|
||||||
import { maybeParseUrl } from '../ts/util/url';
|
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 type { LocalizerType } from '../ts/types/Util';
|
||||||
import { strictAssert } from '../ts/util/assert';
|
import { strictAssert } from '../ts/util/assert';
|
||||||
import type { LoggerType } from '../ts/types/Logging';
|
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
|
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');
|
label = i18n('icu:contextMenuCopyLink');
|
||||||
} else if (isImage) {
|
} else if (isImage) {
|
||||||
const urlIsViewOnce =
|
click = async () => {
|
||||||
params.srcURL?.includes('/temp/') ||
|
|
||||||
params.srcURL?.includes('\\temp\\');
|
|
||||||
if (urlIsViewOnce) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
click = () => {
|
|
||||||
const parsedSrcUrl = maybeParseUrl(params.srcURL);
|
const parsedSrcUrl = maybeParseUrl(params.srcURL);
|
||||||
if (!parsedSrcUrl || parsedSrcUrl.protocol !== 'file:') {
|
if (!parsedSrcUrl || parsedSrcUrl.protocol !== 'attachment:') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const image = nativeImage.createFromPath(
|
const urlIsViewOnce =
|
||||||
fileURLToPath(params.srcURL)
|
parsedSrcUrl.searchParams.get('disposition') === 'temporary';
|
||||||
);
|
if (urlIsViewOnce) {
|
||||||
clipboard.writeImage(image);
|
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');
|
label = i18n('icu:contextMenuCopyImage');
|
||||||
} else {
|
} else {
|
||||||
|
|
Loading…
Reference in a new issue