/** * This file handles attachments for us. * If the attachment filepath is an encrypted one. It will decrypt it, cache it, and return the blob url to it. * An interval is run from time to time to cleanup old blobs loaded and not needed anymore (based on last access timestamp). * * */ import toArrayBuffer from 'to-arraybuffer'; import * as fse from 'fs-extra'; import { decryptAttachmentBuffer } from '../../types/Attachment'; import { DURATION } from '../constants'; const urlToDecryptedBlobMap = new Map< string, { decrypted: string; lastAccessTimestamp: number; forceRetain: boolean } >(); const urlToDecryptingPromise = new Map>(); export const cleanUpOldDecryptedMedias = () => { const currentTimestamp = Date.now(); let countCleaned = 0; let countKept = 0; let keptAsAvatars = 0; window?.log?.info('Starting cleaning of medias blobs...'); for (const iterator of urlToDecryptedBlobMap) { if ( iterator[1].forceRetain && iterator[1].lastAccessTimestamp < currentTimestamp - DURATION.DAYS * 7 ) { // keep forceRetained items for at most 7 days keptAsAvatars++; } else if (iterator[1].lastAccessTimestamp < currentTimestamp - DURATION.HOURS * 1) { // if the last access is older than one hour, revoke the url and remove it. URL.revokeObjectURL(iterator[1].decrypted); urlToDecryptedBlobMap.delete(iterator[0]); countCleaned++; } else { countKept++; } } window?.log?.info( `Clean medias blobs: cleaned/kept/keptAsAvatars: ${countCleaned}:${countKept}:${keptAsAvatars}` ); }; export const getDecryptedMediaUrl = async ( url: string, contentType: string, isAvatar: boolean ): Promise => { if (!url) { return url; } if (url.startsWith('blob:')) { return url; } else if ( window.Signal.Migrations.attachmentsPath && url.startsWith(window.Signal.Migrations.attachmentsPath) ) { // this is a file encoded by session on our current attachments path. // we consider the file is encrypted. // if it's not, the hook caller has to fallback to setting the img src as an url to the file instead and load it if (urlToDecryptedBlobMap.has(url)) { // refresh the last access timestamp so we keep the one being currently in use const existing = urlToDecryptedBlobMap.get(url); const existingObjUrl = existing?.decrypted as string; urlToDecryptedBlobMap.set(url, { decrypted: existingObjUrl, lastAccessTimestamp: Date.now(), forceRetain: existing?.forceRetain || false, }); // typescript does not realize that the has above makes sure the get is not undefined return existingObjUrl; } else { if (urlToDecryptingPromise.has(url)) { return urlToDecryptingPromise.get(url) as Promise; } urlToDecryptingPromise.set( url, new Promise(async resolve => { const encryptedFileContent = await fse.readFile(url); const decryptedContent = await decryptAttachmentBuffer( toArrayBuffer(encryptedFileContent) ); if (decryptedContent?.length) { const arrayBuffer = decryptedContent.buffer; const { makeObjectUrl } = window.Signal.Types.VisualAttachment; const obj = makeObjectUrl(arrayBuffer, contentType); if (!urlToDecryptedBlobMap.has(url)) { urlToDecryptedBlobMap.set(url, { decrypted: obj, lastAccessTimestamp: Date.now(), forceRetain: isAvatar, }); } urlToDecryptingPromise.delete(url); resolve(obj); return; } else { // failed to decrypt, fallback to url image loading // it might be a media we received before the update encrypting attachments locally. urlToDecryptingPromise.delete(url); resolve(url); return; } }) ); return urlToDecryptingPromise.get(url) as Promise; } } else { // Not sure what we got here. Just return the file. return url; } }; /** * * Returns the already decrypted URL or null */ export const getAlreadyDecryptedMediaUrl = (url: string): string | null => { if (!url) { return null; } if (url.startsWith('blob:')) { return url; } else if ( window.Signal.Migrations.attachmentsPath && url.startsWith(window.Signal.Migrations.attachmentsPath) ) { if (urlToDecryptedBlobMap.has(url)) { const existingObjUrl = urlToDecryptedBlobMap.get(url)?.decrypted as string; return existingObjUrl; } } return null; };