// Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { PassThrough } from 'node:stream'; import { stat } from 'node:fs/promises'; import { join, relative, normalize } from 'path'; import pMap from 'p-map'; import fastGlob from 'fast-glob'; import fse from 'fs-extra'; import { map, isString } from 'lodash'; import normalizePath from 'normalize-path'; import { isPathInside } from '../ts/util/isPathInside'; import { DAY } from '../ts/util/durations'; import { isOlderThan } from '../ts/util/timestamp'; import { isNotNil } from '../ts/util/isNotNil'; import { generateKeys, decryptAttachmentV2ToSink, encryptAttachmentV2ToDisk, } from '../ts/AttachmentCrypto'; import type { LocalAttachmentV2Type } from '../ts/types/Attachment'; import * as Errors from '../ts/types/errors'; const PATH = 'attachments.noindex'; const AVATAR_PATH = 'avatars.noindex'; const BADGES_PATH = 'badges.noindex'; const STICKER_PATH = 'stickers.noindex'; const TEMP_PATH = 'temp'; const UPDATE_CACHE_PATH = 'update-cache'; const DRAFT_PATH = 'drafts.noindex'; const DOWNLOADS_PATH = 'downloads.noindex'; const CACHED_PATHS = new Map(); const FS_CONCURRENCY = 100; const createPathGetter = (subpath: string) => (userDataPath: string): string => { if (!isString(userDataPath)) { throw new TypeError("'userDataPath' must be a string"); } const naivePath = join(userDataPath, subpath); const cached = CACHED_PATHS.get(naivePath); if (cached) { return cached; } let result = naivePath; if (fse.pathExistsSync(naivePath)) { result = fse.realpathSync(naivePath); } CACHED_PATHS.set(naivePath, result); return result; }; export const getAvatarsPath = createPathGetter(AVATAR_PATH); export const getBadgesPath = createPathGetter(BADGES_PATH); export const getDraftPath = createPathGetter(DRAFT_PATH); export const getDownloadsPath = createPathGetter(DOWNLOADS_PATH); export const getPath = createPathGetter(PATH); export const getStickersPath = createPathGetter(STICKER_PATH); export const getTempPath = createPathGetter(TEMP_PATH); export const getUpdateCachePath = createPathGetter(UPDATE_CACHE_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); }; }; export const getAllAttachments = async ( userDataPath: string ): Promise> => { const dir = getPath(userDataPath); const pattern = normalizePath(join(dir, '**', '*')); const files = await fastGlob(pattern, { onlyFiles: true }); return map(files, file => relative(dir, file)); }; export const getAllDownloads = async ( userDataPath: string ): Promise> => { const dir = getDownloadsPath(userDataPath); const pattern = normalizePath(join(dir, '**', '*')); const files = await fastGlob(pattern, { onlyFiles: true }); return map(files, file => relative(dir, file)); }; const getAllBadgeImageFiles = async ( userDataPath: string ): Promise> => { const dir = getBadgesPath(userDataPath); const pattern = normalizePath(join(dir, '**', '*')); const files = await fastGlob(pattern, { onlyFiles: true }); return map(files, file => relative(dir, file)); }; export const getAllStickers = async ( userDataPath: string ): Promise> => { const dir = getStickersPath(userDataPath); const pattern = normalizePath(join(dir, '**', '*')); const files = await fastGlob(pattern, { onlyFiles: true }); return map(files, file => relative(dir, file)); }; export const getAllDraftAttachments = async ( userDataPath: string ): Promise> => { const dir = getDraftPath(userDataPath); const pattern = normalizePath(join(dir, '**', '*')); const files = await fastGlob(pattern, { onlyFiles: true }); return map(files, file => relative(dir, file)); }; export const clearTempPath = (userDataPath: string): Promise => { const tempPath = getTempPath(userDataPath); return fse.emptyDir(tempPath); }; export const deleteStaleDownloads = async ( userDataPath: string ): Promise => { const dir = getDownloadsPath(userDataPath); const files = await getAllDownloads(userDataPath); const result = await pMap( files, async file => { try { const { birthtimeMs } = await stat(join(dir, file)); if (isOlderThan(birthtimeMs, DAY)) { return file; } } catch (error) { // No longer exists if (error.code === 'ENOENT') { return; } console.error( 'deleteStaleDownloads: failed to get file stats', Errors.toLogFormat(error) ); } return undefined; }, { concurrency: FS_CONCURRENCY } ); const stale = result.filter(isNotNil); if (stale.length === 0) { return; } console.log(`deleteStaleDownloads: found ${stale.length}`); await deleteAllDownloads({ userDataPath, downloads: stale }); }; export const deleteAll = async ({ userDataPath, attachments, }: { userDataPath: string; attachments: ReadonlyArray; }): Promise => { const deleteFromDisk = createDeleter(getPath(userDataPath)); await pMap(attachments, deleteFromDisk, { concurrency: FS_CONCURRENCY }); console.log(`deleteAll: deleted ${attachments.length} files`); }; export const deleteAllDownloads = async ({ userDataPath, downloads, }: { userDataPath: string; downloads: ReadonlyArray; }): Promise => { const deleteFromDisk = createDeleter(getDownloadsPath(userDataPath)); await pMap(downloads, deleteFromDisk, { concurrency: FS_CONCURRENCY }); console.log(`deleteAllDownloads: deleted ${downloads.length} files`); }; export const deleteAllStickers = async ({ userDataPath, stickers, }: { userDataPath: string; stickers: ReadonlyArray; }): Promise => { const deleteFromDisk = createDeleter(getStickersPath(userDataPath)); await pMap(stickers, deleteFromDisk, { concurrency: FS_CONCURRENCY }); console.log(`deleteAllStickers: deleted ${stickers.length} files`); }; export const deleteAllBadges = async ({ userDataPath, pathsToKeep, }: { userDataPath: string; pathsToKeep: Set; }): Promise => { const deleteFromDisk = createDeleter(getBadgesPath(userDataPath)); let filesDeleted = 0; for (const file of await getAllBadgeImageFiles(userDataPath)) { if (!pathsToKeep.has(file)) { // eslint-disable-next-line no-await-in-loop await deleteFromDisk(file); filesDeleted += 1; } } console.log(`deleteAllBadges: deleted ${filesDeleted} files`); }; export const deleteAllDraftAttachments = async ({ userDataPath, attachments, }: { userDataPath: string; attachments: ReadonlyArray; }): Promise => { const deleteFromDisk = createDeleter(getDraftPath(userDataPath)); await pMap(attachments, deleteFromDisk, { concurrency: FS_CONCURRENCY }); console.log(`deleteAllDraftAttachments: deleted ${attachments.length} files`); }; export const readAndDecryptDataFromDisk = async ({ absolutePath, keysBase64, size, }: { absolutePath: string; keysBase64: string; size: number; }): Promise => { const sink = new PassThrough(); const chunks = new Array(); sink.on('data', chunk => chunks.push(chunk)); sink.resume(); await decryptAttachmentV2ToSink( { ciphertextPath: absolutePath, idForLogging: 'attachments/readAndDecryptDataFromDisk', keysBase64, size, type: 'local', }, sink ); return Buffer.concat(chunks); }; export const writeNewAttachmentData = async ({ data, getAbsoluteAttachmentPath, }: { data: Uint8Array; getAbsoluteAttachmentPath: (relativePath: string) => string; }): Promise => { const keys = generateKeys(); const { plaintextHash, path } = await encryptAttachmentV2ToDisk({ plaintext: { data }, getAbsoluteAttachmentPath, keys, }); return { version: 2, plaintextHash, size: data.byteLength, path, localKey: Buffer.from(keys).toString('base64'), }; };