202 lines
7.8 KiB
Swift
202 lines
7.8 KiB
Swift
//
|
|
// Copyright 2019 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Foundation
|
|
|
|
open class CDNDownloadOperation: OWSOperation {
|
|
|
|
// MARK: - Dependencies
|
|
|
|
private var cdn0urlSession: OWSURLSessionProtocol {
|
|
signalService.urlSessionForCdn(cdnNumber: 0)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private let _task = AtomicOptional<URLSessionTask>(nil, lock: .sharedGlobal)
|
|
private var task: URLSessionTask? {
|
|
get {
|
|
_task.get()
|
|
}
|
|
set {
|
|
_task.set(newValue)
|
|
}
|
|
}
|
|
|
|
public override init() {
|
|
super.init()
|
|
|
|
self.remainingRetries = 4
|
|
}
|
|
|
|
deinit {
|
|
task?.cancel()
|
|
}
|
|
|
|
let kMaxStickerDataDownloadSize: UInt = 1000 * 1000
|
|
let kMaxStickerPackDownloadSize: UInt = 1000 * 1000
|
|
|
|
public func tryToDownload(urlPath: String, maxDownloadSize: UInt?) throws -> Promise<URL> {
|
|
guard !isCorrupt(urlPath: urlPath) else {
|
|
Logger.warn("Skipping download of corrupt data.")
|
|
throw StickerError.corruptData
|
|
}
|
|
|
|
// We use a separate promise so that we can cancel from the progress block.
|
|
let (promise, future) = Promise<URL>.pending()
|
|
|
|
let hasCheckedContentLength = AtomicBool(false, lock: .sharedGlobal)
|
|
firstly(on: DispatchQueue.global()) { () -> Promise<OWSUrlDownloadResponse> in
|
|
let headers = ["Content-Type": MimeType.applicationOctetStream.rawValue]
|
|
let urlSession = self.cdn0urlSession
|
|
return urlSession.downloadTaskPromise(urlPath,
|
|
method: .get,
|
|
headers: headers) { [weak self] (task: URLSessionTask, progress: Progress) in
|
|
guard let self = self else {
|
|
return
|
|
}
|
|
self.task = task
|
|
self.handleDownloadProgress(task: task,
|
|
progress: progress,
|
|
future: future,
|
|
maxDownloadSize: maxDownloadSize,
|
|
hasCheckedContentLength: hasCheckedContentLength)
|
|
}
|
|
}.map(on: DispatchQueue.global()) { [weak self] (response: OWSUrlDownloadResponse) -> Void in
|
|
guard self != nil else {
|
|
throw OWSAssertionError("Operation has been deallocated.")
|
|
}
|
|
let downloadUrl = response.downloadUrl
|
|
guard let fileSize = OWSFileSystem.fileSize(of: downloadUrl) else {
|
|
owsFailDebug("Couldn't determine file size.")
|
|
throw SSKUnretryableError.stickerMissingFile
|
|
}
|
|
if let maxDownloadSize = maxDownloadSize {
|
|
guard fileSize.uint64Value <= maxDownloadSize else {
|
|
owsFailDebug("Download length exceeds max size.")
|
|
throw SSKUnretryableError.stickerOversizeFile
|
|
}
|
|
}
|
|
|
|
do {
|
|
let temporaryFileUrl = OWSFileSystem.temporaryFileUrl(isAvailableWhileDeviceLocked: true)
|
|
try OWSFileSystem.moveFile(from: downloadUrl, to: temporaryFileUrl)
|
|
future.resolve(temporaryFileUrl)
|
|
} catch {
|
|
owsFailDebug("Could not move to temporary file: \(error)")
|
|
// Fail immediately; do not retry.
|
|
throw SSKUnretryableError.downloadCouldNotMoveFile
|
|
}
|
|
}.catch(on: DispatchQueue.global()) { (error: Error) in
|
|
Logger.warn("Download failed: \(error)")
|
|
future.reject(error)
|
|
}
|
|
|
|
return promise
|
|
}
|
|
|
|
public func tryToDownload(urlPath: String, maxDownloadSize: UInt?) throws -> Promise<Data> {
|
|
return try tryToDownload(urlPath: urlPath, maxDownloadSize: maxDownloadSize).map { (downloadUrl: URL) in
|
|
do {
|
|
let data = try Data(contentsOf: downloadUrl)
|
|
try OWSFileSystem.deleteFile(url: downloadUrl)
|
|
return data
|
|
} catch {
|
|
owsFailDebug("Could not load data failed: \(error)")
|
|
// Fail immediately; do not retry.
|
|
throw SSKUnretryableError.downloadCouldNotDeleteFile
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleDownloadProgress(task: URLSessionTask,
|
|
progress: Progress,
|
|
future: Future<URL>,
|
|
maxDownloadSize: UInt?,
|
|
hasCheckedContentLength: AtomicBool) {
|
|
// Don't do anything until we've received at least one byte of data.
|
|
guard progress.completedUnitCount > 0 else {
|
|
return
|
|
}
|
|
|
|
let abortDownload = { (message: String) -> Void in
|
|
owsFailDebug(message)
|
|
future.reject(StickerError.assertionFailure)
|
|
task.cancel()
|
|
}
|
|
|
|
// A malicious service might send a misleading content length header,
|
|
// so....
|
|
//
|
|
// If the current downloaded bytes or the expected total byes
|
|
// exceed the max download size, abort the download.
|
|
if let maxDownloadSize = maxDownloadSize {
|
|
guard progress.totalUnitCount <= maxDownloadSize,
|
|
progress.completedUnitCount <= maxDownloadSize else {
|
|
return abortDownload("Attachment download exceed expected content length: \(progress.totalUnitCount), \(progress.completedUnitCount).")
|
|
}
|
|
}
|
|
|
|
// We only need to check the content length header once.
|
|
guard !hasCheckedContentLength.get() else {
|
|
return
|
|
}
|
|
|
|
// Once we've received some bytes of the download, check the content length
|
|
// header for the download.
|
|
//
|
|
// If the task doesn't exist, or doesn't have a response, or is missing
|
|
// the expected headers, or has an invalid or oversize content length, etc.,
|
|
// abort the download.
|
|
guard let httpResponse = task.response as? HTTPURLResponse else {
|
|
return abortDownload("Invalid or missing response.")
|
|
}
|
|
guard let headers = httpResponse.allHeaderFields as? [String: Any] else {
|
|
return abortDownload("Invalid response headers.")
|
|
}
|
|
guard let contentLengthString = headers["Content-Length"] as? String else {
|
|
return abortDownload("Invalid or missing content length.")
|
|
}
|
|
guard let contentLength = Int64(contentLengthString) else {
|
|
return abortDownload("Invalid content length.")
|
|
}
|
|
if let maxDownloadSize = maxDownloadSize {
|
|
guard contentLength < maxDownloadSize else {
|
|
return abortDownload("Content length exceeds max download size.")
|
|
}
|
|
}
|
|
|
|
// This response has a valid content length that is less
|
|
// than our max download size. Proceed with the download.
|
|
hasCheckedContentLength.set(true)
|
|
}
|
|
|
|
override public func retryInterval() -> TimeInterval {
|
|
return OWSOperation.retryIntervalForExponentialBackoff(failureCount: errorCount)
|
|
}
|
|
|
|
// MARK: - Corrupt Data
|
|
|
|
// We track corrupt downloads, to avoid retrying them more than once per launch.
|
|
//
|
|
// TODO: We could persist this state.
|
|
private static let serialQueue = DispatchQueue(label: "org.signal.cdn-download")
|
|
private static var corruptDataKeys = Set<String>()
|
|
|
|
func markUrlPathAsCorrupt(_ urlPath: String) {
|
|
_ = CDNDownloadOperation.serialQueue.sync {
|
|
CDNDownloadOperation.corruptDataKeys.insert(urlPath)
|
|
}
|
|
}
|
|
|
|
func isCorrupt(urlPath: String) -> Bool {
|
|
var result = false
|
|
CDNDownloadOperation.serialQueue.sync {
|
|
result = CDNDownloadOperation.corruptDataKeys.contains(urlPath)
|
|
}
|
|
return result
|
|
}
|
|
}
|