signal-desktop/app/attachment_channel.ts
2024-10-02 12:03:10 -07:00

636 lines
17 KiB
TypeScript

// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { ipcMain, protocol } from 'electron';
import { createReadStream } from 'node:fs';
import { join, normalize } from 'node:path';
import { PassThrough, type Writable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { randomBytes } from 'node:crypto';
import { once } from 'node:events';
import z from 'zod';
import * as rimraf from 'rimraf';
import LRU from 'lru-cache';
import {
inferChunkSize,
DigestingWritable,
} from '@signalapp/libsignal-client/dist/incremental_mac';
import { RangeFinder, DefaultStorage } from '@indutny/range-finder';
import {
getAllAttachments,
getAllDownloads,
getAvatarsPath,
getPath,
getStickersPath,
getTempPath,
getDraftPath,
getDownloadsPath,
deleteAll as deleteAllAttachments,
deleteAllBadges,
deleteAllDownloads,
deleteStaleDownloads,
getAllStickers,
deleteAllStickers,
getAllDraftAttachments,
deleteAllDraftAttachments,
} from './attachments';
import type { MainSQL } from '../ts/sql/main';
import type { MessageAttachmentsCursorType } from '../ts/sql/Interface';
import * as Errors from '../ts/types/errors';
import {
APPLICATION_OCTET_STREAM,
MIMETypeToString,
stringToMIMEType,
} from '../ts/types/MIME';
import { sleep } from '../ts/util/sleep';
import { isPathInside } from '../ts/util/isPathInside';
import { missingCaseError } from '../ts/util/missingCaseError';
import { safeParseInteger } from '../ts/util/numbers';
import { SECOND } from '../ts/util/durations';
import { drop } from '../ts/util/drop';
import { strictAssert } from '../ts/util/assert';
import { ValidatingPassThrough } from '../ts/util/ValidatingPassThrough';
import { toWebStream } from '../ts/util/toWebStream';
import {
isImageTypeSupported,
isVideoTypeSupported,
} from '../ts/util/GoogleChrome';
import { decryptAttachmentV2ToSink } from '../ts/AttachmentCrypto';
import { parseLoose } from '../ts/util/schemas';
let initialized = false;
const ERASE_ATTACHMENTS_KEY = 'erase-attachments';
const ERASE_STICKERS_KEY = 'erase-stickers';
const ERASE_TEMP_KEY = 'erase-temp';
const ERASE_DRAFTS_KEY = 'erase-drafts';
const ERASE_DOWNLOADS_KEY = 'erase-downloads';
const CLEANUP_DOWNLOADS_KEY = 'cleanup-downloads';
const CLEANUP_ORPHANED_ATTACHMENTS_KEY = 'cleanup-orphaned-attachments';
const INTERACTIVITY_DELAY = 50;
type RangeFinderContextType = Readonly<
(
| {
type: 'ciphertext';
keysBase64: string;
size: number;
}
| {
type: 'plaintext';
}
) & {
path: string;
}
>;
type DigestLRUEntryType = Readonly<{
key: Buffer;
digest: Buffer;
}>;
const digestLRU = new LRU<string, DigestLRUEntryType>({
// The size of each entry is roughgly 8kb per digest + 32 bytes per key. We
// mostly need this cache for range requests, so keep it low.
max: 100,
});
async function safeDecryptToSink(
ctx: RangeFinderContextType,
sink: Writable
): Promise<void> {
strictAssert(ctx.type === 'ciphertext', 'Cannot decrypt plaintext');
const options = {
ciphertextPath: ctx.path,
idForLogging: 'attachment_channel',
keysBase64: ctx.keysBase64,
type: 'local' as const,
size: ctx.size,
};
try {
const chunkSize = inferChunkSize(ctx.size);
let entry = digestLRU.get(ctx.path);
if (!entry) {
const key = randomBytes(32);
const writable = new DigestingWritable(key, chunkSize);
const controller = new AbortController();
await Promise.race([
// Just use a non-existing event name to wait for an 'error'. We want
// to handle errors on `sink` while generating digest in case whole
// request get cancelled early.
once(sink, 'non-error-event', { signal: controller.signal }),
decryptAttachmentV2ToSink(options, writable),
]);
// Stop handling errors on sink
controller.abort();
entry = {
key,
digest: writable.getFinalDigest(),
};
digestLRU.set(ctx.path, entry);
}
const validator = new ValidatingPassThrough(
entry.key,
chunkSize,
entry.digest
);
await Promise.all([
decryptAttachmentV2ToSink(options, validator),
pipeline(validator, sink),
]);
} catch (error) {
// These errors happen when canceling fetch from `attachment://` urls,
// ignore them to avoid noise in the logs.
if (
error.name === 'AbortError' ||
error.code === 'ERR_STREAM_PREMATURE_CLOSE'
) {
return;
}
console.error(
'handleAttachmentRequest: decryption error',
Errors.toLogFormat(error)
);
}
}
const storage = new DefaultStorage<RangeFinderContextType>(
ctx => {
if (ctx.type === 'plaintext') {
return createReadStream(ctx.path);
}
if (ctx.type === 'ciphertext') {
const plaintext = new PassThrough();
drop(safeDecryptToSink(ctx, plaintext));
return plaintext;
}
throw missingCaseError(ctx);
},
{
maxSize: 10,
ttl: SECOND,
cacheKey: ctx => {
if (ctx.type === 'ciphertext') {
return `${ctx.type}:${ctx.path}:${ctx.size}:${ctx.keysBase64}`;
}
if (ctx.type === 'plaintext') {
return `${ctx.type}:${ctx.path}`;
}
throw missingCaseError(ctx);
},
}
);
const rangeFinder = new RangeFinder<RangeFinderContextType>(storage, {
noActiveReuse: true,
});
const dispositionSchema = z.enum([
'attachment',
'temporary',
'draft',
'sticker',
'avatarData',
]);
type DeleteOrphanedAttachmentsOptionsType = Readonly<{
orphanedAttachments: Set<string>;
orphanedDownloads: Set<string>;
sql: MainSQL;
userDataPath: string;
}>;
type CleanupOrphanedAttachmentsOptionsType = Readonly<{
sql: MainSQL;
userDataPath: string;
}>;
async function cleanupOrphanedAttachments({
sql,
userDataPath,
}: CleanupOrphanedAttachmentsOptionsType): Promise<void> {
await deleteAllBadges({
userDataPath,
pathsToKeep: await sql.sqlRead('getAllBadgeImageFileLocalPaths'),
});
const allStickers = await getAllStickers(userDataPath);
const orphanedStickers = await sql.sqlWrite(
'removeKnownStickers',
allStickers
);
await deleteAllStickers({
userDataPath,
stickers: orphanedStickers,
});
const allDraftAttachments = await getAllDraftAttachments(userDataPath);
const orphanedDraftAttachments = await sql.sqlWrite(
'removeKnownDraftAttachments',
allDraftAttachments
);
await deleteAllDraftAttachments({
userDataPath,
attachments: orphanedDraftAttachments,
});
// Delete orphaned attachments from conversations and messages.
const orphanedAttachments = new Set(await getAllAttachments(userDataPath));
console.log(
'cleanupOrphanedAttachments: found ' +
`${orphanedAttachments.size} attachments on disk`
);
const orphanedDownloads = new Set(await getAllDownloads(userDataPath));
console.log(
'cleanupOrphanedAttachments: found ' +
`${orphanedDownloads.size} downloads on disk`
);
{
const attachments: Array<string> = await sql.sqlRead(
'getKnownConversationAttachments'
);
let missing = 0;
for (const known of attachments) {
if (!orphanedAttachments.delete(known)) {
missing += 1;
}
}
console.log(
`cleanupOrphanedAttachments: found ${attachments.length} conversation ` +
`attachments (${missing} missing), ${orphanedAttachments.size} remain`
);
}
{
const downloads: Array<string> = await sql.sqlRead('getKnownDownloads');
let missing = 0;
for (const known of downloads) {
if (!orphanedDownloads.delete(known)) {
missing += 1;
}
}
console.log(
`cleanupOrphanedAttachments: found ${downloads.length} downloads ` +
`(${missing} missing), ${orphanedDownloads.size} remain`
);
}
// This call is intentionally not awaited. We block the app while running
// all fetches above to ensure that there are no in-flight attachments that
// are saved to disk, but not put into any message or conversation model yet.
deleteOrphanedAttachments({
orphanedAttachments,
orphanedDownloads,
sql,
userDataPath,
});
}
function deleteOrphanedAttachments({
orphanedAttachments,
orphanedDownloads,
sql,
userDataPath,
}: DeleteOrphanedAttachmentsOptionsType): void {
// This function *can* throw.
async function runWithPossibleException(): Promise<void> {
let cursor: MessageAttachmentsCursorType | undefined;
let totalFound = 0;
let totalMissing = 0;
let totalDownloadsFound = 0;
let totalDownloadsMissing = 0;
try {
do {
let attachments: ReadonlyArray<string>;
let downloads: ReadonlyArray<string>;
// eslint-disable-next-line no-await-in-loop
({ attachments, downloads, cursor } = await sql.sqlRead(
'getKnownMessageAttachments',
cursor
));
totalFound += attachments.length;
totalDownloadsFound += downloads.length;
for (const known of attachments) {
if (!orphanedAttachments.delete(known)) {
totalMissing += 1;
}
}
for (const known of downloads) {
if (!orphanedDownloads.delete(known)) {
totalDownloadsMissing += 1;
}
}
if (cursor === undefined) {
break;
}
// Let other SQL calls come through. There are hundreds of thousands of
// messages in the database and it might take time to go through them all.
// eslint-disable-next-line no-await-in-loop
await sleep(INTERACTIVITY_DELAY);
} while (cursor !== undefined && !cursor.done);
} finally {
if (cursor !== undefined) {
await sql.sqlRead('finishGetKnownMessageAttachments', cursor);
}
}
console.log(
`cleanupOrphanedAttachments: found ${totalFound} message ` +
`attachments, (${totalMissing} missing) ` +
`${orphanedAttachments.size} remain`
);
await deleteAllAttachments({
userDataPath,
attachments: Array.from(orphanedAttachments),
});
console.log(
`cleanupOrphanedAttachments: found ${totalDownloadsFound} downloads ` +
`(${totalDownloadsMissing} missing) ` +
`${orphanedDownloads.size} remain`
);
await deleteAllDownloads({
userDataPath,
downloads: Array.from(orphanedDownloads),
});
}
async function runSafe() {
const start = Date.now();
try {
await runWithPossibleException();
} catch (error) {
console.error(
'deleteOrphanedAttachments: error',
Errors.toLogFormat(error)
);
} finally {
const duration = Date.now() - start;
console.log(`deleteOrphanedAttachments: took ${duration}ms`);
}
}
// Intentionally not awaiting
void runSafe();
}
let attachmentsDir: string | undefined;
let stickersDir: string | undefined;
let tempDir: string | undefined;
let draftDir: string | undefined;
let downloadsDir: string | undefined;
let avatarDataDir: string | undefined;
export function initialize({
configDir,
sql,
}: {
configDir: string;
sql: MainSQL;
}): void {
if (initialized) {
throw new Error('initialize: Already initialized!');
}
initialized = true;
attachmentsDir = getPath(configDir);
stickersDir = getStickersPath(configDir);
tempDir = getTempPath(configDir);
draftDir = getDraftPath(configDir);
downloadsDir = getDownloadsPath(configDir);
avatarDataDir = getAvatarsPath(configDir);
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(ERASE_DOWNLOADS_KEY, () => {
strictAssert(downloadsDir != null, 'not initialized');
rimraf.sync(downloadsDir);
});
ipcMain.handle(CLEANUP_ORPHANED_ATTACHMENTS_KEY, async () => {
const start = Date.now();
await cleanupOrphanedAttachments({ sql, userDataPath: configDir });
const duration = Date.now() - start;
console.log(`cleanupOrphanedAttachments: took ${duration}ms`);
});
ipcMain.handle(CLEANUP_DOWNLOADS_KEY, async () => {
const start = Date.now();
await deleteStaleDownloads(configDir);
const duration = Date.now() - start;
console.log(`cleanupDownloads: took ${duration}ms`);
});
protocol.handle('attachment', handleAttachmentRequest);
}
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 = parseLoose(dispositionSchema, 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;
}
}
let context: RangeFinderContextType;
// Legacy plaintext attachments
if (url.host === 'v1') {
context = {
type: 'plaintext',
path,
};
} else {
// 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 });
}
context = {
type: 'ciphertext',
path,
keysBase64: maybeKeysBase64,
size: maybeSize,
};
}
try {
return handleRangeRequest({
request: req,
size: maybeSize,
context,
});
} catch (error) {
console.error('handleAttachmentRequest: error', Errors.toLogFormat(error));
throw error;
}
}
type HandleRangeRequestOptionsType = Readonly<{
request: Request;
size: number | undefined;
context: RangeFinderContextType;
}>;
function handleRangeRequest({
request,
size,
context,
}: HandleRangeRequestOptionsType): Response {
const url = new URL(request.url);
// Get content-type
const contentTypeParam = url.searchParams.get('contentType');
let contentType = MIMETypeToString(APPLICATION_OCTET_STREAM);
if (contentTypeParam) {
const mime = stringToMIMEType(contentTypeParam);
if (isImageTypeSupported(mime) || isVideoTypeSupported(mime)) {
contentType = MIMETypeToString(mime);
}
}
const headers: HeadersInit = {
'cache-control': 'no-cache, no-store',
'content-type': contentType,
};
if (size != null) {
headers['content-length'] = size.toString();
}
const create200Response = (): Response => {
const plaintext = rangeFinder.get(0, context);
return new Response(toWebStream(plaintext), {
status: 200,
headers,
});
};
const range = request.headers.get('range');
if (range == null) {
return create200Response();
}
// Chromium only sends open-ended ranges: "start-"
const match = range.match(/^bytes=(\d+)-$/);
if (match == null) {
console.error(`attachment_channel: invalid range header: ${range}`);
return create200Response();
}
const startParam = safeParseInteger(match[1]);
if (startParam == null) {
console.error(`attachment_channel: invalid range header: ${range}`);
return create200Response();
}
const start = Math.min(startParam, size || Infinity);
headers['content-range'] = `bytes ${start}-/${size ?? '*'}`;
if (size !== undefined) {
headers['content-length'] = (size - start).toString();
}
const stream = rangeFinder.get(start, context);
return new Response(toWebStream(stream), {
status: 206,
headers,
});
}