// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation import PromiseKit import SessionUtilitiesKit import SessionSnodeKit import SignalCoreKit public enum AttachmentDownloadJob: JobExecutor { public static var maxFailureCount: Int = 10 public static var requiresThreadId: Bool = true public static let requiresInteractionId: Bool = true public static func run( _ job: Job, queue: DispatchQueue, success: @escaping (Job, Bool) -> (), failure: @escaping (Job, Error?, Bool) -> (), deferred: @escaping (Job) -> () ) { guard let threadId: String = job.threadId, let detailsData: Data = job.details, let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData), let attachment: Attachment = GRDBStorage.shared .read({ db in try Attachment.fetchOne(db, id: details.attachmentId) }) else { failure(job, JobRunnerError.missingRequiredDetails, false) return } // Due to the complex nature of jobs and how attachments can be reused it's possible for // and AttachmentDownloadJob to get created for an attachment which has already been // downloaded/uploaded so in those cases just succeed immediately guard attachment.state != .downloaded && attachment.state != .uploaded else { success(job, false) return } // Update to the 'downloading' state (no need to update the 'attachment' instance) GRDBStorage.shared.write { db in try Attachment .filter(id: attachment.id) .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.downloading)) } let temporaryFileUrl: URL = URL( fileURLWithPath: OWSTemporaryDirectoryAccessibleAfterFirstAuth() + UUID().uuidString ) let downloadPromise: Promise = { guard let downloadUrl: String = attachment.downloadUrl, let fileAsString: String = downloadUrl.split(separator: "/").last.map({ String($0) }), let file: Int64 = Int64(fileAsString) else { return Promise(error: AttachmentDownloadError.invalidUrl) } let maybeOpenGroupDownloadPromise: Promise? = GRDBStorage.shared.read({ db in guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { return nil // Not an open group so just use standard FileServer upload } return OpenGroupAPI.downloadFile(db, fileId: file, from: openGroup.roomToken, on: openGroup.server) .map { _, data in data } }) return ( maybeOpenGroupDownloadPromise ?? FileServerAPI.download(file, useOldServer: downloadUrl.contains(FileServerAPI.oldServer)) ) }() downloadPromise .then(on: queue) { data -> Promise in try data.write(to: temporaryFileUrl, options: .atomic) let plaintext: Data = try { guard let key: Data = attachment.encryptionKey, let digest: Data = attachment.digest, key.count > 0, digest.count > 0 else { return data } // Open group attachments are unencrypted return try Cryptography.decryptAttachment( data, withKey: key, digest: digest, unpaddedSize: UInt32(attachment.byteCount) ) }() guard try attachment.write(data: plaintext) else { throw AttachmentDownloadError.failedToSaveFile } return Promise.value(()) } .done(on: queue) { // Remove the temporary file OWSFileSystem.deleteFile(temporaryFileUrl.path) /// Update the attachment state /// /// **Note:** We **MUST** use the `'with()` function here as it will update the /// `isValid` and `duration` values based on the downloaded data and the state GRDBStorage.shared.write { db in _ = try attachment .with( state: .downloaded, creationTimestamp: Date().timeIntervalSince1970, localRelativeFilePath: ( attachment.localRelativeFilePath ?? Attachment.localRelativeFilePath(from: attachment.originalFilePath) ) ) .saved(db) } success(job, false) } .catch(on: queue) { error in OWSFileSystem.deleteFile(temporaryFileUrl.path) switch error { case OnionRequestAPIError.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 400: /// Otherwise, the attachment will show a state of downloading forever, and the message /// won't be able to be marked as read /// /// **Note:** We **MUST** use the `'with()` function here as it will update the /// `isValid` and `duration` values based on the downloaded data and the state GRDBStorage.shared.write { db in _ = try attachment .with(state: .failedDownload) .saved(db) } // This usually indicates a file that has expired on the server, so there's no need to retry failure(job, error, true) default: failure(job, error, false) } } } } // MARK: - AttachmentDownloadJob.Details extension AttachmentDownloadJob { public struct Details: Codable { public let attachmentId: String public init(attachmentId: String) { self.attachmentId = attachmentId } } public enum AttachmentDownloadError: LocalizedError { case failedToSaveFile case invalidUrl public var errorDescription: String? { switch self { case .failedToSaveFile: return "Failed to save file" case .invalidUrl: return "Invalid file URL" } } } }