270 lines
8.2 KiB
TypeScript
270 lines
8.2 KiB
TypeScript
// Copyright 2025 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
/* eslint-disable max-classes-per-file */
|
|
|
|
import { existsSync } from 'node:fs';
|
|
import { PassThrough } from 'node:stream';
|
|
import { constants as FS_CONSTANTS, copyFile, mkdir } from 'fs/promises';
|
|
|
|
import * as durations from '../util/durations';
|
|
import { createLogger } from '../logging/log';
|
|
|
|
import * as Errors from '../types/errors';
|
|
import { redactGenericText } from '../util/privacy';
|
|
import {
|
|
JobManager,
|
|
type JobManagerParamsType,
|
|
type JobManagerJobResultType,
|
|
} from './JobManager';
|
|
import { type BackupsService, backupsService } from '../services/backups';
|
|
import { decryptAttachmentV2ToSink } from '../AttachmentCrypto';
|
|
import {
|
|
type AttachmentLocalBackupJobType,
|
|
type CoreAttachmentLocalBackupJobType,
|
|
} from '../types/AttachmentBackup';
|
|
import { isInCall as isInCallSelector } from '../state/selectors/calling';
|
|
import { encryptAndUploadAttachment } from '../util/uploadAttachment';
|
|
import type { WebAPIType } from '../textsecure/WebAPI';
|
|
import {
|
|
getLocalBackupDirectoryForMediaName,
|
|
getLocalBackupPathForMediaName,
|
|
} from '../services/backups/util/localBackup';
|
|
|
|
const log = createLogger('AttachmentLocalBackupManager');
|
|
|
|
const MAX_CONCURRENT_JOBS = 3;
|
|
const RETRY_CONFIG = {
|
|
maxAttempts: 3,
|
|
backoffConfig: {
|
|
// 1 minute, 5 minutes, 25 minutes, every hour
|
|
multiplier: 3,
|
|
firstBackoffs: [10 * durations.SECOND],
|
|
maxBackoffTime: durations.MINUTE,
|
|
},
|
|
};
|
|
|
|
export class AttachmentLocalBackupManager extends JobManager<CoreAttachmentLocalBackupJobType> {
|
|
static #instance: AttachmentLocalBackupManager | undefined;
|
|
readonly #jobsByMediaName = new Map<string, AttachmentLocalBackupJobType>();
|
|
|
|
static defaultParams: JobManagerParamsType<CoreAttachmentLocalBackupJobType> =
|
|
{
|
|
markAllJobsInactive: AttachmentLocalBackupManager.markAllJobsInactive,
|
|
saveJob: AttachmentLocalBackupManager.saveJob,
|
|
removeJob: AttachmentLocalBackupManager.removeJob,
|
|
getNextJobs: AttachmentLocalBackupManager.getNextJobs,
|
|
runJob: runAttachmentBackupJob,
|
|
shouldHoldOffOnStartingQueuedJobs: () => {
|
|
const reduxState = window.reduxStore?.getState();
|
|
if (reduxState) {
|
|
return isInCallSelector(reduxState);
|
|
}
|
|
return false;
|
|
},
|
|
getJobId,
|
|
getJobIdForLogging,
|
|
getRetryConfig: () => RETRY_CONFIG,
|
|
maxConcurrentJobs: MAX_CONCURRENT_JOBS,
|
|
};
|
|
|
|
override logPrefix = 'AttachmentLocalBackupManager';
|
|
|
|
static get instance(): AttachmentLocalBackupManager {
|
|
if (!AttachmentLocalBackupManager.#instance) {
|
|
AttachmentLocalBackupManager.#instance = new AttachmentLocalBackupManager(
|
|
AttachmentLocalBackupManager.defaultParams
|
|
);
|
|
}
|
|
return AttachmentLocalBackupManager.#instance;
|
|
}
|
|
|
|
static get jobs(): Map<string, AttachmentLocalBackupJobType> {
|
|
return AttachmentLocalBackupManager.instance.#jobsByMediaName;
|
|
}
|
|
|
|
static async start(): Promise<void> {
|
|
log.info('starting');
|
|
await AttachmentLocalBackupManager.instance.start();
|
|
}
|
|
|
|
static async stop(): Promise<void> {
|
|
log.info('stopping');
|
|
return AttachmentLocalBackupManager.#instance?.stop();
|
|
}
|
|
|
|
static async addJob(newJob: CoreAttachmentLocalBackupJobType): Promise<void> {
|
|
return AttachmentLocalBackupManager.instance.addJob(newJob);
|
|
}
|
|
|
|
static async waitForIdle(): Promise<void> {
|
|
return AttachmentLocalBackupManager.instance.waitForIdle();
|
|
}
|
|
|
|
static async markAllJobsInactive(): Promise<void> {
|
|
for (const [mediaName, job] of AttachmentLocalBackupManager.jobs) {
|
|
AttachmentLocalBackupManager.jobs.set(mediaName, {
|
|
...job,
|
|
active: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
static async saveJob(job: AttachmentLocalBackupJobType): Promise<void> {
|
|
AttachmentLocalBackupManager.jobs.set(job.mediaName, job);
|
|
}
|
|
|
|
static async removeJob(
|
|
job: Pick<AttachmentLocalBackupJobType, 'mediaName'>
|
|
): Promise<void> {
|
|
AttachmentLocalBackupManager.jobs.delete(job.mediaName);
|
|
}
|
|
|
|
static clearAllJobs(): void {
|
|
AttachmentLocalBackupManager.jobs.clear();
|
|
}
|
|
|
|
static async getNextJobs({
|
|
limit,
|
|
timestamp,
|
|
}: {
|
|
limit: number;
|
|
timestamp: number;
|
|
}): Promise<Array<AttachmentLocalBackupJobType>> {
|
|
let countRemaining = limit;
|
|
const nextJobs: Array<AttachmentLocalBackupJobType> = [];
|
|
for (const job of AttachmentLocalBackupManager.jobs.values()) {
|
|
if (job.active || (job.retryAfter && job.retryAfter > timestamp)) {
|
|
continue;
|
|
}
|
|
|
|
nextJobs.push(job);
|
|
countRemaining -= 1;
|
|
if (countRemaining <= 0) {
|
|
break;
|
|
}
|
|
}
|
|
return nextJobs;
|
|
}
|
|
}
|
|
|
|
function getJobId(job: CoreAttachmentLocalBackupJobType): string {
|
|
return job.mediaName;
|
|
}
|
|
|
|
function getJobIdForLogging(job: CoreAttachmentLocalBackupJobType): string {
|
|
return `${redactGenericText(job.mediaName)}`;
|
|
}
|
|
|
|
/**
|
|
* Backup-specific methods
|
|
*/
|
|
class AttachmentPermanentlyMissingError extends Error {}
|
|
|
|
type RunAttachmentBackupJobDependenciesType = {
|
|
getAbsoluteAttachmentPath: typeof window.Signal.Migrations.getAbsoluteAttachmentPath;
|
|
backupMediaBatch?: WebAPIType['backupMediaBatch'];
|
|
backupsService: BackupsService;
|
|
encryptAndUploadAttachment: typeof encryptAndUploadAttachment;
|
|
decryptAttachmentV2ToSink: typeof decryptAttachmentV2ToSink;
|
|
};
|
|
|
|
export async function runAttachmentBackupJob(
|
|
job: AttachmentLocalBackupJobType,
|
|
_options: {
|
|
isLastAttempt: boolean;
|
|
abortSignal: AbortSignal;
|
|
},
|
|
dependencies: RunAttachmentBackupJobDependenciesType = {
|
|
getAbsoluteAttachmentPath:
|
|
window.Signal.Migrations.getAbsoluteAttachmentPath,
|
|
backupsService,
|
|
backupMediaBatch: window.textsecure.server?.backupMediaBatch,
|
|
encryptAndUploadAttachment,
|
|
decryptAttachmentV2ToSink,
|
|
}
|
|
): Promise<JobManagerJobResultType<CoreAttachmentLocalBackupJobType>> {
|
|
const jobIdForLogging = getJobIdForLogging(job);
|
|
const logId = `AttachmentLocalBackupManager/runAttachmentBackupJob/${jobIdForLogging}`;
|
|
try {
|
|
await runAttachmentBackupJobInner(job, dependencies);
|
|
return { status: 'finished' };
|
|
} catch (error) {
|
|
log.error(
|
|
`${logId}: Failed to backup attachment, attempt ${job.attempts}`,
|
|
Errors.toLogFormat(error)
|
|
);
|
|
|
|
if (error instanceof AttachmentPermanentlyMissingError) {
|
|
log.error(`${logId}: Attachment unable to be found, giving up on job`);
|
|
return { status: 'finished' };
|
|
}
|
|
|
|
return { status: 'retry' };
|
|
}
|
|
}
|
|
|
|
async function runAttachmentBackupJobInner(
|
|
job: AttachmentLocalBackupJobType,
|
|
dependencies: RunAttachmentBackupJobDependenciesType
|
|
): Promise<void> {
|
|
const jobIdForLogging = getJobIdForLogging(job);
|
|
const logId = `AttachmentLocalBackupManager.runAttachmentBackupJobInner(${jobIdForLogging})`;
|
|
|
|
log.info(`${logId}: starting`);
|
|
|
|
const { backupsBaseDir, mediaName } = job;
|
|
const { localKey, path, size } = job.data;
|
|
|
|
if (!path) {
|
|
throw new AttachmentPermanentlyMissingError('No path property');
|
|
}
|
|
|
|
const absolutePath = dependencies.getAbsoluteAttachmentPath(path);
|
|
if (!existsSync(absolutePath)) {
|
|
throw new AttachmentPermanentlyMissingError('No file at provided path');
|
|
}
|
|
|
|
if (!localKey) {
|
|
throw new Error('No localKey property, required for test decryption');
|
|
}
|
|
|
|
const localBackupFileDir = getLocalBackupDirectoryForMediaName({
|
|
backupsBaseDir,
|
|
mediaName,
|
|
});
|
|
await mkdir(localBackupFileDir, { recursive: true });
|
|
|
|
const localBackupFilePath = getLocalBackupPathForMediaName({
|
|
backupsBaseDir,
|
|
mediaName,
|
|
});
|
|
|
|
// TODO: Add check in local FS to prevent double backup
|
|
|
|
// File is already encrypted with localKey, so we just have to copy it to the backup dir
|
|
const attachmentPath =
|
|
window.Signal.Migrations.getAbsoluteAttachmentPath(path);
|
|
|
|
// Set COPYFILE_FICLONE for Copy on Write (OS dependent, gracefully falls back to copy)
|
|
await copyFile(
|
|
attachmentPath,
|
|
localBackupFilePath,
|
|
FS_CONSTANTS.COPYFILE_FICLONE
|
|
);
|
|
|
|
// TODO: Optimize this check -- it can be expensive to test decrypt on every export
|
|
log.info(`${logId}: Verifying file in local backup`);
|
|
const sink = new PassThrough();
|
|
sink.resume();
|
|
await decryptAttachmentV2ToSink(
|
|
{
|
|
ciphertextPath: localBackupFilePath,
|
|
idForLogging: 'AttachmentLocalBackupManager',
|
|
keysBase64: localKey,
|
|
size,
|
|
type: 'local',
|
|
},
|
|
sink
|
|
);
|
|
}
|