595 lines
19 KiB
TypeScript
595 lines
19 KiB
TypeScript
import { InternalEventInterface } from './../Internal/InternalEventInterface'
|
|
import { ApplicationStageChangedEventPayload } from '../Event/ApplicationStageChangedEventPayload'
|
|
import { ApplicationEvent } from '../Event/ApplicationEvent'
|
|
import { InternalEventHandlerInterface } from '../Internal/InternalEventHandlerInterface'
|
|
import { NoteType } from '@standardnotes/features'
|
|
import { ApplicationStage } from '../Application/ApplicationStage'
|
|
import {
|
|
PayloadEmitSource,
|
|
FileItem,
|
|
CreateEncryptedBackupFileContextPayload,
|
|
SNNote,
|
|
SNTag,
|
|
isNote,
|
|
NoteContent,
|
|
} from '@standardnotes/models'
|
|
import { ClientDisplayableError, ValetTokenOperation } from '@standardnotes/responses'
|
|
import {
|
|
FilesApiInterface,
|
|
FileBackupMetadataFile,
|
|
FileBackupsDevice,
|
|
FileBackupsMapping,
|
|
FileBackupRecord,
|
|
OnChunkCallback,
|
|
BackupServiceInterface,
|
|
DesktopWatchedDirectoriesChanges,
|
|
SuperConverterServiceInterface,
|
|
DirectoryManagerInterface,
|
|
} 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 { StorageServiceInterface } from '../Storage/StorageServiceInterface'
|
|
import { StorageKey } from '../Storage/StorageKeys'
|
|
import { SessionsClientInterface } from '../Session/SessionsClientInterface'
|
|
import { PayloadManagerInterface } from '../Payloads/PayloadManagerInterface'
|
|
import { HistoryServiceInterface } from '../History/HistoryServiceInterface'
|
|
import { ContentType } from '@standardnotes/domain-core'
|
|
import { EncryptionProviderInterface } from '../Encryption/EncryptionProviderInterface'
|
|
|
|
const PlaintextBackupsDirectoryName = 'Plaintext Backups'
|
|
export const TextBackupsDirectoryName = 'Text Backups'
|
|
export const FileBackupsDirectoryName = 'File Backups'
|
|
|
|
export class FilesBackupService
|
|
extends AbstractService
|
|
implements BackupServiceInterface, InternalEventHandlerInterface
|
|
{
|
|
private filesObserverDisposer: () => void
|
|
private notesObserverDisposer: () => void
|
|
private tagsObserverDisposer: () => void
|
|
|
|
private pendingFiles = new Set<string>()
|
|
private mappingCache?: FileBackupsMapping['files']
|
|
|
|
private markdownConverter!: SuperConverterServiceInterface
|
|
|
|
constructor(
|
|
private items: ItemManagerInterface,
|
|
private api: FilesApiInterface,
|
|
private encryptor: EncryptionProviderInterface,
|
|
private device: FileBackupsDevice,
|
|
private status: StatusServiceInterface,
|
|
private crypto: PureCryptoInterface,
|
|
private storage: StorageServiceInterface,
|
|
private session: SessionsClientInterface,
|
|
private payloads: PayloadManagerInterface,
|
|
private history: HistoryServiceInterface,
|
|
private directory: DirectoryManagerInterface,
|
|
protected override internalEventBus: InternalEventBusInterface,
|
|
) {
|
|
super(internalEventBus)
|
|
|
|
this.filesObserverDisposer = items.addObserver<FileItem>(
|
|
ContentType.TYPES.File,
|
|
({ changed, inserted, source }) => {
|
|
const applicableSources = [
|
|
PayloadEmitSource.LocalDatabaseLoaded,
|
|
PayloadEmitSource.RemoteSaved,
|
|
PayloadEmitSource.RemoteRetrieved,
|
|
]
|
|
if (applicableSources.includes(source)) {
|
|
void this.handleChangedFiles([...changed, ...inserted])
|
|
}
|
|
},
|
|
)
|
|
|
|
const noteAndTagSources = [
|
|
PayloadEmitSource.RemoteSaved,
|
|
PayloadEmitSource.RemoteRetrieved,
|
|
PayloadEmitSource.OfflineSyncSaved,
|
|
]
|
|
|
|
this.notesObserverDisposer = items.addObserver<SNNote>(ContentType.TYPES.Note, ({ changed, inserted, source }) => {
|
|
if (noteAndTagSources.includes(source)) {
|
|
void this.handleChangedNotes([...changed, ...inserted])
|
|
}
|
|
})
|
|
|
|
this.tagsObserverDisposer = items.addObserver<SNTag>(ContentType.TYPES.Tag, ({ changed, inserted, source }) => {
|
|
if (noteAndTagSources.includes(source)) {
|
|
void this.handleChangedTags([...changed, ...inserted])
|
|
}
|
|
})
|
|
}
|
|
|
|
async handleEvent(event: InternalEventInterface): Promise<void> {
|
|
if (event.type === ApplicationEvent.ApplicationStageChanged) {
|
|
const stage = (event.payload as ApplicationStageChangedEventPayload).stage
|
|
if (stage === ApplicationStage.Launched_10) {
|
|
void this.automaticallyEnableTextBackupsIfPreferenceNotSet()
|
|
}
|
|
}
|
|
}
|
|
|
|
setSuperConverter(converter: SuperConverterServiceInterface): void {
|
|
this.markdownConverter = converter
|
|
}
|
|
|
|
async importWatchedDirectoryChanges(changes: DesktopWatchedDirectoriesChanges): Promise<void> {
|
|
for (const change of changes) {
|
|
const existingItem = this.items.findItem(change.itemUuid)
|
|
if (!existingItem) {
|
|
continue
|
|
}
|
|
|
|
if (!isNote(existingItem)) {
|
|
continue
|
|
}
|
|
|
|
const newContent: NoteContent = {
|
|
...existingItem.payload.content,
|
|
preview_html: undefined,
|
|
preview_plain: undefined,
|
|
text: change.content,
|
|
}
|
|
|
|
const payloadCopy = existingItem.payload.copy({
|
|
content: newContent,
|
|
})
|
|
|
|
await this.payloads.importPayloads([payloadCopy], this.history.getHistoryMapCopy())
|
|
}
|
|
}
|
|
|
|
override deinit() {
|
|
super.deinit()
|
|
this.filesObserverDisposer()
|
|
this.notesObserverDisposer()
|
|
this.tagsObserverDisposer()
|
|
;(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
|
|
;(this.storage as unknown) = undefined
|
|
;(this.session as unknown) = undefined
|
|
}
|
|
|
|
private async automaticallyEnableTextBackupsIfPreferenceNotSet(): Promise<void> {
|
|
if (this.storage.getValue(StorageKey.TextBackupsEnabled) != undefined) {
|
|
return
|
|
}
|
|
|
|
this.storage.setValue(StorageKey.TextBackupsEnabled, true)
|
|
const documentsDir = await this.device.getUserDocumentsDirectory()
|
|
if (!documentsDir) {
|
|
return
|
|
}
|
|
|
|
const location = await this.device.joinPaths(
|
|
documentsDir,
|
|
await this.prependWorkspacePathForPath(TextBackupsDirectoryName),
|
|
)
|
|
this.storage.setValue(StorageKey.TextBackupsLocation, location)
|
|
}
|
|
|
|
openAllDirectoriesContainingBackupFiles(): void {
|
|
const fileBackupsLocation = this.getFilesBackupsLocation()
|
|
const plaintextBackupsLocation = this.getPlaintextBackupsLocation()
|
|
const textBackupsLocation = this.getTextBackupsLocation()
|
|
|
|
if (fileBackupsLocation) {
|
|
void this.directory.openLocation(fileBackupsLocation)
|
|
}
|
|
|
|
if (plaintextBackupsLocation) {
|
|
void this.directory.openLocation(plaintextBackupsLocation)
|
|
}
|
|
|
|
if (textBackupsLocation) {
|
|
void this.directory.openLocation(textBackupsLocation)
|
|
}
|
|
}
|
|
|
|
isFilesBackupsEnabled(): boolean {
|
|
return this.storage.getValue(StorageKey.FileBackupsEnabled, undefined, false)
|
|
}
|
|
|
|
getFilesBackupsLocation(): string | undefined {
|
|
return this.storage.getValue(StorageKey.FileBackupsLocation)
|
|
}
|
|
|
|
isTextBackupsEnabled(): boolean {
|
|
return this.storage.getValue(StorageKey.TextBackupsEnabled, undefined, true)
|
|
}
|
|
|
|
async prependWorkspacePathForPath(path: string): Promise<string> {
|
|
const workspacePath = this.session.getWorkspaceDisplayIdentifier()
|
|
|
|
return this.device.joinPaths(workspacePath, path)
|
|
}
|
|
|
|
async enableTextBackups(): Promise<void> {
|
|
let location = this.getTextBackupsLocation()
|
|
if (!location) {
|
|
location = await this.directory.presentDirectoryPickerForLocationChangeAndTransferOld(
|
|
await this.prependWorkspacePathForPath(TextBackupsDirectoryName),
|
|
)
|
|
if (!location) {
|
|
return
|
|
}
|
|
}
|
|
|
|
this.storage.setValue(StorageKey.TextBackupsEnabled, true)
|
|
this.storage.setValue(StorageKey.TextBackupsLocation, location)
|
|
}
|
|
|
|
disableTextBackups(): void {
|
|
this.storage.setValue(StorageKey.TextBackupsEnabled, false)
|
|
}
|
|
|
|
getTextBackupsLocation(): string | undefined {
|
|
return this.storage.getValue(StorageKey.TextBackupsLocation)
|
|
}
|
|
|
|
async openTextBackupsLocation(): Promise<void> {
|
|
const location = this.getTextBackupsLocation()
|
|
if (location) {
|
|
void this.directory.openLocation(location)
|
|
}
|
|
}
|
|
|
|
async changeTextBackupsLocation(): Promise<string | undefined> {
|
|
const oldLocation = this.getTextBackupsLocation()
|
|
const newLocation = await this.directory.presentDirectoryPickerForLocationChangeAndTransferOld(
|
|
await this.prependWorkspacePathForPath(TextBackupsDirectoryName),
|
|
oldLocation,
|
|
)
|
|
|
|
if (!newLocation) {
|
|
return undefined
|
|
}
|
|
|
|
this.storage.setValue(StorageKey.TextBackupsLocation, newLocation)
|
|
|
|
return newLocation
|
|
}
|
|
|
|
async saveTextBackupData(data: string): Promise<void> {
|
|
const location = this.getTextBackupsLocation()
|
|
if (!location) {
|
|
return
|
|
}
|
|
|
|
return this.device.saveTextBackupData(location, data)
|
|
}
|
|
|
|
isPlaintextBackupsEnabled(): boolean {
|
|
return this.storage.getValue(StorageKey.PlaintextBackupsEnabled, undefined, false)
|
|
}
|
|
|
|
public async enablePlaintextBackups(): Promise<void> {
|
|
let location = this.getPlaintextBackupsLocation()
|
|
if (!location) {
|
|
location = await this.directory.presentDirectoryPickerForLocationChangeAndTransferOld(
|
|
await this.prependWorkspacePathForPath(PlaintextBackupsDirectoryName),
|
|
)
|
|
if (!location) {
|
|
return
|
|
}
|
|
}
|
|
|
|
this.storage.setValue(StorageKey.PlaintextBackupsEnabled, true)
|
|
this.storage.setValue(StorageKey.PlaintextBackupsLocation, location)
|
|
|
|
void this.handleChangedNotes(this.items.getItems<SNNote>(ContentType.TYPES.Note))
|
|
}
|
|
|
|
disablePlaintextBackups(): void {
|
|
this.storage.setValue(StorageKey.PlaintextBackupsEnabled, false)
|
|
this.storage.setValue(StorageKey.PlaintextBackupsLocation, undefined)
|
|
}
|
|
|
|
getPlaintextBackupsLocation(): string | undefined {
|
|
return this.storage.getValue(StorageKey.PlaintextBackupsLocation)
|
|
}
|
|
|
|
async openPlaintextBackupsLocation(): Promise<void> {
|
|
const location = this.getPlaintextBackupsLocation()
|
|
if (location) {
|
|
void this.directory.openLocation(location)
|
|
}
|
|
}
|
|
|
|
async changePlaintextBackupsLocation(): Promise<string | undefined> {
|
|
const oldLocation = this.getPlaintextBackupsLocation()
|
|
const newLocation = await this.directory.presentDirectoryPickerForLocationChangeAndTransferOld(
|
|
await this.prependWorkspacePathForPath(PlaintextBackupsDirectoryName),
|
|
oldLocation,
|
|
)
|
|
|
|
if (!newLocation) {
|
|
return undefined
|
|
}
|
|
|
|
this.storage.setValue(StorageKey.PlaintextBackupsLocation, newLocation)
|
|
|
|
return newLocation
|
|
}
|
|
|
|
public async enableFilesBackups(): Promise<void> {
|
|
let location = this.getFilesBackupsLocation()
|
|
if (!location) {
|
|
location = await this.directory.presentDirectoryPickerForLocationChangeAndTransferOld(
|
|
await this.prependWorkspacePathForPath(FileBackupsDirectoryName),
|
|
)
|
|
if (!location) {
|
|
return
|
|
}
|
|
}
|
|
|
|
this.storage.setValue(StorageKey.FileBackupsEnabled, true)
|
|
this.storage.setValue(StorageKey.FileBackupsLocation, location)
|
|
|
|
this.backupAllFiles()
|
|
}
|
|
|
|
private backupAllFiles(): void {
|
|
const files = this.items.getItems<FileItem>(ContentType.TYPES.File)
|
|
|
|
void this.handleChangedFiles(files)
|
|
}
|
|
|
|
public disableFilesBackups(): void {
|
|
this.storage.setValue(StorageKey.FileBackupsEnabled, false)
|
|
}
|
|
|
|
public async changeFilesBackupsLocation(): Promise<string | undefined> {
|
|
const oldLocation = this.getFilesBackupsLocation()
|
|
const newLocation = await this.directory.presentDirectoryPickerForLocationChangeAndTransferOld(
|
|
await this.prependWorkspacePathForPath(FileBackupsDirectoryName),
|
|
oldLocation,
|
|
)
|
|
if (!newLocation) {
|
|
return undefined
|
|
}
|
|
|
|
this.storage.setValue(StorageKey.FileBackupsLocation, newLocation)
|
|
|
|
return newLocation
|
|
}
|
|
|
|
public async openFilesBackupsLocation(): Promise<void> {
|
|
const location = this.getFilesBackupsLocation()
|
|
if (location) {
|
|
void this.directory.openLocation(location)
|
|
}
|
|
}
|
|
|
|
private async getBackupsMappingFromDisk(): Promise<FileBackupsMapping['files'] | undefined> {
|
|
const location = this.getFilesBackupsLocation()
|
|
if (!location) {
|
|
return undefined
|
|
}
|
|
|
|
const result = (await this.device.getFilesBackupsMappingFile(location)).files
|
|
|
|
this.mappingCache = result
|
|
|
|
return result
|
|
}
|
|
|
|
private invalidateMappingCache(): void {
|
|
this.mappingCache = undefined
|
|
}
|
|
|
|
private async getBackupsMappingFromCache(): Promise<FileBackupsMapping['files'] | undefined> {
|
|
return this.mappingCache ?? (await this.getBackupsMappingFromDisk())
|
|
}
|
|
|
|
public async getFileBackupInfo(file: { uuid: string }): Promise<FileBackupRecord | undefined> {
|
|
const mapping = await this.getBackupsMappingFromCache()
|
|
if (!mapping) {
|
|
return undefined
|
|
}
|
|
|
|
const record = mapping[file.uuid]
|
|
return record
|
|
}
|
|
|
|
public getFileBackupAbsolutePath(record: FileBackupRecord): Promise<string> {
|
|
const location = this.getFilesBackupsLocation()
|
|
if (!location) {
|
|
throw new ClientDisplayableError('No files backups location set')
|
|
}
|
|
return this.device.joinPaths(location, record.relativePath)
|
|
}
|
|
|
|
public async openFileBackup(record: FileBackupRecord): Promise<void> {
|
|
const location = await this.getFileBackupAbsolutePath(record)
|
|
await this.directory.openLocation(location)
|
|
}
|
|
|
|
private async handleChangedFiles(files: FileItem[]): Promise<void> {
|
|
if (files.length === 0 || !this.isFilesBackupsEnabled()) {
|
|
return
|
|
}
|
|
|
|
const mapping = await this.getBackupsMappingFromDisk()
|
|
if (!mapping) {
|
|
throw new ClientDisplayableError('No backups mapping found')
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
private async handleChangedNotes(notes: SNNote[]): Promise<void> {
|
|
if (notes.length === 0 || !this.isPlaintextBackupsEnabled()) {
|
|
return
|
|
}
|
|
|
|
const location = this.getPlaintextBackupsLocation()
|
|
if (!location) {
|
|
throw new ClientDisplayableError('No plaintext backups location found')
|
|
}
|
|
|
|
if (!this.markdownConverter) {
|
|
throw 'Super markdown converter not initialized'
|
|
}
|
|
|
|
for (const note of notes) {
|
|
const tags = this.items.getSortedTagsForItem(note)
|
|
const tagNames = tags.map((tag) => this.items.getTagLongTitle(tag))
|
|
const text = note.noteType === NoteType.Super ? this.markdownConverter.convertString(note.text, 'md') : note.text
|
|
await this.device.savePlaintextNoteBackup(location, note.uuid, note.title, tagNames, text)
|
|
}
|
|
|
|
await this.device.persistPlaintextBackupsMappingFile(location)
|
|
}
|
|
|
|
private async handleChangedTags(tags: SNTag[]): Promise<void> {
|
|
if (tags.length === 0 || !this.isPlaintextBackupsEnabled()) {
|
|
return
|
|
}
|
|
|
|
for (const tag of tags) {
|
|
const notes = this.items.referencesForItem<SNNote>(tag, ContentType.TYPES.Note)
|
|
await this.handleChangedNotes(notes)
|
|
}
|
|
}
|
|
|
|
async readEncryptedFileFromBackup(uuid: string, onChunk: OnChunkCallback): Promise<'success' | 'failed' | 'aborted'> {
|
|
const fileBackup = await this.getFileBackupInfo({ uuid })
|
|
|
|
if (!fileBackup) {
|
|
return 'failed'
|
|
}
|
|
|
|
const fileBackupsLocation = this.getFilesBackupsLocation()
|
|
|
|
if (!fileBackupsLocation) {
|
|
return 'failed'
|
|
}
|
|
|
|
const path = await this.device.joinPaths(fileBackupsLocation, fileBackup.relativePath, fileBackup.binaryFileName)
|
|
const token = await this.device.getFileBackupReadToken(path)
|
|
|
|
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'> {
|
|
const location = this.getFilesBackupsLocation()
|
|
if (!location) {
|
|
return 'failed'
|
|
}
|
|
|
|
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) {
|
|
this.status.removeMessage(messageId)
|
|
return 'failed'
|
|
}
|
|
|
|
const encryptedItemsKey = await this.encryptor.encryptSplitSingle({
|
|
usesRootKeyWithKeyLookup: {
|
|
items: [itemsKey.payload],
|
|
},
|
|
})
|
|
|
|
const token = await this.api.createUserFileValetToken(file.remoteIdentifier, ValetTokenOperation.Read)
|
|
|
|
if (token instanceof ClientDisplayableError) {
|
|
this.status.removeMessage(messageId)
|
|
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 downloadType = !file.user_uuid || file.user_uuid === this.session.getSureUser().uuid ? 'user' : 'shared-vault'
|
|
|
|
const result = await this.device.saveFilesBackupsFile(location, file.uuid, metaFileAsString, {
|
|
chunkSizes: file.encryptedChunkSizes,
|
|
url: this.api.getFilesDownloadUrl(downloadType),
|
|
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
|
|
}
|
|
|
|
/**
|
|
* Not presently used or enabled. It works, but presently has the following edge cases:
|
|
* 1. Editing the note directly in SN triggers an immediate backup which triggers a file change which triggers the observer
|
|
* 2. Since changes are based on filenames, a note with the same title as another may not properly map to the correct uuid
|
|
* 3. Opening the file triggers a watch event from Node's watch API.
|
|
* 4. Gives web code ability to monitor arbitrary locations. Needs whitelisting mechanism.
|
|
*/
|
|
disabledExperimental_monitorPlaintextBackups(): void {
|
|
const location = this.getPlaintextBackupsLocation()
|
|
if (location) {
|
|
void this.device.monitorPlaintextBackupsLocationForChanges(location)
|
|
}
|
|
}
|
|
}
|