session-ios/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift

317 lines
14 KiB
Swift

// 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,
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),
var 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
attachment = GRDBStorage.shared
.write { db in
try attachment
.with(state: .downloading)
.saved(db)
}
.defaulting(to: attachment)
let temporaryFileUrl: URL = URL(
fileURLWithPath: OWSTemporaryDirectoryAccessibleAfterFirstAuth() + UUID().uuidString
)
let downloadPromise: Promise<Data> = {
guard
let downloadUrl: String = attachment.downloadUrl,
let fileAsString: String = downloadUrl.split(separator: "/").last.map({ String($0) }),
let file: UInt64 = UInt64(fileAsString)
else {
return Promise(error: AttachmentDownloadError.invalidUrl)
}
if let openGroup: OpenGroup = GRDBStorage.shared.read({ db in try OpenGroup.fetchOne(db, id: threadId) }) {
return OpenGroupAPIV2.download(file, from: openGroup.room, on: openGroup.server)
}
return FileServerAPIV2.download(file, useOldServer: downloadUrl.contains(FileServerAPIV2.oldServer))
}()
downloadPromise
.then { data -> Promise<Void> 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 {
// Remove the temporary file
OWSFileSystem.deleteFile(temporaryFileUrl.path)
// Update the attachment state
GRDBStorage.shared.write { db in
try attachment
.with(
state: .downloaded,
creationTimestamp: Date().timeIntervalSince1970,
localRelativeFilePath: attachment.originalFilePath?
.substring(from: (Attachment.attachmentsFolder.count + 1)) // Leading forward slash
)
.save(db)
}
success(job, false)
}
.catch { error in
OWSFileSystem.deleteFile(temporaryFileUrl.path)
switch error {
case OnionRequestAPI.Error.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
GRDBStorage.shared.write { db in
try attachment
.with(state: .failed)
.save(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"
}
}
}
}
// TODO: MessageInvalidator.invalidate(tsMessage, with: transaction)
// public let attachmentID: String
// public let tsMessageID: String
// public let threadID: String
// public var delegate: JobDelegate?
// public var id: String?
// public var failureCount: UInt = 0
// public var isDeferred = false
//
// public enum Error : LocalizedError {
// case noAttachment
// case invalidURL
//
// public var errorDescription: String? {
// switch self {
// case .noAttachment: return "No such attachment."
// case .invalidURL: return "Invalid file URL."
// }
// }
// }
//
// // MARK: Settings
// public class var collection: String { return "AttachmentDownloadJobCollection" }
// public static let maxFailureCount: UInt = 20
//
// // MARK: Initialization
// public init(attachmentID: String, tsMessageID: String, threadID: String) {
// self.attachmentID = attachmentID
// self.tsMessageID = tsMessageID
// self.threadID = threadID
// }
//
// // MARK: Coding
// public init?(coder: NSCoder) {
// guard let attachmentID = coder.decodeObject(forKey: "attachmentID") as! String?,
// let tsMessageID = coder.decodeObject(forKey: "tsIncomingMessageID") as! String?,
// let threadID = coder.decodeObject(forKey: "threadID") as! String?,
// let id = coder.decodeObject(forKey: "id") as! String? else { return nil }
// self.attachmentID = attachmentID
// self.tsMessageID = tsMessageID
// self.threadID = threadID
// self.id = id
// self.failureCount = coder.decodeObject(forKey: "failureCount") as! UInt? ?? 0
// self.isDeferred = coder.decodeBool(forKey: "isDeferred")
// }
//
// public func encode(with coder: NSCoder) {
// coder.encode(attachmentID, forKey: "attachmentID")
// coder.encode(tsMessageID, forKey: "tsIncomingMessageID")
// coder.encode(threadID, forKey: "threadID")
// coder.encode(id, forKey: "id")
// coder.encode(failureCount, forKey: "failureCount")
// coder.encode(isDeferred, forKey: "isDeferred")
// }
//
// // MARK: Running
// public func execute() {
// if let id = id {
// JobQueue.currentlyExecutingJobs.insert(id)
// }
// guard !isDeferred else { return }
// if TSAttachment.fetch(uniqueId: attachmentID) is TSAttachmentStream {
// // FIXME: It's not clear * how * this happens, but apparently we can get to this point
// // from time to time with an already downloaded attachment.
// return handleSuccess()
// }
// guard let pointer = TSAttachment.fetch(uniqueId: attachmentID) as? TSAttachmentPointer else {
// return handleFailure(error: Error.noAttachment)
// }
// let storage = SNMessagingKitConfiguration.shared.storage
// storage.write(with: { transaction in
// storage.setAttachmentState(to: .downloading, for: pointer, associatedWith: self.tsMessageID, using: transaction)
// }, completion: { })
// let temporaryFilePath = URL(fileURLWithPath: OWSTemporaryDirectoryAccessibleAfterFirstAuth() + UUID().uuidString)
// let handleFailure: (Swift.Error) -> Void = { error in // Intentionally capture self
// OWSFileSystem.deleteFile(temporaryFilePath.absoluteString)
// if let error = error as? Error, case .noAttachment = error {
// storage.write(with: { transaction in
// storage.setAttachmentState(to: .failed, for: pointer, associatedWith: self.tsMessageID, using: transaction)
// }, completion: { })
// self.handlePermanentFailure(error: error)
// } else if let error = error as? OnionRequestAPI.Error, case .httpRequestFailedAtDestination(let statusCode, _, _) = error,
// statusCode == 400 {
// // Otherwise, the attachment will show a state of downloading forever,
// // and the message won't be able to be marked as read.
// storage.write(with: { transaction in
// storage.setAttachmentState(to: .failed, for: pointer, associatedWith: self.tsMessageID, using: transaction)
// }, completion: { })
// // This usually indicates a file that has expired on the server, so there's no need to retry.
// self.handlePermanentFailure(error: error)
// } else {
// self.handleFailure(error: error)
// }
// }
// if let tsMessage = TSMessage.fetch(uniqueId: tsMessageID), let v2OpenGroup = storage.getV2OpenGroup(for: tsMessage.uniqueThreadId) {
// guard let fileAsString = pointer.downloadURL.split(separator: "/").last, let file = UInt64(fileAsString) else {
// return handleFailure(Error.invalidURL)
// }
// OpenGroupAPIV2.download(file, from: v2OpenGroup.room, on: v2OpenGroup.server).done(on: DispatchQueue.global(qos: .userInitiated)) { data in
// self.handleDownloadedAttachment(data: data, temporaryFilePath: temporaryFilePath, pointer: pointer, failureHandler: handleFailure)
// }.catch(on: DispatchQueue.global()) { error in
// handleFailure(error)
// }
// } else {
// guard let fileAsString = pointer.downloadURL.split(separator: "/").last, let file = UInt64(fileAsString) else {
// return handleFailure(Error.invalidURL)
// }
// let useOldServer = pointer.downloadURL.contains(FileServerAPIV2.oldServer)
// FileServerAPIV2.download(file, useOldServer: useOldServer).done(on: DispatchQueue.global(qos: .userInitiated)) { data in
// self.handleDownloadedAttachment(data: data, temporaryFilePath: temporaryFilePath, pointer: pointer, failureHandler: handleFailure)
// }.catch(on: DispatchQueue.global()) { error in
// handleFailure(error)
// }
// }
// }
//
// private func handleDownloadedAttachment(data: Data, temporaryFilePath: URL, pointer: TSAttachmentPointer, failureHandler: (Swift.Error) -> Void) {
// let storage = SNMessagingKitConfiguration.shared.storage
// do {
// try data.write(to: temporaryFilePath, options: .atomic)
// } catch {
// return failureHandler(error)
// }
// let plaintext: Data
// if let key = pointer.encryptionKey, let digest = pointer.digest, key.count > 0 && digest.count > 0 {
// do {
// plaintext = try Cryptography.decryptAttachment(data, withKey: key, digest: digest, unpaddedSize: pointer.byteCount)
// } catch {
// return failureHandler(error)
// }
// } else {
// plaintext = data // Open group attachments are unencrypted
// }
// let stream = TSAttachmentStream(pointer: pointer)
// do {
// try stream.write(plaintext)
// } catch {
// return failureHandler(error)
// }
// OWSFileSystem.deleteFile(temporaryFilePath.absoluteString)
// storage.write(with: { transaction in
// storage.persist(stream, associatedWith: self.tsMessageID, using: transaction)
// }, completion: {
// self.handleSuccess()
// })
// }
//
// private func handleSuccess() {
// delegate?.handleJobSucceeded(self)
// }
//
// private func handlePermanentFailure(error: Swift.Error) {
// delegate?.handleJobFailedPermanently(self, with: error)
// }
//
// private func handleFailure(error: Swift.Error) {
// delegate?.handleJobFailed(self, with: error)
// }
//}