//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public enum Upload {
public static let uploadQueue = ConcurrentTaskQueue(concurrentLimit: CurrentAppContext().isNSE ? 2 : 12)
public enum Constants {
public static let attachmentUploadProgressNotification = NSNotification.Name("AttachmentUploadProgressNotification")
public static let uploadProgressKey = "UploadProgressKey"
public static let uploadAttachmentIDKey = "UploadAttachmentIDKey"
/// If within this window, we can reause existing attachment transit tier uploads for resending.
public static let uploadReuseWindow: TimeInterval = 3 * .day
public static let uploadFormReuseWindow: TimeInterval = 6 * .day
public static let maxUploadAttempts = 5
}
public enum FormSource {
case remote
case local(Upload.Form)
}
public struct Form: Codable {
private enum CodingKeys: String, CodingKey {
case headers = "headers"
case signedUploadLocation = "signedUploadLocation"
case cdnKey = "key"
case cdnNumber = "cdn"
}
let headers: HttpHeaders
let signedUploadLocation: String
let cdnKey: String
let cdnNumber: UInt32
}
// MARK: -
public enum FailureMode: Equatable {
public enum RetryMode: Equatable {
/// This was a temporary failure, such as a network
/// timeout, so an immediate retry should be possible
case immediately
/// The remote server sent back a retry-after header that should be honored
/// when attempting a backoff before retry
case afterServerRequestedDelay(TimeInterval)
/// An error was encountered and the server didn't provide a backoff, so
/// use the internal exponential backoff
case afterBackoff
}
// The overall upload has hit the max number of retries.
case noMoreRetries
// Attempt to resume the current upload from the last known good state.
case resume(RetryMode)
// Restart the upload by discarding any current upload progres and
// fetching a new upload form.
case restart(RetryMode)
}
public enum ResumeProgress {
// There was an issue with the resume data, discard the current upload
// form and restart the upload with a new form
case restart
// The endpoint reported a complete upload.
case complete
// Contains the number of bytes the upload endpoint has received. This
// can be 0 bytes, which is effectively a new upload, but can use the
// existing upload form.
case uploaded(Int)
}
public enum Error: Swift.Error, LocalizedError, Equatable {
case invalidUploadURL
case networkError
case networkTimeout
case uploadFailure(recovery: FailureMode)
case partialUpload(bytesUploaded: UInt32)
case unsupportedEndpoint
case unexpectedResponseStatusCode(Int)
case missingFile
case unknown
public var errorDescription: String? {
localizedDescription
}
public var localizedDescription: String {
return OWSLocalizedString(
"ERROR_MESSAGE_ATTACHMENT_UPLOAD_FAILED",
comment: "Error message indicating that attachment upload(s) failed.",
)
}
}
public struct EncryptedBackupUploadMetadata: UploadMetadata {
/// When we started the export of this backup.
public let exportStartDate: Date
/// File URL of the data consisting of "iv + encrypted data + hmac"
public let fileUrl: URL
/// The digest of the encrypted file. The encrypted file consist of "iv + encrypted data + hmac"
public let digest: Data
/// The length of the encrypted data, consiting of "iv + encrypted data + hmac"
public let encryptedDataLength: UInt32
/// The total size of all backup-able attachments in the backup.
/// Does NOT take into account current backup plan state; just per-attachment
/// backup eligibility.
public let attachmentByteSize: UInt64
/// Metadata related to the SVRB nonce used for forward secrecy that should be persisted
/// after upload success.
let nonceMetadata: BackupExportPurpose.NonceMetadata?
}
public struct LocalUploadMetadata: AttachmentUploadMetadata, Codable {
/// File URL of the data consisting of "iv + encrypted data + hmac"
public let fileUrl: URL
/// encryption key + hmac
public let key: Data
/// The digest of the encrypted file. The encrypted file consist of "iv + encrypted data + hmac"
public let digest: Data
/// The length of the encrypted data, consiting of "iv + encrypted data + hmac"
public let encryptedDataLength: UInt32
/// The length of the unencrypted data
public let plaintextDataLength: UInt32
public var isReusedTransitTierUpload: Bool { false }
}
public struct LinkNSyncUploadMetadata: UploadMetadata {
/// File URL of the link'n'sync transient backup.
public let fileUrl: URL
/// The length of the file.
public let encryptedDataLength: UInt32
}
public struct ReusedUploadMetadata: AttachmentUploadMetadata {
public let cdnKey: String
public let cdnNumber: UInt32
/// encryption key + hmac
public let key: Data
/// The digest of the encrypted file. The encrypted file consist of "iv + encrypted data + hmac"
public let digest: Data
/// The length of the unencrypted data
public let plaintextDataLength: UInt32
/// The length of the encrypted data, consiting of "iv + encrypted data + hmac"
public let encryptedDataLength: UInt32
public var isReusedTransitTierUpload: Bool { true }
}
public struct Result<Metadata: UploadMetadata> {
let cdnKey: String
let cdnNumber: UInt32
let localUploadMetadata: Metadata
// Timestamp the upload attempt began
let beginTimestamp: UInt64
// Timestamp the upload attempt completed
let finishTimestamp: UInt64
}
public struct AttachmentResult {
let cdnKey: String
let cdnNumber: UInt32
let localUploadMetadata: AttachmentUploadMetadata
// Timestamp the upload attempt began
let beginTimestamp: UInt64
// Timestamp the upload attempt completed
let finishTimestamp: UInt64
}
public struct Attempt<Metadata: UploadMetadata> {
let cdnKey: String
let cdnNumber: UInt32
/// File URL of the data consisting of "iv + encrypted data + hmac"
let fileUrl: URL
/// The length of the encrypted data, consiting of "iv + encrypted data + hmac"
let encryptedDataLength: UInt32
let localMetadata: Metadata
let beginTimestamp: UInt64
let endpoint: UploadEndpoint
let uploadLocation: URL
let isResumedUpload: Bool
let logger: PrefixedLogger
}
}
extension Upload.LocalUploadMetadata {
static func validateAndBuild(
fileUrl: URL,
metadata: EncryptionMetadata,
) throws -> Upload.LocalUploadMetadata {
guard
let encryptedLength = UInt32(exactly: metadata.encryptedLength),
encryptedLength > 0,
let plaintextLength = UInt32(exactly: metadata.plaintextLength),
plaintextLength > 0
else {
throw OWSAssertionError("Invalid length.")
}
let digest = metadata.digest
return Upload.LocalUploadMetadata(
fileUrl: fileUrl,
key: metadata.key.combinedKey,
digest: digest,
encryptedDataLength: encryptedLength,
plaintextDataLength: plaintextLength,
)
}
}
extension UploadEndpoint {
func readUploadFileChunk(
fileSystem: Upload.Shims.FileSystem,
url: URL,
startIndex chunkStartIndex: Int,
) throws(Upload.Error) -> (data: Data, truncated: Bool) {
guard fileSystem.fileOrFolderExists(url: url) else {
throw .missingFile
}
let fileData: Data
do {
fileData = try fileSystem.readMemoryMappedFileData(url: url)
} catch {
Logger.error("Unable to map upload file into memory")
throw .missingFile
}
let remainingData = fileData.dropFirst(chunkStartIndex)
let dataChunk = remainingData.prefix(fileSystem.maxFileChunkSizeBytes())
return (
dataChunk,
dataChunk.count != remainingData.count,
)
}
}