From dda177a94468060331c451efb2f1bb93233d454a Mon Sep 17 00:00:00 2001 From: Ammar Ahmed Date: Fri, 13 Dec 2024 14:07:15 +0500 Subject: [PATCH] mobile: fix encryption on ios --- apps/mobile/app/common/database/encryption.ts | 146 +++++++++++++----- .../common/database/{index.js => index.ts} | 40 ++--- .../common/database/{logger.js => logger.ts} | 0 .../app/common/database/{mmkv.js => mmkv.ts} | 0 .../app/common/database/sqlite.kysely.ts | 2 +- .../database/{storage.js => storage.ts} | 126 +++++++-------- ...-attachment.js => download-attachment.tsx} | 97 ++++++------ .../filesystem/{download.js => download.ts} | 64 ++++---- .../common/filesystem/{index.js => index.ts} | 39 +++-- .../app/common/filesystem/{io.js => io.ts} | 104 ++++++++----- .../filesystem/{upload.js => upload.ts} | 43 ++++-- .../common/filesystem/{utils.js => utils.ts} | 77 ++++++--- .../app/components/auth/change-password.js | 9 +- .../recovery-key/{index.js => index.jsx} | 14 +- apps/mobile/app/screens/settings/debug.tsx | 16 +- .../screens/settings/restore-backup/index.tsx | 4 +- apps/mobile/app/services/backup.ts | 17 +- apps/mobile/app/services/exporter.ts | 11 +- apps/mobile/native/ios/Podfile.lock | 4 +- apps/mobile/native/package.json | 2 +- apps/mobile/package-lock.json | 14 +- apps/mobile/package.json | 1 + 22 files changed, 497 insertions(+), 333 deletions(-) rename apps/mobile/app/common/database/{index.js => index.ts} (83%) rename apps/mobile/app/common/database/{logger.js => logger.ts} (100%) rename apps/mobile/app/common/database/{mmkv.js => mmkv.ts} (100%) rename apps/mobile/app/common/database/{storage.js => storage.ts} (50%) rename apps/mobile/app/common/filesystem/{download-attachment.js => download-attachment.tsx} (79%) rename apps/mobile/app/common/filesystem/{download.js => download.ts} (76%) rename apps/mobile/app/common/filesystem/{index.js => index.ts} (81%) rename apps/mobile/app/common/filesystem/{io.js => io.ts} (70%) rename apps/mobile/app/common/filesystem/{upload.js => upload.ts} (77%) rename apps/mobile/app/common/filesystem/{utils.js => utils.ts} (65%) rename apps/mobile/app/components/sheets/recovery-key/{index.js => index.jsx} (98%) diff --git a/apps/mobile/app/common/database/encryption.ts b/apps/mobile/app/common/database/encryption.ts index 13ee15208..8d9510909 100644 --- a/apps/mobile/app/common/database/encryption.ts +++ b/apps/mobile/app/common/database/encryption.ts @@ -18,6 +18,7 @@ along with this program. If not, see . */ import Sodium, { Cipher, Password } from "@ammarahmed/react-native-sodium"; +import { SerializedKey } from "@notesnook/crypto"; import { Platform } from "react-native"; import "react-native-get-random-values"; import * as Keychain from "react-native-keychain"; @@ -233,7 +234,40 @@ export async function getDatabaseKey(appLockPassword?: string) { } } -export async function deriveCryptoKey(data: Required) { +export async function deriveCryptoKeyFallback(data: SerializedKey) { + if (Platform.OS !== "ios") return; + try { + if (!data.password || !data.salt) + throw new Error( + "Invalid password and salt provided to deriveCryptoKeyFallback" + ); + + const credentials = await Sodium.deriveKeyFallback?.( + data.password, + data.salt + ); + + if (!credentials) return; + + const userKeyCipher = (await encrypt( + { + key: (await getDatabaseKey()) as string, + salt: NOTESNOOK_DB_KEY_SALT + }, + credentials.key as string + )) as Cipher<"base64">; + DatabaseLogger.info("User key fallback stored: ", { + userKeyCipher: !!userKeyCipher + }); + + // Store encrypted user key in MMKV + MMKV.setMap(USER_KEY_CIPHER, userKeyCipher); + } catch (e) { + DatabaseLogger.error(e); + } +} + +export async function deriveCryptoKey(data: SerializedKey) { try { if (!data.password || !data.salt) throw new Error("Invalid password and salt provided to deriveCryptoKey"); @@ -248,14 +282,13 @@ export async function deriveCryptoKey(data: Required) { salt: NOTESNOOK_DB_KEY_SALT }, credentials.key as string - )) as Cipher; + )) as Cipher<"base64">; DatabaseLogger.info("User key stored: ", { userKeyCipher: !!userKeyCipher }); // Store encrypted user key in MMKV MMKV.setMap(USER_KEY_CIPHER, userKeyCipher); - return credentials.key; } catch (e) { DatabaseLogger.error(e); } @@ -266,7 +299,7 @@ export async function getCryptoKey() { const keyCipher: Cipher = MMKV.getMap(USER_KEY_CIPHER); if (!keyCipher) { DatabaseLogger.info("User key cipher is null"); - return null; + return undefined; } const key = await decrypt( @@ -298,41 +331,65 @@ export async function getRandomBytes(length: number) { return await generateSecureRandom(length); } -export async function hash(password: string, email: string) { - //@ts-ignore - return await Sodium.hashPassword(password, email); +export async function hash( + password: string, + email: string, + options?: { usesFallback?: boolean } +) { + DatabaseLogger.log(`Hashing password: fallback: ${options?.usesFallback}`); + + if (options?.usesFallback && Platform.OS !== "ios") { + return null; + } + + return ( + options?.usesFallback + ? await Sodium.hashPasswordFallback?.(password, email) + : await Sodium.hashPassword(password, email) + ) as string; } export async function generateCryptoKey(password: string, salt?: string) { - try { - //@ts-ignore - const credentials = await Sodium.deriveKey(password, salt || null); - return credentials; - } catch (e) { - DatabaseLogger.error(e); - } + return (await Sodium.deriveKey(password, salt)) as Promise; } export function getAlgorithm(base64Variant: number) { return `xcha-argon2i13-${base64Variant}`; } -export async function decrypt(password: Password, data: Cipher) { - if (!password.password && !password.key) return undefined; - if (password.password && password.password === "" && !password.key) - return undefined; +export async function decrypt(password: SerializedKey, data: Cipher<"base64">) { const _data = { ...data }; _data.output = "plain"; if (!password.salt) password.salt = data.salt; + + if (Platform.OS === "ios" && !password.key && password.password) { + const key = await Sodium.deriveKey(password.password, password.salt); + try { + return await Sodium.decrypt(key, _data); + } catch (e) { + const fallbackKey = await Sodium.deriveKeyFallback?.( + password.password, + password.salt + ); + if (Platform.OS === "ios" && fallbackKey) { + DatabaseLogger.info("Using fallback key for decryption"); + } + if (fallbackKey) { + return await Sodium.decrypt(fallbackKey, _data); + } else { + throw e; + } + } + } + return await Sodium.decrypt(password, _data); } -export async function decryptMulti(password: Password, data: Cipher[]) { - if (!password.password && !password.key) return undefined; - if (password.password && password.password === "" && !password.key) - return undefined; - +export async function decryptMulti( + password: Password, + data: Cipher<"base64">[] +) { data = data.map((d) => { d.output = "plain"; return d; @@ -342,6 +399,26 @@ export async function decryptMulti(password: Password, data: Cipher[]) { password.salt = data[0].salt; } + if (Platform.OS === "ios" && !password.key && password.password) { + const key = await Sodium.deriveKey(password.password, password.salt); + try { + return await Sodium.decryptMulti(key, data); + } catch (e) { + const fallbackKey = await Sodium.deriveKeyFallback?.( + password.password, + password.salt as string + ); + if (Platform.OS === "ios" && fallbackKey) { + DatabaseLogger.info("Using fallback key for decryption"); + } + if (fallbackKey) { + return await Sodium.decryptMulti(fallbackKey, data); + } else { + throw e; + } + } + } + return await Sodium.decryptMulti(password, data); } @@ -357,14 +434,10 @@ export function parseAlgorithm(alg: string) { }; } -export async function encrypt(password: Password, data: string) { - if (!password.password && !password.key) return undefined; - if (password.password && password.password === "" && !password.key) - return undefined; - - const result = await Sodium.encrypt(password, { +export async function encrypt(password: SerializedKey, plainText: string) { + const result = await Sodium.encrypt<"base64">(password, { type: "plain", - data: data + data: plainText }); return { @@ -373,14 +446,13 @@ export async function encrypt(password: Password, data: string) { }; } -export async function encryptMulti(password: Password, data: string[]) { - if (!password.password && !password.key) return undefined; - if (password.password && password.password === "" && !password.key) - return undefined; - - const results = await Sodium.encryptMulti( +export async function encryptMulti( + password: SerializedKey, + plainText: string[] +) { + const results = await Sodium.encryptMulti<"base64">( password, - data.map((item) => ({ + plainText.map((item) => ({ type: "plain", data: item })) diff --git a/apps/mobile/app/common/database/index.js b/apps/mobile/app/common/database/index.ts similarity index 83% rename from apps/mobile/app/common/database/index.js rename to apps/mobile/app/common/database/index.ts index 9224b558f..8f63c4f0f 100644 --- a/apps/mobile/app/common/database/index.js +++ b/apps/mobile/app/common/database/index.ts @@ -16,26 +16,26 @@ GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import "./logger"; import { database } from "@notesnook/common"; -import { logger as dbLogger } from "@notesnook/core"; -import { Platform } from "react-native"; -import * as Gzip from "react-native-gzip"; -import EventSource from "../../utils/sse/even-source-ios"; -import AndroidEventSource from "../../utils/sse/event-source"; +import { logger as dbLogger, ICompressor } from "@notesnook/core"; +import { strings } from "@notesnook/intl"; import { SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler } from "@streetwriters/kysely"; -import filesystem from "../filesystem"; -import Storage from "./storage"; -import { RNSqliteDriver } from "./sqlite.kysely"; -import { getDatabaseKey } from "./encryption"; +import { Platform } from "react-native"; +import * as Gzip from "react-native-gzip"; import SettingsService from "../../services/settings"; -import { strings } from "@notesnook/intl"; +import EventSource from "../../utils/sse/even-source-ios"; +import AndroidEventSource from "../../utils/sse/event-source"; +import { FileStorage } from "../filesystem"; +import { getDatabaseKey } from "./encryption"; +import "./logger"; +import { RNSqliteDriver } from "./sqlite.kysely"; +import { Storage } from "./storage"; -export async function setupDatabase(password) { +export async function setupDatabase(password?: string) { const key = await getDatabaseKey(password); if (!key) throw new Error(strings.databaseSetupFailed()); @@ -47,17 +47,21 @@ export async function setupDatabase(password) { SSE_HOST: "https://events.streetwriters.co", SUBSCRIPTIONS_HOST: "https://subscriptions.streetwriters.co", ISSUES_HOST: "https://issues.streetwriters.co", + MONOGRAPH_HOST: "https://monogr.ph", ...(SettingsService.getProperty("serverUrls") || {}) }); database.setup({ storage: Storage, - eventsource: Platform.OS === "ios" ? EventSource : AndroidEventSource, - fs: filesystem, - compressor: () => ({ - compress: Gzip.deflate, - decompress: Gzip.inflate - }), + eventsource: (Platform.OS === "ios" + ? EventSource + : AndroidEventSource) as any, + fs: FileStorage, + compressor: async () => + ({ + compress: Gzip.deflate, + decompress: Gzip.inflate + } as ICompressor), batchSize: 100, sqliteOptions: { dialect: (name) => ({ diff --git a/apps/mobile/app/common/database/logger.js b/apps/mobile/app/common/database/logger.ts similarity index 100% rename from apps/mobile/app/common/database/logger.js rename to apps/mobile/app/common/database/logger.ts diff --git a/apps/mobile/app/common/database/mmkv.js b/apps/mobile/app/common/database/mmkv.ts similarity index 100% rename from apps/mobile/app/common/database/mmkv.js rename to apps/mobile/app/common/database/mmkv.ts diff --git a/apps/mobile/app/common/database/sqlite.kysely.ts b/apps/mobile/app/common/database/sqlite.kysely.ts index a5898af80..04a0c4857 100644 --- a/apps/mobile/app/common/database/sqlite.kysely.ts +++ b/apps/mobile/app/common/database/sqlite.kysely.ts @@ -26,7 +26,7 @@ import { CompiledQuery } from "@streetwriters/kysely"; import { QuickSQLiteConnection, open } from "react-native-quick-sqlite"; import { strings } from "@notesnook/intl"; -type Config = { dbName: string; async: boolean; location: string }; +type Config = { dbName: string; async: boolean; location?: string }; export class RNSqliteDriver implements Driver { private connection?: DatabaseConnection; diff --git a/apps/mobile/app/common/database/storage.js b/apps/mobile/app/common/database/storage.ts similarity index 50% rename from apps/mobile/app/common/database/storage.js rename to apps/mobile/app/common/database/storage.ts index 53c462824..e1523b108 100644 --- a/apps/mobile/app/common/database/storage.js +++ b/apps/mobile/app/common/database/storage.ts @@ -16,58 +16,51 @@ GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ - -import { Platform } from "react-native"; -import RNFetchBlob from "react-native-blob-util"; +import { IStorage } from "@notesnook/core"; +import { MMKVInstance } from "react-native-mmkv-storage"; import { decrypt, decryptMulti, deriveCryptoKey, + deriveCryptoKeyFallback, encrypt, encryptMulti, generateCryptoKey, getCryptoKey, - getRandomBytes, - hash, - removeCryptoKey + hash } from "./encryption"; import { MMKV } from "./mmkv"; export class KV { - /** - * @type {typeof MMKV} - */ - storage = null; - constructor(storage) { + storage: MMKVInstance; + constructor(storage: MMKVInstance) { this.storage = storage; } - async read(key) { - if (!key) return null; - let data = this.storage.getString(key); - if (!data) return null; + + async read(key: string, isArray?: boolean) { + if (!key) return undefined; + const data = this.storage.getString(key); + if (!data) return undefined; try { - let parse = JSON.parse(data); - return parse; + return JSON.parse(data) as T; } catch (e) { - return data; + return data as T; } } - async write(key, data) { + async write(key: string, data: T) { this.storage.setString( key, typeof data === "string" ? data : JSON.stringify(data) ); - - return true; } - async readMulti(keys) { + async readMulti(keys: string[]) { if (keys.length <= 0) { return []; } else { try { - let data = await this.storage.getMultipleItemsAsync( + const data = await this.storage.getMultipleItemsAsync( keys.slice(), "string" ); @@ -79,24 +72,24 @@ export class KV { obj = value; } return [key, obj]; - }); + }) as [string, T][]; } catch (e) { - console.log(e); + return []; } } } - async remove(key) { - return this.storage.removeItem(key); + async remove(key: string) { + this.storage.removeItem(key); } - async removeMulti(keys) { - if (!keys) return true; - return this.storage.removeItems(keys); + async removeMulti(keys: string[]) { + if (!keys) return; + this.storage.removeItems(keys); } async clear() { - return this.storage.clearStore(); + this.storage.clearStore(); } async getAllKeys() { @@ -113,54 +106,45 @@ export class KV { return keys; } - async writeMulti(items) { - return this.storage.setMultipleItemsAsync(items, "object"); + async writeMulti(items: [string, any][]) { + await this.storage.setMultipleItemsAsync(items, "object"); } } const DefaultStorage = new KV(MMKV); -async function requestPermission() { - if (Platform.OS === "ios") return true; - return true; -} -async function checkAndCreateDir(path) { - let dir = - Platform.OS === "ios" - ? RNFetchBlob.fs.dirs.DocumentDir + path - : RNFetchBlob.fs.dirs.SDCardDir + "/Notesnook/" + path; - - try { - let exists = await RNFetchBlob.fs.exists(dir); - let isDir = await RNFetchBlob.fs.isDir(dir); - if (!exists || !isDir) { - await RNFetchBlob.fs.mkdir(dir); - } - } catch (e) { - await RNFetchBlob.fs.mkdir(dir); - } - return dir; -} - -export default { - read: (key) => DefaultStorage.read(key), - write: (key, value) => DefaultStorage.write(key, value), - readMulti: (keys) => DefaultStorage.readMulti(keys), - remove: (key) => DefaultStorage.remove(key), - clear: () => DefaultStorage.clear(), - getAllKeys: () => DefaultStorage.getAllKeys(), - writeMulti: (items) => DefaultStorage.writeMulti(items), - removeMulti: (keys) => DefaultStorage.removeMulti(keys), +export const Storage: IStorage = { + write(key: string, data: T): Promise { + return DefaultStorage.write(key, data); + }, + writeMulti(entries: [string, T][]): Promise { + return DefaultStorage.writeMulti(entries); + }, + readMulti(keys: string[]): Promise<[string, T][]> { + return DefaultStorage.readMulti(keys); + }, + read(key: string, isArray?: boolean): Promise { + return DefaultStorage.read(key, isArray); + }, + remove(key: string): Promise { + return DefaultStorage.remove(key); + }, + removeMulti(keys: string[]): Promise { + return DefaultStorage.removeMulti(keys); + }, + clear(): Promise { + return DefaultStorage.clear(); + }, + getAllKeys(): Promise { + return DefaultStorage.getAllKeys(); + }, + hash, + getCryptoKey, encrypt, + encryptMulti, decrypt, decryptMulti, - getRandomBytes, - checkAndCreateDir, - requestPermission, deriveCryptoKey, - getCryptoKey, - removeCryptoKey, - hash, generateCryptoKey, - encryptMulti + deriveCryptoKeyFallback }; diff --git a/apps/mobile/app/common/filesystem/download-attachment.js b/apps/mobile/app/common/filesystem/download-attachment.tsx similarity index 79% rename from apps/mobile/app/common/filesystem/download-attachment.js rename to apps/mobile/app/common/filesystem/download-attachment.tsx index 699eb5fe7..4a6d4bfd8 100644 --- a/apps/mobile/app/common/filesystem/download-attachment.js +++ b/apps/mobile/app/common/filesystem/download-attachment.tsx @@ -16,7 +16,6 @@ GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ - import Sodium from "@ammarahmed/react-native-sodium"; import { getFileNameWithExtension } from "@notesnook/core"; import { strings } from "@notesnook/intl"; @@ -26,11 +25,11 @@ import RNFetchBlob from "react-native-blob-util"; import * as ScopedStorage from "react-native-scoped-storage"; import { subscribe, zip } from "react-native-zip-archive"; import { ShareComponent } from "../../components/sheets/export-notes/share"; -import { ToastManager, presentSheet } from "../../services/event-manager"; +import { presentSheet, ToastManager } from "../../services/event-manager"; import { useAttachmentStore } from "../../stores/use-attachment-store"; import { IOS_APPGROUPID } from "../../utils/constants"; import { DatabaseLogger, db } from "../database"; -import Storage from "../database/storage"; +import filesystem from "../filesystem"; import { createCacheDir, exists } from "./io"; import { cacheDir, copyFileAsync, releasePermissions } from "./utils"; @@ -51,23 +50,23 @@ export async function downloadAllAttachments() { /** * Downloads provided attachments to a .zip file * on user's device. - * @param {string[]} attachments + * @param {string[]} attachmentIds * @param onProgress * @returns */ -export async function downloadAttachments(attachments) { +export async function downloadAttachments(attachmentIds: string[]) { await createCacheDir(); - if (!attachments || !attachments.length) return; + if (!attachmentIds || !attachmentIds.length) return; const groupId = `download-all-${Date.now()}`; let outputFolder; if (Platform.OS === "android") { // Ask the user to select a directory to store the file - let file = await ScopedStorage.openDocumentTree(true); + const file = await ScopedStorage.openDocumentTree(true); outputFolder = file.uri; if (!outputFolder) return; } else { - outputFolder = await Storage.checkAndCreateDir("/downloads/"); + outputFolder = await filesystem.checkAndCreateDir("/downloads/"); } // Create the folder to zip; @@ -83,7 +82,7 @@ export async function downloadAttachments(attachments) { await RNFetchBlob.fs.mkdir(zipSourceFolder); const isCancelled = () => { - if (useAttachmentStore.getState().downloading[groupId]?.canceled) { + if (useAttachmentStore.getState().downloading?.[groupId]?.canceled) { RNFetchBlob.fs.unlink(zipSourceFolder).catch(console.log); useAttachmentStore.getState().setDownloading({ groupId, @@ -97,19 +96,21 @@ export async function downloadAttachments(attachments) { } }; - for (let i = 0; i < attachments.length; i++) { + for (let i = 0; i < attachmentIds.length; i++) { if (isCancelled()) return; - let attachment = await db.attachments.attachment(attachments[i]); + const attachment = await db.attachments.attachment(attachmentIds[i]); + if (!attachment) continue; + const hash = attachment.hash; try { useAttachmentStore.getState().setDownloading({ groupId: groupId, current: i + 1, - total: attachments.length, + total: attachmentIds.length, filename: attachment.hash }); // Download to cache - let uri = await downloadAttachment(hash, false, { + const uri = await downloadAttachment(hash, false, { silent: true, cache: true, groupId: groupId @@ -184,7 +185,7 @@ export async function downloadAttachments(attachments) { }); releasePermissions(outputFolder); sub?.remove(); - ToastManager.error(e, "Error zipping attachments"); + ToastManager.error(e as Error, "Error zipping attachments"); } // Remove source & zip file from cache. RNFetchBlob.fs.unlink(zipSourceFolder).catch(console.log); @@ -194,38 +195,44 @@ export async function downloadAttachments(attachments) { } export default async function downloadAttachment( - hash, + hashOrId: string, global = true, - options = { - silent: false, - cache: false, - throwError: false, - groupId: undefined, - base64: false, - text: false + options?: { + silent?: boolean; + cache?: boolean; + throwError?: boolean; + groupId?: string; + base64?: boolean; + text?: boolean; } ) { await createCacheDir(); - let attachment = await db.attachments.attachment(hash); + const attachment = await db.attachments.attachment(hashOrId); if (!attachment) { - DatabaseLogger.log("Attachment not found"); + DatabaseLogger.log("Attachment not found", { + hash: hashOrId + }); return; } - let folder = {}; - if (!options.cache) { + let folder: { + uri: string; + } | null = null; + if (!options?.cache) { if (Platform.OS === "android") { folder = await ScopedStorage.openDocumentTree(); if (!folder) return; } else { - folder.uri = await Storage.checkAndCreateDir("/downloads/"); + folder = { + uri: await filesystem.checkAndCreateDir("/downloads/") + }; } } try { useAttachmentStore.getState().setDownloading({ - groupId: options.groupId || attachment.hash, + groupId: options?.groupId || attachment.hash, current: 0, total: 1, filename: attachment.filename @@ -234,13 +241,13 @@ export default async function downloadAttachment( await db .fs() .downloadFile( - options.groupId || attachment.hash, + options?.groupId || attachment.hash, attachment.hash, attachment.chunkSize ); useAttachmentStore.getState().setDownloading({ - groupId: options.groupId || attachment.hash, + groupId: options?.groupId || attachment.hash, current: 1, total: 1, filename: attachment.filename, @@ -252,7 +259,7 @@ export default async function downloadAttachment( return; } - if (options.base64 || options.text) { + if (options?.base64 || options?.text) { DatabaseLogger.log(`Starting to decrypt... hash: ${attachment.hash}`); return await db.attachments.read( attachment.hash, @@ -260,14 +267,16 @@ export default async function downloadAttachment( ); } - let filename = await getFileNameWithExtension( + const filename = await getFileNameWithExtension( attachment.filename, attachment.mimeType ); - let key = await db.attachments.decryptKey(attachment.key); + const key = await db.attachments.decryptKey(attachment.key); - let info = { + if (!key) return; + + const info = { iv: attachment.iv, salt: attachment.salt, length: attachment.size, @@ -275,18 +284,18 @@ export default async function downloadAttachment( hash: attachment.hash, hashType: attachment.hashType, mime: attachment.mimeType, - fileName: options.cache ? undefined : filename, - uri: options.cache ? undefined : folder.uri, + fileName: options?.cache ? undefined : filename, + uri: options?.cache ? undefined : folder?.uri, chunkSize: attachment.chunkSize, appGroupId: IOS_APPGROUPID }; let fileUri = await Sodium.decryptFile( key, info, - options.cache ? "cache" : "file" + options?.cache ? "cache" : "file" ); - if (!options.silent) { + if (!options?.silent) { ToastManager.show({ heading: strings.network.downloadSuccess(), message: strings.network.fileDownloaded(filename), @@ -294,15 +303,15 @@ export default async function downloadAttachment( }); } - if (Platform.OS === "ios" && !options.cache) { - fileUri = folder.uri + `/${filename}`; + if (Platform.OS === "ios" && !options?.cache) { + fileUri = folder?.uri + `/${filename}`; } - if (!options.silent) { + if (!options?.silent) { presentSheet({ title: strings.network.fileDownloaded(), paragraph: strings.fileSaved(filename, Platform.OS), icon: "download", - context: global ? null : attachment.hash, + context: global ? "global" : attachment.hash, component: }); } @@ -319,7 +328,7 @@ export default async function downloadAttachment( } useAttachmentStore.getState().setDownloading({ - groupId: options.groupId || attachment.hash, + groupId: options?.groupId || attachment.hash, current: 0, total: 0, filename: attachment.filename, @@ -327,7 +336,7 @@ export default async function downloadAttachment( }); DatabaseLogger.error(e); useAttachmentStore.getState().remove(attachment.hash); - if (options.throwError) { + if (options?.throwError) { throw e; } } diff --git a/apps/mobile/app/common/filesystem/download.js b/apps/mobile/app/common/filesystem/download.ts similarity index 76% rename from apps/mobile/app/common/filesystem/download.js rename to apps/mobile/app/common/filesystem/download.ts index ae341f12d..f1062b4e7 100644 --- a/apps/mobile/app/common/filesystem/download.js +++ b/apps/mobile/app/common/filesystem/download.ts @@ -17,6 +17,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ +import { RequestOptions } from "@notesnook/core"; import { strings } from "@notesnook/intl"; import NetInfo from "@react-native-community/netinfo"; import RNFetchBlob from "react-native-blob-util"; @@ -26,7 +27,13 @@ import { DatabaseLogger, db } from "../database"; import { createCacheDir, exists } from "./io"; import { ABYTES, cacheDir, getUploadedFileSize, parseS3Error } from "./utils"; -export async function downloadFile(filename, requestOptions, cancelToken) { +export async function downloadFile( + filename: string, + requestOptions: RequestOptions, + cancelToken: { + cancel: (reason?: string) => Promise; + } +) { if (!requestOptions) { DatabaseLogger.log( `Error downloading file: ${filename}, reason: No requestOptions` @@ -36,9 +43,11 @@ export async function downloadFile(filename, requestOptions, cancelToken) { DatabaseLogger.log(`Downloading ${filename}`); await createCacheDir(); - let { url, headers, chunkSize } = requestOptions; - let tempFilePath = `${cacheDir}/${filename}_temp`; - let originalFilePath = `${cacheDir}/${filename}`; + + const { url, headers, chunkSize } = requestOptions; + const tempFilePath = `${cacheDir}/${filename}_temp`; + const originalFilePath = `${cacheDir}/${filename}`; + try { if (await exists(filename)) { DatabaseLogger.log(`File Exists already: ${filename}`); @@ -46,6 +55,8 @@ export async function downloadFile(filename, requestOptions, cancelToken) { } const attachment = await db.attachments.attachment(filename); + if (!attachment) return false; + const size = await getUploadedFileSize(filename); if (size === -1) { @@ -71,21 +82,21 @@ export async function downloadFile(filename, requestOptions, cancelToken) { throw new Error(error); } - let res = await fetch(url, { + const resolveUrlResponse = await fetch(url, { method: "GET", headers }); - if (!res.ok) { + if (!resolveUrlResponse.ok) { DatabaseLogger.log( - `Error downloading file: ${filename}, ${res.status}, ${res.statusText}, reason: Unable to resolve download url` + `Error downloading file: ${filename}, ${resolveUrlResponse.status}, ${resolveUrlResponse.statusText}, reason: Unable to resolve download url` ); throw new Error( - `${res.status}: ${strings.failedToResolvedDownloadUrl()}` + `${resolveUrlResponse.status}: ${strings.failedToResolvedDownloadUrl()}` ); } - const downloadUrl = await res.text(); + const downloadUrl = await resolveUrlResponse.text(); if (!downloadUrl) { DatabaseLogger.log( @@ -95,12 +106,12 @@ export async function downloadFile(filename, requestOptions, cancelToken) { } DatabaseLogger.log(`Download starting: ${filename}`); - let request = RNFetchBlob.config({ + const request = RNFetchBlob.config({ path: tempFilePath, IOSBackgroundTask: true, overwrite: true }) - .fetch("GET", downloadUrl, null) + .fetch("GET", downloadUrl) .progress(async (recieved, total) => { useAttachmentStore .getState() @@ -109,15 +120,14 @@ export async function downloadFile(filename, requestOptions, cancelToken) { DatabaseLogger.log(`Downloading: ${filename}, ${recieved}/${total}`); }); - cancelToken.cancel = () => { + cancelToken.cancel = async (reason) => { useAttachmentStore.getState().remove(filename); request.cancel(); RNFetchBlob.fs.unlink(tempFilePath).catch(console.log); - DatabaseLogger.log(`Download cancelled: ${filename}`); + DatabaseLogger.log(`Download cancelled: ${reason} ${filename}`); }; - let response = await request; - console.log(response.info().headers); + const response = await request; const contentType = response.info().headers?.["content-type"] || @@ -128,10 +138,10 @@ export async function downloadFile(filename, requestOptions, cancelToken) { throw new Error(`[${error.Code}] ${error.Message}`); } - let status = response.info().status; + const status = response.info().status; useAttachmentStore.getState().remove(filename); - if (exists(originalFilePath)) { + if (await exists(originalFilePath)) { await RNFetchBlob.fs.unlink(originalFilePath).catch(console.log); } @@ -143,11 +153,14 @@ export async function downloadFile(filename, requestOptions, cancelToken) { return status >= 200 && status < 300; } catch (e) { - if (e.message !== "canceled" && !e.message.includes("NoSuchKey")) { + if ( + (e as Error).message !== "canceled" && + !(e as Error).message.includes("NoSuchKey") + ) { const toast = { - heading: strings.downloadError(), - message: e.message, - type: "error", + heading: strings.downloadError((e as Error).message), + message: (e as Error).message, + type: "error" as const, context: "global" }; ToastManager.show(toast); @@ -158,15 +171,14 @@ export async function downloadFile(filename, requestOptions, cancelToken) { useAttachmentStore.getState().remove(filename); RNFetchBlob.fs.unlink(tempFilePath).catch(console.log); RNFetchBlob.fs.unlink(originalFilePath).catch(console.log); - DatabaseLogger.error(e, { - url, - headers + DatabaseLogger.error(e, "Download failed: ", { + url }); return false; } } -export async function checkAttachment(hash) { +export async function checkAttachment(hash: string) { const internetState = await NetInfo.fetch(); const isInternetReachable = internetState.isConnected && internetState.isInternetReachable; @@ -184,7 +196,7 @@ export async function checkAttachment(hash) { failed: `File length is 0. Please upload this file again from the attachment manager. (File hash: ${hash})` }; } catch (e) { - return { failed: e?.message }; + return { failed: (e as Error)?.message }; } return { success: true }; } diff --git a/apps/mobile/app/common/filesystem/index.js b/apps/mobile/app/common/filesystem/index.ts similarity index 81% rename from apps/mobile/app/common/filesystem/index.js rename to apps/mobile/app/common/filesystem/index.ts index 3fc7afa13..676ec4665 100644 --- a/apps/mobile/app/common/filesystem/index.js +++ b/apps/mobile/app/common/filesystem/index.ts @@ -17,24 +17,40 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ +import { IFileStorage } from "@notesnook/core"; import { checkAttachment, downloadFile } from "./download"; import { + bulkExists, + clearCache, clearFileStorage, + deleteCacheFileByName, + deleteCacheFileByPath, deleteFile, exists, - readEncrypted, - writeEncryptedBase64, + getCacheSize, hashBase64, + readEncrypted, + writeEncryptedBase64 +} from "./io"; +import { uploadFile } from "./upload"; +import { + cancelable, + checkAndCreateDir, + getUploadedFileSize, + requestPermission +} from "./utils"; + +export default { + checkAttachment, clearCache, deleteCacheFileByName, deleteCacheFileByPath, - bulkExists, - getCacheSize -} from "./io"; -import { uploadFile } from "./upload"; -import { cancelable, getUploadedFileSize } from "./utils"; + getCacheSize, + requestPermission, + checkAndCreateDir +}; -export default { +export const FileStorage: IFileStorage = { readEncrypted, writeEncryptedBase64, hashBase64, @@ -44,10 +60,5 @@ export default { exists, clearFileStorage, getUploadedFileSize, - checkAttachment, - clearCache, - deleteCacheFileByName, - deleteCacheFileByPath, - bulkExists, - getCacheSize + bulkExists }; diff --git a/apps/mobile/app/common/filesystem/io.js b/apps/mobile/app/common/filesystem/io.ts similarity index 70% rename from apps/mobile/app/common/filesystem/io.js rename to apps/mobile/app/common/filesystem/io.ts index e21b85f8c..69c0d0c62 100644 --- a/apps/mobile/app/common/filesystem/io.js +++ b/apps/mobile/app/common/filesystem/io.ts @@ -18,6 +18,13 @@ along with this program. If not, see . */ import Sodium from "@ammarahmed/react-native-sodium"; +import { + FileEncryptionMetadataWithHash, + FileEncryptionMetadataWithOutputType, + Output, + RequestOptions +} from "@notesnook/core"; +import { DataFormat, SerializedKey } from "@notesnook/crypto"; import { Platform } from "react-native"; import RNFetchBlob from "react-native-blob-util"; import { eSendEvent } from "../../services/event-manager"; @@ -25,17 +32,21 @@ import { IOS_APPGROUPID } from "../../utils/constants"; import { DatabaseLogger, db } from "../database"; import { ABYTES, cacheDir, cacheDirOld, getRandomId } from "./utils"; -export async function readEncrypted(filename, key, cipherData) { +export async function readEncrypted( + filename: string, + key: SerializedKey, + cipherData: FileEncryptionMetadataWithOutputType +) { await migrateFilesFromCache(); DatabaseLogger.log("Read encrypted file..."); - let path = `${cacheDir}/${filename}`; + const path = `${cacheDir}/${filename}`; try { if (!(await exists(filename))) { - return false; + return; } - let output = await Sodium.decryptFile( + const output = await Sodium.decryptFile( key, { ...cipherData, @@ -47,15 +58,14 @@ export async function readEncrypted(filename, key, cipherData) { DatabaseLogger.log("File decrypted..."); - return output; + return output as Output; } catch (e) { RNFetchBlob.fs.unlink(path).catch(console.log); DatabaseLogger.error(e); - return false; } } -export async function hashBase64(data) { +export async function hashBase64(data: string) { const hash = await Sodium.hashFile({ type: "base64", data, @@ -67,61 +77,70 @@ export async function hashBase64(data) { }; } -export async function writeEncryptedBase64(data, key) { +export async function writeEncryptedBase64( + data: string, + encryptionKey: SerializedKey, + mimeType: string +): Promise { await createCacheDir(); - let filepath = cacheDir + `/${getRandomId("imagecache_")}`; + const filepath = cacheDir + `/${getRandomId("imagecache_")}`; await RNFetchBlob.fs.writeFile(filepath, data, "base64"); - let output = await Sodium.encryptFile(key, { + const output = await Sodium.encryptFile(encryptionKey, { uri: Platform.OS === "ios" ? filepath : "file://" + filepath, type: "url" }); + RNFetchBlob.fs.unlink(filepath).catch(console.log); console.log("encrypted file output: ", output); - output.size = output.length; - delete output.length; + return { ...output, alg: "xcha-stream" }; } -export async function deleteFile(filename, data) { +export async function deleteFile( + filename: string, + requestOptions?: RequestOptions +): Promise { await createCacheDir(); - let delFilePath = cacheDir + `/${filename}`; - if (!data) { - if (!filename) return; - RNFetchBlob.fs.unlink(delFilePath).catch(console.log); + const localFilePath = cacheDir + `/${filename}`; + if (!requestOptions) { + RNFetchBlob.fs.unlink(localFilePath).catch(console.log); return true; } - let { url, headers } = data; + const { url, headers } = requestOptions; + try { - let response = await RNFetchBlob.fetch("DELETE", url, headers); - let status = response.info().status; - let ok = status >= 200 && status < 300; + const response = await RNFetchBlob.fetch("DELETE", url, headers); + const status = response.info().status; + const ok = status >= 200 && status < 300; if (ok) { - RNFetchBlob.fs.unlink(delFilePath).catch(console.log); + RNFetchBlob.fs.unlink(localFilePath).catch(console.log); } return ok; } catch (e) { - console.log("delete file: ", e, url, headers); + DatabaseLogger.error(e, "Delete file", { + url: url + }); return false; } } export async function clearFileStorage() { try { - let files = await RNFetchBlob.fs.ls(cacheDir); - let oldCache = await RNFetchBlob.fs.ls(cacheDirOld); + const files = await RNFetchBlob.fs.ls(cacheDir); + const oldCache = await RNFetchBlob.fs.ls(cacheDirOld); - for (let file of files) { + for (const file of files) { await RNFetchBlob.fs.unlink(cacheDir + `/${file}`).catch(console.log); } - for (let file of oldCache) { + for (const file of oldCache) { await RNFetchBlob.fs.unlink(cacheDirOld + `/${file}`).catch(console.log); } } catch (e) { - console.log("clearFileStorage", e); + DatabaseLogger.error(e, "clearFileStorage"); } } @@ -146,11 +165,11 @@ export async function migrateFilesFromCache() { return; } - let files = await RNFetchBlob.fs.ls(cacheDir); + const files = await RNFetchBlob.fs.ls(cacheDir); console.log("Files to migrate:", files.join(",")); - let oldCache = await RNFetchBlob.fs.ls(cacheDirOld); - for (let file of oldCache) { + const oldCache = await RNFetchBlob.fs.ls(cacheDirOld); + for (const file of oldCache) { if (file.startsWith("org.") || file.startsWith("com.")) continue; RNFetchBlob.fs .mv(cacheDirOld + `/${file}`, cacheDir + `/${file}`) @@ -159,7 +178,7 @@ export async function migrateFilesFromCache() { } await RNFetchBlob.fs.createFile(migratedFilesPath, "1", "utf8"); } catch (e) { - console.log("migrateFilesFromCache", e); + DatabaseLogger.error(e, "migrateFilesFromCache"); } } @@ -169,14 +188,14 @@ export async function clearCache() { eSendEvent("cache-cleared"); } -export async function deleteCacheFileByPath(path) { +export async function deleteCacheFileByPath(path: string) { await RNFetchBlob.fs.unlink(path).catch(console.log); } -export async function deleteCacheFileByName(name) { +export async function deleteCacheFileByName(name: string) { const iosAppGroup = Platform.OS === "ios" - ? await RNFetchBlob.fs.pathForAppGroup(IOS_APPGROUPID) + ? await (RNFetchBlob.fs as any).pathForAppGroup(IOS_APPGROUPID) : null; const appGroupPath = `${iosAppGroup}/${name}`; await RNFetchBlob.fs.unlink(appGroupPath).catch(console.log); @@ -192,12 +211,12 @@ export async function deleteDCacheFiles() { } } -export async function exists(filename) { - let path = `${cacheDir}/${filename}`; +export async function exists(filename: string) { + const path = `${cacheDir}/${filename}`; const iosAppGroup = Platform.OS === "ios" - ? await RNFetchBlob.fs.pathForAppGroup(IOS_APPGROUPID) + ? await (RNFetchBlob.fs as any).pathForAppGroup(IOS_APPGROUPID) : null; const appGroupPath = `${iosAppGroup}/${filename}`; @@ -211,6 +230,7 @@ export async function exists(filename) { if (exists || existsInAppGroup) { const attachment = await db.attachments.attachment(filename); + if (!attachment) return false; const totalChunks = Math.ceil(attachment.size / attachment.chunkSize); const totalAbytes = totalChunks * ABYTES; const expectedFileSize = attachment.size + totalAbytes; @@ -234,7 +254,7 @@ export async function exists(filename) { return exists; } -export async function bulkExists(files) { +export async function bulkExists(files: string[]) { try { await createCacheDir(); const cacheFiles = await RNFetchBlob.fs.ls(cacheDir); @@ -243,7 +263,7 @@ export async function bulkExists(files) { if (Platform.OS === "ios") { const iosAppGroup = Platform.OS === "ios" - ? await RNFetchBlob.fs.pathForAppGroup(IOS_APPGROUPID) + ? await (RNFetchBlob.fs as any).pathForAppGroup(IOS_APPGROUPID) : null; const appGroupFiles = await RNFetchBlob.fs.ls(iosAppGroup); missingFiles = missingFiles.filter( @@ -262,8 +282,8 @@ export async function getCacheSize() { const stat = await RNFetchBlob.fs.lstat(`file://` + cacheDir); let total = 0; console.log("Total files", stat.length); - stat.forEach((s) => { - total += parseInt(s.size); + stat.forEach((file) => { + total += parseInt(file.size as unknown as string); }); return total; } diff --git a/apps/mobile/app/common/filesystem/upload.js b/apps/mobile/app/common/filesystem/upload.ts similarity index 77% rename from apps/mobile/app/common/filesystem/upload.js rename to apps/mobile/app/common/filesystem/upload.ts index 377144dbb..c55f8d87d 100644 --- a/apps/mobile/app/common/filesystem/upload.js +++ b/apps/mobile/app/common/filesystem/upload.ts @@ -17,6 +17,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ +import { RequestOptions } from "@notesnook/core"; import { Platform } from "react-native"; import RNFetchBlob from "react-native-blob-util"; import { ToastManager } from "../../services/event-manager"; @@ -24,11 +25,22 @@ import { useAttachmentStore } from "../../stores/use-attachment-store"; import { IOS_APPGROUPID } from "../../utils/constants"; import { DatabaseLogger, db } from "../database"; import { createCacheDir } from "./io"; -import { cacheDir, checkUpload, getUploadedFileSize } from "./utils"; +import { + cacheDir, + checkUpload, + FileSizeResult, + getUploadedFileSize +} from "./utils"; -export async function uploadFile(filename, requestOptions, cancelToken) { +export async function uploadFile( + filename: string, + requestOptions: RequestOptions, + cancelToken: { + cancel: (reason?: string) => Promise; + } +) { if (!requestOptions) return false; - let { url, headers } = requestOptions; + const { url, headers } = requestOptions; await createCacheDir(); DatabaseLogger.info(`Preparing to upload file: ${filename}`); @@ -39,7 +51,7 @@ export async function uploadFile(filename, requestOptions, cancelToken) { if (!exists && Platform.OS === "ios") { const iosAppGroup = Platform.OS === "ios" - ? await RNFetchBlob.fs.pathForAppGroup(IOS_APPGROUPID) + ? await (RNFetchBlob.fs as any).pathForAppGroup(IOS_APPGROUPID) : null; const appGroupPath = `${iosAppGroup}/${filename}`; filePath = appGroupPath; @@ -54,14 +66,15 @@ export async function uploadFile(filename, requestOptions, cancelToken) { const fileSize = (await RNFetchBlob.fs.stat(filePath)).size; - let remoteFileSize = await getUploadedFileSize(filename); - if (remoteFileSize === -1) return false; - if (remoteFileSize > 0 && remoteFileSize === fileSize) { + const remoteFileSize = await getUploadedFileSize(filename); + if (remoteFileSize === FileSizeResult.Error) return false; + + if (remoteFileSize > FileSizeResult.Empty && remoteFileSize === fileSize) { DatabaseLogger.log(`File ${filename} is already uploaded.`); return true; } - let uploadUrlResponse = await fetch(url, { + const uploadUrlResponse = await fetch(url, { method: "PUT", headers }); @@ -78,7 +91,8 @@ export async function uploadFile(filename, requestOptions, cancelToken) { DatabaseLogger.info(`Starting upload: ${filename}`); - let uploadRequest = RNFetchBlob.config({ + const uploadRequest = RNFetchBlob.config({ + //@ts-ignore IOSBackgroundTask: !globalThis["IS_SHARE_EXTENSION"] }) .fetch( @@ -98,14 +112,14 @@ export async function uploadFile(filename, requestOptions, cancelToken) { ); }); - cancelToken.cancel = () => { + cancelToken.cancel = async () => { useAttachmentStore.getState().remove(filename); uploadRequest.cancel(); }; - let uploadResponse = await uploadRequest; - let status = uploadResponse.info().status; - let uploaded = status >= 200 && status < 300; + const uploadResponse = await uploadRequest; + const status = uploadResponse.info().status; + const uploaded = status >= 200 && status < 300; useAttachmentStore.getState().remove(filename); @@ -118,12 +132,13 @@ export async function uploadFile(filename, requestOptions, cancelToken) { ); } const attachment = await db.attachments.attachment(filename); + if (!attachment) return false; await checkUpload(filename, requestOptions.chunkSize, attachment.size); DatabaseLogger.info(`File upload status: ${filename}, ${status}`); return uploaded; } catch (e) { useAttachmentStore.getState().remove(filename); - ToastManager.error(e, "File upload failed"); + ToastManager.error(e as Error, "File upload failed"); DatabaseLogger.error(e, "File upload failed", { filename }); diff --git a/apps/mobile/app/common/filesystem/utils.js b/apps/mobile/app/common/filesystem/utils.ts similarity index 65% rename from apps/mobile/app/common/filesystem/utils.js rename to apps/mobile/app/common/filesystem/utils.ts index 0f312debb..b248cf613 100644 --- a/apps/mobile/app/common/filesystem/utils.js +++ b/apps/mobile/app/common/filesystem/utils.ts @@ -17,11 +17,11 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ +import { hosts, RequestOptions } from "@notesnook/core"; import { Platform } from "react-native"; import RNFetchBlob from "react-native-blob-util"; import * as ScopedStorage from "react-native-scoped-storage"; import { DatabaseLogger, db } from "../database"; -import { hosts } from "@notesnook/core"; export const ABYTES = 17; export const cacheDirOld = RNFetchBlob.fs.dirs.CacheDir; @@ -31,18 +31,13 @@ export const cacheDir = ? RNFetchBlob.fs.dirs.LibraryDir + "/.cache" : RNFetchBlob.fs.dirs.DocumentDir + "/.cache"; -export function getRandomId(prefix) { +export function getRandomId(prefix: string) { return Math.random() .toString(36) .replace("0.", prefix || ""); } -/** - * - * @param {string | undefined} data - * @returns - */ -export function parseS3Error(data) { +export function parseS3Error(data?: string) { const xml = typeof data === "string" ? data : null; const error = { @@ -70,11 +65,19 @@ export function parseS3Error(data) { } } -export function cancelable(operation) { +export function cancelable( + operation: ( + filename: string, + requestOptions: RequestOptions, + cancelToken: { + cancel: (reason?: string) => Promise; + } + ) => Promise +) { const cancelToken = { - cancel: () => {} + cancel: async (reason?: string) => {} }; - return (filename, requestOptions) => { + return (filename: string, requestOptions: RequestOptions) => { return { execute: () => operation(filename, requestOptions, cancelToken), cancel: async () => { @@ -84,29 +87,35 @@ export function cancelable(operation) { }; } -export function copyFileAsync(source, dest) { +export function copyFileAsync(source: string, dest: string) { return new Promise((resolve, reject) => { - ScopedStorage.copyFile(source, dest, (e, r) => { + //@ts-ignore + ScopedStorage.copyFile(source, dest, (e: any, r: any) => { if (e) { reject(e); return; } - resolve(); + resolve(true); }); }); } -export async function releasePermissions(path) { +export async function releasePermissions(path: string) { if (Platform.OS === "ios") return; const uris = await ScopedStorage.getPersistedUriPermissions(); - for (let uri of uris) { + for (const uri of uris) { if (path.startsWith(uri)) { await ScopedStorage.releasePersistableUriPermission(uri); } } } -export async function getUploadedFileSize(hash) { +export const FileSizeResult = { + Empty: 0, + Error: -1 +}; + +export async function getUploadedFileSize(hash: string) { try { const url = `${hosts.API_HOST}/s3?name=${hash}`; const token = await db.tokenManager.getAccessToken(); @@ -115,16 +124,20 @@ export async function getUploadedFileSize(hash) { headers: { Authorization: `Bearer ${token}` } }); const contentLength = parseInt( - attachmentInfo.headers?.get("content-length") + attachmentInfo.headers?.get("content-length") || "0" ); - return isNaN(contentLength) ? 0 : contentLength; + return isNaN(contentLength) ? FileSizeResult.Empty : contentLength; } catch (e) { DatabaseLogger.error(e); - return -1; + return FileSizeResult.Error; } } -export async function checkUpload(filename, chunkSize, expectedSize) { +export async function checkUpload( + filename: string, + chunkSize: number, + expectedSize: number +) { const size = await getUploadedFileSize(filename); const totalChunks = Math.ceil(size / chunkSize); const decryptedLength = size - totalChunks * ABYTES; @@ -138,3 +151,25 @@ export async function checkUpload(filename, chunkSize, expectedSize) { : undefined; if (error) throw new Error(error); } + +export async function requestPermission() { + if (Platform.OS === "ios") return true; + return true; +} +export async function checkAndCreateDir(path: string) { + const dir = + Platform.OS === "ios" + ? RNFetchBlob.fs.dirs.DocumentDir + path + : RNFetchBlob.fs.dirs.SDCardDir + "/Notesnook/" + path; + + try { + const exists = await RNFetchBlob.fs.exists(dir); + const isDir = await RNFetchBlob.fs.isDir(dir); + if (!exists || !isDir) { + await RNFetchBlob.fs.mkdir(dir); + } + } catch (e) { + await RNFetchBlob.fs.mkdir(dir); + } + return dir; +} diff --git a/apps/mobile/app/components/auth/change-password.js b/apps/mobile/app/components/auth/change-password.js index bab16f0a8..679cfa0eb 100644 --- a/apps/mobile/app/components/auth/change-password.js +++ b/apps/mobile/app/components/auth/change-password.js @@ -68,9 +68,14 @@ export const ChangePassword = () => { } setLoading(true); try { - const result = await BackupService.run(false, "change-password-dialog"); - if (!result.error) + const result = await BackupService.run( + false, + "change-password-dialog", + "partial" + ); + if (result.error) { throw new Error(strings.backupFailed() + `: ${result.error}`); + } await db.user.clearSessions(); await db.user.changePassword(oldPassword.current, password.current); diff --git a/apps/mobile/app/components/sheets/recovery-key/index.js b/apps/mobile/app/components/sheets/recovery-key/index.jsx similarity index 98% rename from apps/mobile/app/components/sheets/recovery-key/index.js rename to apps/mobile/app/components/sheets/recovery-key/index.jsx index 04db61ec8..2d0d3b5d0 100644 --- a/apps/mobile/app/components/sheets/recovery-key/index.js +++ b/apps/mobile/app/components/sheets/recovery-key/index.jsx @@ -17,12 +17,17 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ +import { sanitizeFilename } from "@notesnook/common"; +import { strings } from "@notesnook/intl"; import Clipboard from "@react-native-clipboard/clipboard"; import React, { createRef } from "react"; import { Platform, View } from "react-native"; +import RNFetchBlob from "react-native-blob-util"; import FileViewer from "react-native-file-viewer"; import * as ScopedStorage from "react-native-scoped-storage"; import Share from "react-native-share"; +import { db } from "../../../common/database"; +import filesystem from "../../../common/filesystem"; import { eSubscribeEvent, eUnSubscribeEvent, @@ -30,8 +35,6 @@ import { } from "../../../services/event-manager"; import { clearMessage } from "../../../services/message"; import SettingsService from "../../../services/settings"; -import { db } from "../../../common/database"; -import Storage from "../../../common/database/storage"; import { eOpenRecoveryKeyDialog } from "../../../utils/events"; import { SIZE } from "../../../utils/size"; import { sleep } from "../../../utils/time"; @@ -41,9 +44,6 @@ import Seperator from "../../ui/seperator"; import SheetWrapper from "../../ui/sheet"; import { QRCode } from "../../ui/svg/lazy"; import Paragraph from "../../ui/typography/paragraph"; -import RNFetchBlob from "react-native-blob-util"; -import { sanitizeFilename } from "@notesnook/common"; -import { strings } from "@notesnook/intl"; class RecoveryKeySheet extends React.Component { constructor(props) { @@ -126,7 +126,7 @@ class RecoveryKeySheet extends React.Component { "base64" ); } else { - path = await Storage.checkAndCreateDir("/"); + path = await filesystem.checkAndCreateDir("/"); await RNFetchBlob.fs.writeFile(path + fileName, data, "base64"); } ToastManager.show({ @@ -157,7 +157,7 @@ class RecoveryKeySheet extends React.Component { if (!file) return; path = file.uri; } else { - path = await Storage.checkAndCreateDir("/"); + path = await filesystem.checkAndCreateDir("/"); await RNFetchBlob.fs.writeFile(path + fileName, this.state.key, "utf8"); path = path + fileName; } diff --git a/apps/mobile/app/screens/settings/debug.tsx b/apps/mobile/app/screens/settings/debug.tsx index ebdd74576..bb6cd259c 100644 --- a/apps/mobile/app/screens/settings/debug.tsx +++ b/apps/mobile/app/screens/settings/debug.tsx @@ -17,24 +17,24 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import Clipboard from "@react-native-clipboard/clipboard"; -import { LogMessage } from "@notesnook/logger"; +import { sanitizeFilename } from "@notesnook/common"; import { format, LogLevel, logManager } from "@notesnook/core"; +import { strings } from "@notesnook/intl"; +import { LogMessage } from "@notesnook/logger"; +import { useThemeColors } from "@notesnook/theme"; +import Clipboard from "@react-native-clipboard/clipboard"; import React, { useEffect, useRef, useState } from "react"; import { FlatList, Platform, TouchableOpacity, View } from "react-native"; -import * as ScopedStorage from "react-native-scoped-storage"; import RNFetchBlob from "react-native-blob-util"; -import Storage from "../../common/database/storage"; +import * as ScopedStorage from "react-native-scoped-storage"; +import filesystem from "../../common/filesystem"; import { presentDialog } from "../../components/dialog/functions"; import { IconButton } from "../../components/ui/icon-button"; import { Notice } from "../../components/ui/notice"; import Paragraph from "../../components/ui/typography/paragraph"; import useTimer from "../../hooks/use-timer"; import { ToastManager } from "../../services/event-manager"; -import { useThemeColors } from "@notesnook/theme"; import { hexToRGBA } from "../../utils/colors"; -import { sanitizeFilename } from "@notesnook/common"; -import { strings } from "@notesnook/intl"; export default function DebugLogs() { const { colors } = useThemeColors(); @@ -148,7 +148,7 @@ export default function DebugLogs() { if (!file) return; path = file.uri; } else { - path = await Storage.checkAndCreateDir("/"); + path = await filesystem.checkAndCreateDir("/"); await RNFetchBlob.fs.writeFile(path + fileName + ".txt", data, "utf8"); path = path + fileName; } diff --git a/apps/mobile/app/screens/settings/restore-backup/index.tsx b/apps/mobile/app/screens/settings/restore-backup/index.tsx index d22b27625..494abf53e 100644 --- a/apps/mobile/app/screens/settings/restore-backup/index.tsx +++ b/apps/mobile/app/screens/settings/restore-backup/index.tsx @@ -28,7 +28,7 @@ import DocumentPicker from "react-native-document-picker"; import * as ScopedStorage from "react-native-scoped-storage"; import { unzip } from "react-native-zip-archive"; import { DatabaseLogger, db } from "../../../common/database"; -import storage from "../../../common/database/storage"; +import filesystem from "../../../common/filesystem"; import { deleteCacheFileByName } from "../../../common/filesystem/io"; import { cacheDir, copyFileAsync } from "../../../common/filesystem/utils"; import { presentDialog } from "../../../components/dialog/functions"; @@ -300,7 +300,7 @@ export const RestoreBackup = () => { return; } } else { - const path = await storage.checkAndCreateDir("/backups/"); + const path = await filesystem.checkAndCreateDir("/backups/"); files = await RNFetchBlob.fs.lstat(path); } files = files diff --git a/apps/mobile/app/services/backup.ts b/apps/mobile/app/services/backup.ts index c664d2611..033b37da6 100644 --- a/apps/mobile/app/services/backup.ts +++ b/apps/mobile/app/services/backup.ts @@ -19,6 +19,7 @@ along with this program. If not, see . import { sanitizeFilename } from "@notesnook/common"; import { formatDate } from "@notesnook/core"; +import { strings } from "@notesnook/intl"; import { Platform } from "react-native"; import RNFetchBlob from "react-native-blob-util"; import FileViewer from "react-native-file-viewer"; @@ -26,20 +27,14 @@ import * as ScopedStorage from "react-native-scoped-storage"; import Share from "react-native-share"; import { zip } from "react-native-zip-archive"; import { DatabaseLogger, db } from "../common/database"; -import storage from "../common/database/storage"; -import filesystem from "../common/filesystem"; +import filesystem, { FileStorage } from "../common/filesystem"; import { cacheDir, copyFileAsync } from "../common/filesystem/utils"; import { presentDialog } from "../components/dialog/functions"; -import { - endProgress, - startProgress, - updateProgress -} from "../components/dialogs/progress"; +import { endProgress, updateProgress } from "../components/dialogs/progress"; import { eCloseSheet } from "../utils/events"; import { sleep } from "../utils/time"; import { ToastManager, eSendEvent, presentSheet } from "./event-manager"; import SettingsService from "./settings"; -import { strings } from "@notesnook/intl"; const MS_DAY = 86400000; const MS_WEEK = MS_DAY * 7; @@ -178,7 +173,7 @@ async function run( let path; if (Platform.OS === "ios") { - path = await storage.checkAndCreateDir("/backups"); + path = await filesystem.checkAndCreateDir("/backups"); } const backupFileName = sanitizeFilename( @@ -232,7 +227,7 @@ async function run( updateProgress({ progress: `Saving attachments in backup... ${file.hash}` }); - if (await filesystem.exists(file.hash)) { + if (await FileStorage.exists(file.hash)) { await RNFetchBlob.fs.cp( `${cacheDir}/${file.hash}`, `${attachmentsDir}/${file.hash}` @@ -299,7 +294,7 @@ async function run( path: path }; } catch (e) { - ToastManager.error(e, strings.backupFailed(), context || "global"); + ToastManager.error(e as Error, strings.backupFailed(), context || "global"); if ( (e as Error)?.message?.includes("android.net.Uri") && diff --git a/apps/mobile/app/services/exporter.ts b/apps/mobile/app/services/exporter.ts index 5938a485d..44d993b68 100644 --- a/apps/mobile/app/services/exporter.ts +++ b/apps/mobile/app/services/exporter.ts @@ -23,7 +23,6 @@ import RNHTMLtoPDF from "react-native-html-to-pdf-lite"; import * as ScopedStorage from "react-native-scoped-storage"; import { zip } from "react-native-zip-archive"; import { DatabaseLogger } from "../common/database/index"; -import Storage from "../common/database/storage"; import { exportNote as _exportNote, @@ -31,13 +30,13 @@ import { ExportableNote, exportNotes } from "@notesnook/common"; -import { Note } from "@notesnook/core"; -import { FilteredSelector } from "@notesnook/core"; -import { basename, dirname, join, extname } from "pathe"; +import { FilteredSelector, Note } from "@notesnook/core"; +import { strings } from "@notesnook/intl"; +import { basename, dirname, extname, join } from "pathe"; +import filesystem from "../common/filesystem"; import downloadAttachment from "../common/filesystem/download-attachment"; import { cacheDir } from "../common/filesystem/utils"; import { unlockVault } from "../utils/unlock-vault"; -import { strings } from "@notesnook/intl"; const FolderNames: { [name: string]: string } = { txt: "Text", @@ -49,7 +48,7 @@ const FolderNames: { [name: string]: string } = { async function getPath(type: string) { let path = Platform.OS === "ios" && - (await Storage.checkAndCreateDir(`/exported/${type}/`)); + (await filesystem.checkAndCreateDir(`/exported/${type}/`)); if (Platform.OS === "android") { const file = await ScopedStorage.openDocumentTree(true); diff --git a/apps/mobile/native/ios/Podfile.lock b/apps/mobile/native/ios/Podfile.lock index a15f39990..069eee95e 100644 --- a/apps/mobile/native/ios/Podfile.lock +++ b/apps/mobile/native/ios/Podfile.lock @@ -1021,7 +1021,7 @@ PODS: - SDWebImage (~> 5.11.1) - react-native-share-extension (2.6.0): - React - - react-native-sodium (1.5.6): + - react-native-sodium (1.6.1): - React - react-native-theme-switch-animation (0.6.0): - DoubleConversion @@ -1859,7 +1859,7 @@ SPEC CHECKSUMS: react-native-safe-area-context: b7daa1a8df36095a032dff095a1ea8963cb48371 react-native-screenguard: 8b36a3df84c76cd2b82c477f71c26fa1c8cc14a0 react-native-share-extension: 25437eb1039f7409be6e80a7edf8d02b42e1dc99 - react-native-sodium: 605c1523ec8ff5fbff5e9e7769bbacceb571a3c6 + react-native-sodium: 4cb76086943a7f60c42b40ebca866695b360a196 react-native-theme-switch-animation: d3eb50365a3829ce5572628888fa514752703f61 react-native-webview: 553abd09f58e340fdc7746c9e2ae096839e99911 React-nativeconfig: ba9a2e54e2f0882cf7882698825052793ed4c851 diff --git a/apps/mobile/native/package.json b/apps/mobile/native/package.json index 5df967f96..d07aab4db 100644 --- a/apps/mobile/native/package.json +++ b/apps/mobile/native/package.json @@ -65,7 +65,7 @@ "react-native-screenguard": "^1.0.0", "@formatjs/intl-locale": "4.0.0", "@formatjs/intl-pluralrules": "5.2.14", - "@ammarahmed/react-native-sodium": "1.5.6", + "@ammarahmed/react-native-sodium": "^1.6.1", "react-native-mmkv-storage": "^0.10.2", "@react-native-community/datetimepicker": "^8.2.0", "react-native-exit-app": "github:ammarahm-ed/react-native-exit-app", diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index 48b9497be..fd543819b 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -17,6 +17,7 @@ "@ammarahmed/react-native-share-extension": "^2.7.0", "@notesnook/common": "file:../../packages/common", "@notesnook/core": "file:../../packages/core", + "@notesnook/crypto": "file:../../packages/crypto", "@notesnook/editor": "file:../../packages/editor", "@notesnook/editor-mobile": "file:../../packages/editor-mobile", "@notesnook/intl": "file:../../packages/intl", @@ -24352,7 +24353,6 @@ "../../packages/sodium": { "name": "@notesnook/sodium", "version": "2.1.3", - "dev": true, "hasInstallScript": true, "license": "GPL-3.0-or-later", "dependencies": { @@ -24971,12 +24971,10 @@ }, "../../packages/sodium/node_modules/libsodium-sumo": { "version": "0.7.15", - "dev": true, "license": "ISC" }, "../../packages/sodium/node_modules/libsodium-wrappers-sumo": { "version": "0.7.15", - "dev": true, "license": "ISC", "dependencies": { "libsodium-sumo": "^0.7.15" @@ -29080,9 +29078,9 @@ } }, "node_modules/@ammarahmed/react-native-sodium": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/@ammarahmed/react-native-sodium/-/react-native-sodium-1.5.6.tgz", - "integrity": "sha512-DASF/A/cDViTMRnCxvoM35F0v/l/cD8bpecfI+oNOCUmySqWqR37h1L4tdFfEzwnJxLcm/SAyyDSxaa+qgw7BQ==" + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@ammarahmed/react-native-sodium/-/react-native-sodium-1.6.1.tgz", + "integrity": "sha512-3qSTIPCEYN8DChHqYuv94ekgtyLF6dinH13JZXdxsj6DHRcZxu9syPrFNb8osItPNx3ncME6UGhPFnSL2eDI8g==" }, "node_modules/@ampproject/remapping": { "version": "2.2.1", @@ -32854,6 +32852,10 @@ "resolved": "../../packages/core", "link": true }, + "node_modules/@notesnook/crypto": { + "resolved": "../../packages/crypto", + "link": true + }, "node_modules/@notesnook/editor": { "resolved": "../../packages/editor", "link": true diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 47bf83155..623f21927 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -43,6 +43,7 @@ "@notesnook/logger": "file:../../packages/logger", "@notesnook/theme": "file:../../packages/theme", "@notesnook/themes-server": "file:../../servers/themes", + "@notesnook/crypto": "file:../../packages/crypto", "diffblazer": "^1.0.1", "react": "18.2.0", "react-native": "0.74.5",