|
|
@@ -22,10 +22,9 @@ var ENCRYPTION_SECRET_KEY: String? = nil
|
|
|
var ENCRYPTION_PUBLIC_KEY: String? = nil
|
|
|
var FNAME_ENCRYPTION_KEY: Data? = nil
|
|
|
|
|
|
+let FileSyncErrorDomain = "com.logseq.app.FileSyncErrorDomain"
|
|
|
|
|
|
// MARK: Helpers
|
|
|
-
|
|
|
-
|
|
|
@inline(__always) func fnameEncryptionEnabled() -> Bool {
|
|
|
guard let _ = FNAME_ENCRYPTION_KEY else {
|
|
|
return false
|
|
|
@@ -73,7 +72,7 @@ public struct SyncMetadata: CustomStringConvertible, Equatable {
|
|
|
var size: Int
|
|
|
var ctime: Int64
|
|
|
var mtime: Int64
|
|
|
-
|
|
|
+
|
|
|
public init?(of fileURL: URL) {
|
|
|
do {
|
|
|
let fileAttributes = try fileURL.resourceValues(forKeys:[.isRegularFileKey, .fileSizeKey, .contentModificationDateKey,
|
|
|
@@ -84,7 +83,7 @@ public struct SyncMetadata: CustomStringConvertible, Equatable {
|
|
|
size = fileAttributes.fileSize ?? 0
|
|
|
mtime = Int64((fileAttributes.contentModificationDate?.timeIntervalSince1970 ?? 0.0) * 1000)
|
|
|
ctime = Int64((fileAttributes.creationDate?.timeIntervalSince1970 ?? 0.0) * 1000)
|
|
|
-
|
|
|
+
|
|
|
// incremental MD5 checksum
|
|
|
let bufferSize = 512 * 1024
|
|
|
let file = try FileHandle(forReadingFrom: fileURL)
|
|
|
@@ -101,14 +100,14 @@ public struct SyncMetadata: CustomStringConvertible, Equatable {
|
|
|
return false // eof
|
|
|
}
|
|
|
}) {}
|
|
|
-
|
|
|
+
|
|
|
let computed = ctx.finalize()
|
|
|
md5 = computed.map { String(format: "%02hhx", $0) }.joined()
|
|
|
} catch {
|
|
|
return nil
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
public var description: String {
|
|
|
return "SyncMetadata(md5=\(md5), size=\(size), mtime=\(mtime))"
|
|
|
}
|
|
|
@@ -120,7 +119,7 @@ public struct SyncMetadata: CustomStringConvertible, Equatable {
|
|
|
public class FileSync: CAPPlugin, SyncDebugDelegate {
|
|
|
override public func load() {
|
|
|
print("debug FileSync iOS plugin loaded!")
|
|
|
-
|
|
|
+
|
|
|
AWSMobileClient.default().initialize { (userState, error) in
|
|
|
guard error == nil else {
|
|
|
print("error initializing AWSMobileClient. Error: \(error!.localizedDescription)")
|
|
|
@@ -128,18 +127,18 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
// NOTE: for debug, or an activity indicator
|
|
|
public func debugNotification(_ message: [String: Any]) {
|
|
|
self.notifyListeners("debug", data: message)
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
@objc func keygen(_ call: CAPPluginCall) {
|
|
|
let (secretKey, publicKey) = AgeEncryption.keygen()
|
|
|
call.resolve(["secretKey": secretKey,
|
|
|
"publicKey": publicKey])
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
@objc func setKey(_ call: CAPPluginCall) {
|
|
|
let secretKey = call.getString("secretKey")
|
|
|
let publicKey = call.getString("publicKey")
|
|
|
@@ -156,16 +155,16 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
|
|
|
ENCRYPTION_SECRET_KEY = secretKey
|
|
|
ENCRYPTION_PUBLIC_KEY = publicKey
|
|
|
FNAME_ENCRYPTION_KEY = AgeEncryption.toRawX25519Key(secretKey)
|
|
|
-
|
|
|
+
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
@objc func setEnv(_ call: CAPPluginCall) {
|
|
|
guard let env = call.getString("env") else {
|
|
|
call.reject("required parameter: env")
|
|
|
return
|
|
|
}
|
|
|
self.setKey(call)
|
|
|
-
|
|
|
+
|
|
|
switch env {
|
|
|
case "production", "product", "prod":
|
|
|
URL_BASE = URL(string: "https://api.logseq.com/file-sync/")!
|
|
|
@@ -179,11 +178,11 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
|
|
|
call.reject("invalid env: \(env)")
|
|
|
return
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
self.debugNotification(["event": "setenv:\(env)"])
|
|
|
call.resolve(["ok": true])
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
@objc func encryptFnames(_ call: CAPPluginCall) {
|
|
|
guard fnameEncryptionEnabled() else {
|
|
|
call.reject("fname encryption key not set")
|
|
|
@@ -193,7 +192,7 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
|
|
|
call.reject("required parameters: filePaths")
|
|
|
return
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
let nFiles = fnames.count
|
|
|
fnames = fnames.compactMap { $0.removingPercentEncoding!.fnameEncrypt(rawKey: FNAME_ENCRYPTION_KEY!) }
|
|
|
if fnames.count != nFiles {
|
|
|
@@ -201,7 +200,7 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
|
|
|
}
|
|
|
call.resolve(["value": fnames])
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
@objc func decryptFnames(_ call: CAPPluginCall) {
|
|
|
guard fnameEncryptionEnabled() else {
|
|
|
call.reject("fname encryption key not set")
|
|
|
@@ -259,7 +258,7 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
@objc func getLocalFilesMeta(_ call: CAPPluginCall) {
|
|
|
// filePaths are url encoded
|
|
|
guard let basePath = call.getString("basePath"),
|
|
|
@@ -271,7 +270,7 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
|
|
|
call.reject("invalid basePath")
|
|
|
return
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
var fileMetadataDict: [String: [String: Any]] = [:]
|
|
|
for percentFilePath in filePaths {
|
|
|
let filePath = percentFilePath.removingPercentEncoding!
|
|
|
@@ -283,24 +282,24 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
|
|
|
if fnameEncryptionEnabled() {
|
|
|
metaObj["encryptedFname"] = filePath.fnameEncrypt(rawKey: FNAME_ENCRYPTION_KEY!)
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
fileMetadataDict[percentFilePath] = metaObj
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
call.resolve(["result": fileMetadataDict])
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
@objc func getLocalAllFilesMeta(_ call: CAPPluginCall) {
|
|
|
guard let basePath = call.getString("basePath"),
|
|
|
let baseURL = URL(string: basePath) else {
|
|
|
call.reject("invalid basePath")
|
|
|
return
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
var fileMetadataDict: [String: [String: Any]] = [:]
|
|
|
if let enumerator = FileManager.default.enumerator(at: baseURL, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsPackageDescendants, .skipsHiddenFiles]) {
|
|
|
-
|
|
|
+
|
|
|
for case let fileURL as URL in enumerator {
|
|
|
if !fileURL.isSkipped() {
|
|
|
if let meta = SyncMetadata(of: fileURL) {
|
|
|
@@ -320,8 +319,8 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
|
|
|
}
|
|
|
call.resolve(["result": fileMetadataDict])
|
|
|
}
|
|
|
-
|
|
|
-
|
|
|
+
|
|
|
+
|
|
|
@objc func renameLocalFile(_ call: CAPPluginCall) {
|
|
|
guard let basePath = call.getString("basePath"),
|
|
|
let baseURL = URL(string: basePath) else {
|
|
|
@@ -336,10 +335,10 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
|
|
|
call.reject("invalid to file")
|
|
|
return
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
let fromUrl = baseURL.appendingPathComponent(from.removingPercentEncoding!)
|
|
|
let toUrl = baseURL.appendingPathComponent(to.removingPercentEncoding!)
|
|
|
-
|
|
|
+
|
|
|
do {
|
|
|
try FileManager.default.moveItem(at: fromUrl, to: toUrl)
|
|
|
} catch {
|
|
|
@@ -347,23 +346,23 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
|
|
|
return
|
|
|
}
|
|
|
call.resolve(["ok": true])
|
|
|
-
|
|
|
+
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
@objc func deleteLocalFiles(_ call: CAPPluginCall) {
|
|
|
guard let baseURL = call.getString("basePath").flatMap({path in URL(string: path)}),
|
|
|
let filePaths = call.getArray("filePaths") as? [String] else {
|
|
|
call.reject("required paremeters: basePath, filePaths")
|
|
|
return
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
for filePath in filePaths {
|
|
|
let fileUrl = baseURL.appendingPathComponent(filePath.removingPercentEncoding!)
|
|
|
try? FileManager.default.removeItem(at: fileUrl) // ignore any delete errors
|
|
|
}
|
|
|
call.resolve(["ok": true])
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
/// remote -> local
|
|
|
@objc func updateLocalFiles(_ call: CAPPluginCall) {
|
|
|
guard let baseURL = call.getString("basePath").flatMap({path in URL(string: path)}),
|
|
|
@@ -373,7 +372,7 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
|
|
|
call.reject("required paremeters: basePath, filePaths, graphUUID, token")
|
|
|
return
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
// [encrypted-fname: original-fname]
|
|
|
var encryptedFilePathDict: [String: String] = [:]
|
|
|
if fnameEncryptionEnabled() {
|
|
|
@@ -387,49 +386,77 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
|
|
|
} else {
|
|
|
encryptedFilePathDict = Dictionary(uniqueKeysWithValues: filePaths.map { ($0, $0) })
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
let encryptedFilePaths = Array(encryptedFilePathDict.keys)
|
|
|
-
|
|
|
+
|
|
|
let client = SyncClient(token: token, graphUUID: graphUUID)
|
|
|
client.delegate = self // receives notification
|
|
|
-
|
|
|
+
|
|
|
client.getFiles(at: encryptedFilePaths) { (fileURLs, error) in
|
|
|
- if let error = error {
|
|
|
- print("debug getFiles error \(error)")
|
|
|
+ guard error == nil else {
|
|
|
+ print("debug getFiles error \(String(describing: error))")
|
|
|
self.debugNotification(["event": "download:error", "data": ["message": "error while getting files \(filePaths)"]])
|
|
|
- call.reject(error.localizedDescription)
|
|
|
- } else {
|
|
|
- // handle multiple completionHandlers
|
|
|
- let group = DispatchGroup()
|
|
|
-
|
|
|
- var downloaded: [String] = []
|
|
|
-
|
|
|
- for (encryptedFilePath, remoteFileURL) in fileURLs {
|
|
|
- group.enter()
|
|
|
-
|
|
|
- let filePath = encryptedFilePathDict[encryptedFilePath]!
|
|
|
- // NOTE: fileURLs from getFiles API is percent-encoded
|
|
|
- let localFileURL = baseURL.appendingPathComponent(filePath.removingPercentEncoding!)
|
|
|
- remoteFileURL.download(toFile: localFileURL) {error in
|
|
|
- if let error = error {
|
|
|
- self.debugNotification(["event": "download:error", "data": ["message": "error while downloading \(filePath): \(error)"]])
|
|
|
- print("debug download \(error) in \(filePath)")
|
|
|
- } else {
|
|
|
+ call.reject(error!.localizedDescription)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ // handle multiple completionHandlers
|
|
|
+ let group = DispatchGroup()
|
|
|
+
|
|
|
+ var downloaded: [String] = []
|
|
|
+
|
|
|
+ for (encryptedFilePath, remoteFileURL) in fileURLs {
|
|
|
+ group.enter()
|
|
|
+
|
|
|
+ let filePath = encryptedFilePathDict[encryptedFilePath]!
|
|
|
+ // NOTE: fileURLs from getFiles API is percent-encoded
|
|
|
+ let localFileURL = baseURL.appendingPathComponent(filePath.removingPercentEncoding!)
|
|
|
+
|
|
|
+ let progressHandler = {(fraction: Double) in
|
|
|
+ self.debugNotification(["event": "download:progress",
|
|
|
+ "data": ["filePath": filePath,
|
|
|
+ "fraction": fraction]])
|
|
|
+ }
|
|
|
+
|
|
|
+ client.download(url: remoteFileURL, progressHandler: progressHandler) {result in
|
|
|
+ switch result {
|
|
|
+ case .failure(let error):
|
|
|
+ self.debugNotification(["event": "download:error", "data": ["message": "error while downloading \(filePath): \(error)"]])
|
|
|
+ print("debug download \(error) in \(filePath)")
|
|
|
+ case .success(let tempURL):
|
|
|
+ do {
|
|
|
+ // Remove any existing document at file
|
|
|
+ if FileManager.default.fileExists(atPath: localFileURL.path) {
|
|
|
+ try FileManager.default.removeItem(at: localFileURL)
|
|
|
+ } else {
|
|
|
+ let parentURL = localFileURL.deletingLastPathComponent()
|
|
|
+ try FileManager.default.createDirectory(at: parentURL, withIntermediateDirectories: true, attributes: nil)
|
|
|
+ }
|
|
|
+ let rawData = try Data(contentsOf: tempURL!)
|
|
|
+ guard let decryptedRawData = maybeDecrypt(rawData) else {
|
|
|
+ throw NSError(domain: FileSyncErrorDomain, code: 0, userInfo: [NSLocalizedDescriptionKey: "can not decrypt downloaded file"])
|
|
|
+ }
|
|
|
+ try decryptedRawData.write(to: localFileURL, options: .atomic)
|
|
|
+
|
|
|
self.debugNotification(["event": "download:file", "data": ["file": filePath]])
|
|
|
+
|
|
|
downloaded.append(filePath)
|
|
|
+ } catch {
|
|
|
+ // Handle potential file system errors
|
|
|
+ self.debugNotification(["event": "download:error", "data": ["message": "error while downloading \(filePath): \(error)"]])
|
|
|
+ print("debug download \(error) in \(filePath)")
|
|
|
}
|
|
|
- group.leave()
|
|
|
}
|
|
|
+
|
|
|
+ group.leave()
|
|
|
}
|
|
|
- group.notify(queue: .main) {
|
|
|
- self.debugNotification(["event": "download:done"])
|
|
|
- call.resolve(["ok": true, "data": downloaded])
|
|
|
- }
|
|
|
-
|
|
|
+ }
|
|
|
+ group.notify(queue: .main) {
|
|
|
+ self.debugNotification(["event": "download:done"])
|
|
|
+ call.resolve(["ok": true, "data": downloaded])
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
@objc func updateLocalVersionFiles(_ call: CAPPluginCall) {
|
|
|
guard let baseURL = call.getString("basePath").flatMap({path in URL(string: path)}),
|
|
|
let filePaths = call.getArray("filePaths") as? [String],
|
|
|
@@ -440,7 +467,7 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
|
|
|
}
|
|
|
let client = SyncClient(token: token, graphUUID: graphUUID)
|
|
|
client.delegate = self // receives notification
|
|
|
-
|
|
|
+
|
|
|
client.getVersionFiles(at: filePaths) { (fileURLDict, error) in
|
|
|
if let error = error {
|
|
|
print("debug getVersionFiles error \(error)")
|
|
|
@@ -449,12 +476,12 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
|
|
|
} else {
|
|
|
// handle multiple completionHandlers
|
|
|
let group = DispatchGroup()
|
|
|
-
|
|
|
+
|
|
|
var downloaded: [String] = []
|
|
|
-
|
|
|
+
|
|
|
for (filePath, remoteFileURL) in fileURLDict {
|
|
|
group.enter()
|
|
|
-
|
|
|
+
|
|
|
// NOTE: fileURLs from getFiles API is percent-encoded
|
|
|
let localFileURL = baseURL.appendingPathComponent("logseq/version-files/").appendingPathComponent(filePath.removingPercentEncoding!)
|
|
|
remoteFileURL.download(toFile: localFileURL) {error in
|
|
|
@@ -472,11 +499,11 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
|
|
|
self.debugNotification(["event": "version-download:done"])
|
|
|
call.resolve(["ok": true, "data": downloaded])
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
// filePaths: Encrypted file paths
|
|
|
@objc func deleteRemoteFiles(_ call: CAPPluginCall) {
|
|
|
guard var filePaths = call.getArray("filePaths") as? [String],
|
|
|
@@ -490,7 +517,7 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
|
|
|
call.reject("empty filePaths")
|
|
|
return
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
let nFiles = filePaths.count
|
|
|
if fnameEncryptionEnabled() {
|
|
|
filePaths = filePaths.compactMap { $0.removingPercentEncoding!.fnameEncrypt(rawKey: FNAME_ENCRYPTION_KEY!) }
|
|
|
@@ -498,7 +525,7 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
|
|
|
if filePaths.count != nFiles {
|
|
|
call.reject("cannot encrypt all file names")
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
let client = SyncClient(token: token, graphUUID: graphUUID, txid: txid)
|
|
|
client.deleteFiles(filePaths) { txid, error in
|
|
|
guard error == nil else {
|
|
|
@@ -512,7 +539,7 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
|
|
|
call.resolve(["ok": true, "txid": txid])
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
/// local -> remote
|
|
|
@objc func updateRemoteFiles(_ call: CAPPluginCall) {
|
|
|
guard let baseURL = call.getString("basePath").flatMap({path in URL(string: path)}),
|
|
|
@@ -524,14 +551,14 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
|
|
|
return
|
|
|
}
|
|
|
let fnameEncryption = call.getBool("fnameEncryption") ?? false // default to false
|
|
|
-
|
|
|
+
|
|
|
guard !filePaths.isEmpty else {
|
|
|
return call.reject("empty filePaths")
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
let client = SyncClient(token: token, graphUUID: graphUUID, txid: txid)
|
|
|
client.delegate = self
|
|
|
-
|
|
|
+
|
|
|
// 1. refresh_temp_credential
|
|
|
client.getTempCredential() { (credentials, error) in
|
|
|
guard error == nil else {
|
|
|
@@ -539,16 +566,21 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
|
|
|
call.reject("error(getTempCredential): \(error!)")
|
|
|
return
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
var files: [String: URL] = [:]
|
|
|
for filePath in filePaths {
|
|
|
// NOTE: filePath from js may contain spaces
|
|
|
let fileURL = baseURL.appendingPathComponent(filePath.removingPercentEncoding!)
|
|
|
files[filePath] = fileURL
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
// 2. upload_temp_file
|
|
|
- client.uploadTempFiles(files, credentials: credentials!) { (uploadedFileKeyDict, fileMd5Dict, error) in
|
|
|
+ let progressHandler = {(filePath: String, fraction: Double) in
|
|
|
+ self.debugNotification(["event": "upload:progress",
|
|
|
+ "data": ["filePath": filePath,
|
|
|
+ "fraction": fraction]])
|
|
|
+ }
|
|
|
+ client.uploadTempFiles(files, credentials: credentials!, progressHandler: progressHandler) { (uploadedFileKeyDict, fileMd5Dict, error) in
|
|
|
guard error == nil else {
|
|
|
self.debugNotification(["event": "upload:error", "data": ["message": "error while uploading temp files: \(error!)"]])
|
|
|
call.reject("error(uploadTempFiles): \(error!)")
|
|
|
@@ -560,10 +592,10 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
|
|
|
call.reject("no file to update")
|
|
|
return
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
// encrypted-file-name: (file-key, md5)
|
|
|
var uploadedFileKeyMd5Dict: [String: [String]] = [:]
|
|
|
-
|
|
|
+
|
|
|
if fnameEncryptionEnabled() && fnameEncryption {
|
|
|
for (filePath, fileKey) in uploadedFileKeyDict {
|
|
|
guard let encryptedFilePath = filePath.removingPercentEncoding!.fnameEncrypt(rawKey: FNAME_ENCRYPTION_KEY!) else {
|
|
|
@@ -593,4 +625,5 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
}
|