239 lines
7.0 KiB
TypeScript
239 lines
7.0 KiB
TypeScript
import { ContentType } from '@standardnotes/common'
|
|
import { EncryptionProviderInterface } from '@standardnotes/encryption'
|
|
import { PayloadEmitSource, FileItem, CreateEncryptedBackupFileContextPayload } from '@standardnotes/models'
|
|
import { ClientDisplayableError } from '@standardnotes/responses'
|
|
import {
|
|
FilesApiInterface,
|
|
FileBackupMetadataFile,
|
|
FileBackupsDevice,
|
|
FileBackupsMapping,
|
|
FileBackupRecord,
|
|
OnChunkCallback,
|
|
BackupServiceInterface,
|
|
} from '@standardnotes/files'
|
|
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
|
|
import { ItemManagerInterface } from '../Item/ItemManagerInterface'
|
|
import { AbstractService } from '../Service/AbstractService'
|
|
import { StatusServiceInterface } from '../Status/StatusServiceInterface'
|
|
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
|
|
import { log, LoggingDomain } from '../Logging'
|
|
|
|
export class FilesBackupService extends AbstractService implements BackupServiceInterface {
|
|
private itemsObserverDisposer: () => void
|
|
private pendingFiles = new Set<string>()
|
|
private mappingCache?: FileBackupsMapping['files']
|
|
|
|
constructor(
|
|
private items: ItemManagerInterface,
|
|
private api: FilesApiInterface,
|
|
private encryptor: EncryptionProviderInterface,
|
|
private device: FileBackupsDevice,
|
|
private status: StatusServiceInterface,
|
|
private crypto: PureCryptoInterface,
|
|
protected override internalEventBus: InternalEventBusInterface,
|
|
) {
|
|
super(internalEventBus)
|
|
|
|
this.itemsObserverDisposer = items.addObserver<FileItem>(ContentType.File, ({ changed, inserted, source }) => {
|
|
const applicableSources = [
|
|
PayloadEmitSource.LocalDatabaseLoaded,
|
|
PayloadEmitSource.RemoteSaved,
|
|
PayloadEmitSource.RemoteRetrieved,
|
|
]
|
|
|
|
if (applicableSources.includes(source)) {
|
|
void this.handleChangedFiles([...changed, ...inserted])
|
|
}
|
|
})
|
|
}
|
|
|
|
override deinit() {
|
|
super.deinit()
|
|
this.itemsObserverDisposer()
|
|
;(this.items as unknown) = undefined
|
|
;(this.api as unknown) = undefined
|
|
;(this.encryptor as unknown) = undefined
|
|
;(this.device as unknown) = undefined
|
|
;(this.status as unknown) = undefined
|
|
;(this.crypto as unknown) = undefined
|
|
}
|
|
|
|
public isFilesBackupsEnabled(): Promise<boolean> {
|
|
return this.device.isFilesBackupsEnabled()
|
|
}
|
|
|
|
public async enableFilesBackups(): Promise<void> {
|
|
await this.device.enableFilesBackups()
|
|
|
|
if (!(await this.isFilesBackupsEnabled())) {
|
|
return
|
|
}
|
|
|
|
this.backupAllFiles()
|
|
}
|
|
|
|
private backupAllFiles(): void {
|
|
const files = this.items.getItems<FileItem>(ContentType.File)
|
|
|
|
void this.handleChangedFiles(files)
|
|
}
|
|
|
|
public disableFilesBackups(): Promise<void> {
|
|
return this.device.disableFilesBackups()
|
|
}
|
|
|
|
public changeFilesBackupsLocation(): Promise<string | undefined> {
|
|
return this.device.changeFilesBackupsLocation()
|
|
}
|
|
|
|
public getFilesBackupsLocation(): Promise<string> {
|
|
return this.device.getFilesBackupsLocation()
|
|
}
|
|
|
|
public openFilesBackupsLocation(): Promise<void> {
|
|
return this.device.openFilesBackupsLocation()
|
|
}
|
|
|
|
private async getBackupsMappingFromDisk(): Promise<FileBackupsMapping['files']> {
|
|
const result = (await this.device.getFilesBackupsMappingFile()).files
|
|
|
|
this.mappingCache = result
|
|
|
|
return result
|
|
}
|
|
|
|
private invalidateMappingCache(): void {
|
|
this.mappingCache = undefined
|
|
}
|
|
|
|
private async getBackupsMappingFromCache(): Promise<FileBackupsMapping['files']> {
|
|
return this.mappingCache ?? (await this.getBackupsMappingFromDisk())
|
|
}
|
|
|
|
public async getFileBackupInfo(file: { uuid: string }): Promise<FileBackupRecord | undefined> {
|
|
const mapping = await this.getBackupsMappingFromCache()
|
|
const record = mapping[file.uuid]
|
|
return record
|
|
}
|
|
|
|
public async openFileBackup(record: FileBackupRecord): Promise<void> {
|
|
await this.device.openFileBackup(record)
|
|
}
|
|
|
|
private async handleChangedFiles(files: FileItem[]): Promise<void> {
|
|
if (files.length === 0) {
|
|
return
|
|
}
|
|
|
|
if (!(await this.isFilesBackupsEnabled())) {
|
|
return
|
|
}
|
|
|
|
const mapping = await this.getBackupsMappingFromDisk()
|
|
|
|
for (const file of files) {
|
|
if (this.pendingFiles.has(file.uuid)) {
|
|
continue
|
|
}
|
|
|
|
const record = mapping[file.uuid]
|
|
|
|
if (record == undefined) {
|
|
this.pendingFiles.add(file.uuid)
|
|
|
|
await this.performBackupOperation(file)
|
|
|
|
this.pendingFiles.delete(file.uuid)
|
|
}
|
|
}
|
|
|
|
this.invalidateMappingCache()
|
|
}
|
|
|
|
async readEncryptedFileFromBackup(uuid: string, onChunk: OnChunkCallback): Promise<'success' | 'failed' | 'aborted'> {
|
|
const fileBackup = await this.getFileBackupInfo({ uuid })
|
|
|
|
if (!fileBackup) {
|
|
return 'failed'
|
|
}
|
|
|
|
const token = await this.device.getFileBackupReadToken(fileBackup)
|
|
|
|
let readMore = true
|
|
let index = 0
|
|
|
|
while (readMore) {
|
|
const { chunk, isLast, progress } = await this.device.readNextChunk(token)
|
|
|
|
await onChunk({ data: chunk, index, isLast, progress })
|
|
|
|
readMore = !isLast
|
|
|
|
index++
|
|
}
|
|
|
|
return 'success'
|
|
}
|
|
|
|
private async performBackupOperation(file: FileItem): Promise<'success' | 'failed' | 'aborted'> {
|
|
log(LoggingDomain.FilesBackups, 'Backing up file locally', file.uuid)
|
|
|
|
const messageId = this.status.addMessage(`Backing up file ${file.name}...`)
|
|
|
|
const encryptedFile = await this.encryptor.encryptSplitSingle({
|
|
usesItemsKeyWithKeyLookup: {
|
|
items: [file.payload],
|
|
},
|
|
})
|
|
|
|
const itemsKey = this.items.getDisplayableItemsKeys().find((k) => k.uuid === encryptedFile.items_key_id)
|
|
|
|
if (!itemsKey) {
|
|
return 'failed'
|
|
}
|
|
|
|
const encryptedItemsKey = await this.encryptor.encryptSplitSingle({
|
|
usesRootKeyWithKeyLookup: {
|
|
items: [itemsKey.payload],
|
|
},
|
|
})
|
|
|
|
const token = await this.api.createFileValetToken(file.remoteIdentifier, 'read')
|
|
|
|
if (token instanceof ClientDisplayableError) {
|
|
return 'failed'
|
|
}
|
|
|
|
const metaFile: FileBackupMetadataFile = {
|
|
info: {
|
|
warning: 'Do not edit this file.',
|
|
information: 'The file and key data below is encrypted with your account password.',
|
|
instructions:
|
|
'Drag and drop this metadata file into the File Backups preferences pane in the Standard Notes desktop or web application interface.',
|
|
},
|
|
file: CreateEncryptedBackupFileContextPayload(encryptedFile.ejected()),
|
|
itemsKey: CreateEncryptedBackupFileContextPayload(encryptedItemsKey.ejected()),
|
|
version: '1.0.0',
|
|
}
|
|
|
|
const metaFileAsString = JSON.stringify(metaFile, null, 2)
|
|
|
|
const result = await this.device.saveFilesBackupsFile(file.uuid, metaFileAsString, {
|
|
chunkSizes: file.encryptedChunkSizes,
|
|
url: this.api.getFilesDownloadUrl(),
|
|
valetToken: token,
|
|
})
|
|
|
|
this.status.removeMessage(messageId)
|
|
|
|
if (result === 'failed') {
|
|
const failMessageId = this.status.addMessage(`Failed to back up ${file.name}...`)
|
|
setTimeout(() => {
|
|
this.status.removeMessage(failMessageId)
|
|
}, 2000)
|
|
}
|
|
|
|
return result
|
|
}
|
|
}
|