chatsecureios / ChatSecure / Classes / Controllers / FileTransferManager.swift @ 8d76e2e3
History | View | Annotate | Download (36.8 KB)
| 1 |
// |
|---|---|
| 2 |
// FileTransferManager.swift |
| 3 |
// ChatSecure |
| 4 |
// |
| 5 |
// Created by Chris Ballinger on 3/28/17. |
| 6 |
// Copyright © 2017 Chris Ballinger. All rights reserved. |
| 7 |
// |
| 8 |
|
| 9 |
import Foundation |
| 10 |
import XMPPFramework |
| 11 |
import CocoaLumberjack |
| 12 |
import OTRKit |
| 13 |
import Alamofire |
| 14 |
import OTRAssets |
| 15 |
|
| 16 |
extension UIImage {
|
| 17 |
enum DataSize {
|
| 18 |
case unlimited |
| 19 |
case maxBytes(UInt) |
| 20 |
var numBytes: UInt {
|
| 21 |
switch self {
|
| 22 |
case .unlimited: |
| 23 |
return UInt.max |
| 24 |
case .maxBytes(let limit): |
| 25 |
return limit |
| 26 |
} |
| 27 |
} |
| 28 |
} |
| 29 |
struct Quality {
|
| 30 |
static let low = Quality(initial: 0.4, decrementFactor: 0.65) |
| 31 |
static let medium = Quality(initial: 0.65, decrementFactor: 0.65) |
| 32 |
static let high = Quality(initial: 0.75, decrementFactor: 0.75) |
| 33 |
|
| 34 |
/// This value cannot be > 1 or bad things will happen |
| 35 |
let initial: CGFloat |
| 36 |
/// Multiplied to reduce the initial value. This value cannot be > 1 or bad things will happen |
| 37 |
let decrementFactor: CGFloat |
| 38 |
} |
| 39 |
func jpegData(dataSize: DataSize, |
| 40 |
resize: Quality = Quality.medium, |
| 41 |
jpeg: Quality = Quality.medium, |
| 42 |
maxTries: UInt = 10) -> Data? {
|
| 43 |
let image = self |
| 44 |
var sizeInBytes: UInt = 0 |
| 45 |
var scaleFactor: CGFloat = resize.initial |
| 46 |
var jpegQuality: CGFloat = jpeg.initial |
| 47 |
let qualityDecrement: CGFloat = jpeg.decrementFactor |
| 48 |
let scaleDecrement: CGFloat = resize.decrementFactor |
| 49 |
var scaledImageData: Data? = nil |
| 50 |
var newSize = CGSize.zero |
| 51 |
let maxTries = 10 |
| 52 |
var numTries = 0 |
| 53 |
let maxSize = dataSize.numBytes |
| 54 |
while (sizeInBytes == 0 || sizeInBytes > maxSize) && |
| 55 |
numTries < maxTries {
|
| 56 |
numTries = numTries + 1 |
| 57 |
newSize = CGSize(width: image.size.width * scaleFactor, height: image.size.height * scaleFactor) |
| 58 |
let scaledImage = UIImage.otr_image(with: image, scaledTo: newSize) |
| 59 |
scaledImageData = UIImageJPEGRepresentation(scaledImage, jpegQuality) |
| 60 |
if let imageData = scaledImageData {
|
| 61 |
sizeInBytes = UInt(imageData.count) |
| 62 |
scaleFactor = scaleFactor * scaleDecrement |
| 63 |
jpegQuality = jpegQuality * qualityDecrement |
| 64 |
} else {
|
| 65 |
DDLogError("Could not make JPEG out of image!")
|
| 66 |
return nil |
| 67 |
} |
| 68 |
} |
| 69 |
return scaledImageData |
| 70 |
} |
| 71 |
} |
| 72 |
|
| 73 |
public enum FileTransferError: CustomNSError {
|
| 74 |
case unknown |
| 75 |
case noServers |
| 76 |
case serverError |
| 77 |
case exceedsMaxSize |
| 78 |
case urlFormatting |
| 79 |
case fileNotFound |
| 80 |
case keyGenerationError |
| 81 |
case cryptoError |
| 82 |
case automaticDownloadsDisabled |
| 83 |
|
| 84 |
public var localizedDescription: String {
|
| 85 |
switch self {
|
| 86 |
case .unknown: |
| 87 |
return UNKNOWN_ERROR_STRING() |
| 88 |
case .noServers: |
| 89 |
return NO_HTTP_UPLOAD_SERVERS_STRING() + " " + PLEASE_CONTACT_SERVER_OP_STRING() |
| 90 |
case .serverError: |
| 91 |
return UNKNOWN_ERROR_STRING() + " " + PLEASE_CONTACT_SERVER_OP_STRING() |
| 92 |
case .exceedsMaxSize: |
| 93 |
return FILE_EXCEEDS_MAX_SIZE_STRING() |
| 94 |
case .urlFormatting: |
| 95 |
return COULD_NOT_PARSE_URL_STRING() |
| 96 |
case .fileNotFound: |
| 97 |
return FILE_NOT_FOUND_STRING() |
| 98 |
case .cryptoError, .keyGenerationError: |
| 99 |
return errSSLCryptoString() |
| 100 |
case .automaticDownloadsDisabled: |
| 101 |
return AUTOMATIC_DOWNLOADS_DISABLED_STRING() |
| 102 |
} |
| 103 |
} |
| 104 |
} |
| 105 |
|
| 106 |
public class FileTransferManager: NSObject, OTRServerCapabilitiesDelegate {
|
| 107 |
|
| 108 |
let httpFileUpload: XMPPHTTPFileUpload |
| 109 |
let serverCapabilities: OTRServerCapabilities |
| 110 |
let connection: YapDatabaseConnection |
| 111 |
let internalQueue = DispatchQueue(label: "FileTransferManager Queue") |
| 112 |
let callbackQueue = DispatchQueue.main |
| 113 |
let sessionManager: SessionManager |
| 114 |
private var servers: [HTTPServer] = [] |
| 115 |
|
| 116 |
@objc public var canUploadFiles: Bool {
|
| 117 |
return self.servers.first != nil |
| 118 |
} |
| 119 |
|
| 120 |
deinit {
|
| 121 |
httpFileUpload.removeDelegate(self) |
| 122 |
serverCapabilities.removeDelegate(self) |
| 123 |
} |
| 124 |
|
| 125 |
@objc public init(connection: YapDatabaseConnection, |
| 126 |
serverCapabilities: OTRServerCapabilities, |
| 127 |
sessionConfiguration: URLSessionConfiguration) {
|
| 128 |
self.serverCapabilities = serverCapabilities |
| 129 |
self.httpFileUpload = XMPPHTTPFileUpload() |
| 130 |
self.connection = connection |
| 131 |
self.sessionManager = Alamofire.SessionManager(configuration: sessionConfiguration) |
| 132 |
super.init() |
| 133 |
if let stream = serverCapabilities.xmppStream {
|
| 134 |
httpFileUpload.activate(stream) |
| 135 |
} |
| 136 |
httpFileUpload.addDelegate(self, delegateQueue: DispatchQueue.main) |
| 137 |
serverCapabilities.addDelegate(self, delegateQueue: DispatchQueue.main) |
| 138 |
self.refreshCapabilities() |
| 139 |
} |
| 140 |
|
| 141 |
// MARK: - Public Methods |
| 142 |
|
| 143 |
/// This will fetch capabilities and setup XMPP transfer module if needed |
| 144 |
@objc public func refreshCapabilities() {
|
| 145 |
guard let allCapabilities = serverCapabilities.allCapabilities else {
|
| 146 |
serverCapabilities.fetchAllCapabilities() |
| 147 |
return |
| 148 |
} |
| 149 |
servers = serversFromCapabilities(capabilities: allCapabilities) |
| 150 |
serverCapabilities.fetchAllCapabilities() |
| 151 |
} |
| 152 |
|
| 153 |
private func upload(mediaItem: OTRMediaItem, |
| 154 |
shouldEncrypt: Bool, |
| 155 |
prefetchedData: Data?, |
| 156 |
completion: @escaping (_ url: URL?, _ error: Error?) -> ()) {
|
| 157 |
internalQueue.async {
|
| 158 |
if let data = prefetchedData {
|
| 159 |
self.upload(media: mediaItem, data: data, shouldEncrypt: shouldEncrypt, filename: mediaItem.filename, contentType: mediaItem.mimeType, completion: completion) |
| 160 |
} else {
|
| 161 |
var url: URL? = nil |
| 162 |
self.connection.read({ (transaction) in
|
| 163 |
url = mediaItem.mediaServerURL(with: transaction) |
| 164 |
}) |
| 165 |
if let url = url {
|
| 166 |
self.upload(media: mediaItem, file: url, shouldEncrypt: shouldEncrypt, completion: completion) |
| 167 |
} else {
|
| 168 |
let error = FileTransferError.fileNotFound |
| 169 |
DDLogError("Upload filed: File not found \(error)")
|
| 170 |
self.callbackQueue.async {
|
| 171 |
completion(nil, error) |
| 172 |
} |
| 173 |
} |
| 174 |
} |
| 175 |
} |
| 176 |
} |
| 177 |
|
| 178 |
/// Currently just a wrapper around sendData |
| 179 |
private func upload(media: OTRMediaItem, |
| 180 |
file: URL, |
| 181 |
shouldEncrypt: Bool, |
| 182 |
completion: @escaping (_ url: URL?, _ error: Error?) -> ()) {
|
| 183 |
internalQueue.async {
|
| 184 |
do {
|
| 185 |
let data = try Data(contentsOf: file) |
| 186 |
let mimeType = OTRKitGetMimeTypeForExtension(file.pathExtension) |
| 187 |
self.upload(media: media, data: data, shouldEncrypt: shouldEncrypt, filename: file.lastPathComponent, contentType: mimeType, completion: completion) |
| 188 |
} catch let error {
|
| 189 |
DDLogError("Error sending file URL \(file): \(error)")
|
| 190 |
} |
| 191 |
} |
| 192 |
|
| 193 |
} |
| 194 |
|
| 195 |
private func upload(media: OTRMediaItem, |
| 196 |
data inData: Data, |
| 197 |
shouldEncrypt: Bool, |
| 198 |
filename: String, |
| 199 |
contentType: String, |
| 200 |
completion: @escaping (_ url: URL?, _ error: Error?) -> ()) {
|
| 201 |
internalQueue.async {
|
| 202 |
guard let service = self.servers.first else {
|
| 203 |
DDLogWarn("No HTTP upload servers available")
|
| 204 |
self.callbackQueue.async {
|
| 205 |
completion(nil, FileTransferError.noServers) |
| 206 |
} |
| 207 |
return |
| 208 |
} |
| 209 |
var data = inData |
| 210 |
|
| 211 |
// When resending images, sometimes we need to recompress them |
| 212 |
// to fit the max upload limit |
| 213 |
if UInt(data.count) > service.maxSize, |
| 214 |
let _ = media as? OTRImageItem, |
| 215 |
let image = UIImage(data: inData), |
| 216 |
let imageData = image.jpegData(dataSize: .maxBytes(service.maxSize), resize: UIImage.Quality.medium, jpeg: UIImage.Quality.medium, maxTries: 10) |
| 217 |
{
|
| 218 |
data = imageData |
| 219 |
} |
| 220 |
|
| 221 |
if UInt(data.count) > service.maxSize {
|
| 222 |
DDLogError("HTTP Upload exceeds max size \(data.count) > \(service.maxSize)")
|
| 223 |
self.callbackQueue.async {
|
| 224 |
completion(nil, FileTransferError.exceedsMaxSize) |
| 225 |
} |
| 226 |
return |
| 227 |
} |
| 228 |
|
| 229 |
// TODO: Refactor to use streaming encryption |
| 230 |
var outData = data |
| 231 |
var outKeyIv: Data? = nil |
| 232 |
if shouldEncrypt {
|
| 233 |
guard let key = OTRPasswordGenerator.randomData(withLength: 32), let iv = OTRPasswordGenerator.randomData(withLength: 16) else {
|
| 234 |
DDLogError("Could not generate key/iv")
|
| 235 |
self.callbackQueue.async {
|
| 236 |
completion(nil, FileTransferError.keyGenerationError) |
| 237 |
} |
| 238 |
return |
| 239 |
} |
| 240 |
outKeyIv = iv + key |
| 241 |
do {
|
| 242 |
let crypted = try OTRCryptoUtility.encryptAESGCMData(data, key: key, iv: iv) |
| 243 |
outData = crypted.data + crypted.authTag |
| 244 |
} catch let error {
|
| 245 |
outData = Data() |
| 246 |
DDLogError("Could not encrypt data for file transfer \(error)")
|
| 247 |
self.callbackQueue.async {
|
| 248 |
completion(nil, error) |
| 249 |
} |
| 250 |
return |
| 251 |
} |
| 252 |
} |
| 253 |
|
| 254 |
|
| 255 |
self.httpFileUpload.requestSlot(fromService: service.jid, filename: filename, size: UInt(outData.count), contentType: contentType, completion: { (slot: XMPPSlot?, iq: XMPPIQ?, error: Error?) in
|
| 256 |
guard let slot = slot else {
|
| 257 |
let outError = error ?? FileTransferError.serverError |
| 258 |
DDLogError("\(service) failed to assign upload slot: \(outError)")
|
| 259 |
self.callbackQueue.async {
|
| 260 |
completion(nil, outError) |
| 261 |
} |
| 262 |
return |
| 263 |
} |
| 264 |
self.sessionManager.upload(outData, to: slot.putURL, method: .put) |
| 265 |
.validate() |
| 266 |
.responseData(queue: self.callbackQueue) { response in
|
| 267 |
switch response.result {
|
| 268 |
case .success: |
| 269 |
if let outKeyIv = outKeyIv {
|
| 270 |
// If there's a AES-GCM key, we gotta put it in the url |
| 271 |
// and change the scheme to `aesgcm` |
| 272 |
if var components = URLComponents(url: slot.getURL, resolvingAgainstBaseURL: true) {
|
| 273 |
components.scheme = URLScheme.aesgcm.rawValue |
| 274 |
components.fragment = outKeyIv.hexString() |
| 275 |
if let outURL = components.url {
|
| 276 |
completion(outURL, nil) |
| 277 |
} else {
|
| 278 |
completion(nil, FileTransferError.urlFormatting) |
| 279 |
} |
| 280 |
} else {
|
| 281 |
completion(nil, FileTransferError.urlFormatting) |
| 282 |
} |
| 283 |
} else {
|
| 284 |
// The plaintext case |
| 285 |
completion(slot.getURL, nil) |
| 286 |
} |
| 287 |
case .failure(let error): |
| 288 |
completion(nil, error) |
| 289 |
DDLogError("Upload error: \(error)")
|
| 290 |
} |
| 291 |
}.uploadProgress(queue: self.internalQueue) { progress in
|
| 292 |
DDLogVerbose("Download progress \(progress.fractionCompleted)")
|
| 293 |
self.connection.asyncReadWrite { transaction in
|
| 294 |
if let media = media.refetch(with: transaction) {
|
| 295 |
media.transferProgress = Float(progress.fractionCompleted) |
| 296 |
media.save(with: transaction) |
| 297 |
media.touchParentMessage(with: transaction) |
| 298 |
} |
| 299 |
} |
| 300 |
} |
| 301 |
}) |
| 302 |
} |
| 303 |
} |
| 304 |
|
| 305 |
@objc public func send(videoURL url: URL, thread: OTRThreadOwner) {
|
| 306 |
internalQueue.async {
|
| 307 |
self.send(url: url, thread: thread, type: .video) |
| 308 |
} |
| 309 |
} |
| 310 |
|
| 311 |
private enum MediaURLType {
|
| 312 |
case audio |
| 313 |
case video |
| 314 |
//case image |
| 315 |
} |
| 316 |
|
| 317 |
private func send(url: URL, thread: OTRThreadOwner, type: MediaURLType) {
|
| 318 |
internalQueue.async {
|
| 319 |
var item: OTRMediaItem? = nil |
| 320 |
switch type {
|
| 321 |
case .audio: |
| 322 |
item = OTRAudioItem(audioURL: url, isIncoming: false) |
| 323 |
case .video: |
| 324 |
item = OTRVideoItem(videoURL: url, isIncoming: false) |
| 325 |
} |
| 326 |
guard let mediaItem = item else {
|
| 327 |
DDLogError("No media item to share for URL: \(url)")
|
| 328 |
return |
| 329 |
} |
| 330 |
|
| 331 |
guard let message = self.newOutgoingMessage(to: thread, mediaItem: mediaItem) else {
|
| 332 |
DDLogError("No message could be created for \(thread) \(mediaItem)")
|
| 333 |
return |
| 334 |
} |
| 335 |
mediaItem.parentObjectKey = message.messageKey |
| 336 |
mediaItem.parentObjectCollection = message.messageCollection |
| 337 |
let newPath = OTRMediaFileManager.path(for: mediaItem, buddyUniqueId: thread.threadIdentifier) |
| 338 |
self.connection.readWrite { transaction in
|
| 339 |
message.save(with: transaction) |
| 340 |
mediaItem.save(with: transaction) |
| 341 |
|
| 342 |
} |
| 343 |
OTRMediaFileManager.shared.copyData(fromFilePath: url.path, toEncryptedPath: newPath, completion: { (result, copyError: Error?) in
|
| 344 |
var prefetchedData: Data? = nil |
| 345 |
if FileManager.default.fileExists(atPath: url.path) {
|
| 346 |
do {
|
| 347 |
let attributes = try FileManager.default.attributesOfItem(atPath: url.path) |
| 348 |
if let size = attributes[FileAttributeKey.size] as? NSNumber, size.uint64Value < 1024 * 1024 * 1 {
|
| 349 |
prefetchedData = try Data(contentsOf: url) |
| 350 |
} |
| 351 |
} catch let error {
|
| 352 |
DDLogError("Error prefetching data: \(error)")
|
| 353 |
} |
| 354 |
do {
|
| 355 |
try FileManager.default.removeItem(atPath: url.path) |
| 356 |
} catch let error {
|
| 357 |
DDLogError("Error removing video: \(error)")
|
| 358 |
} |
| 359 |
} |
| 360 |
message.messageError = copyError |
| 361 |
self.connection.readWrite({ (transaction) in
|
| 362 |
mediaItem.save(with: transaction) |
| 363 |
message.save(with: transaction) |
| 364 |
}) |
| 365 |
self.send(mediaItem: mediaItem, prefetchedData: prefetchedData, message: message) |
| 366 |
}, completionQueue: self.internalQueue) |
| 367 |
} |
| 368 |
} |
| 369 |
|
| 370 |
@objc public func send(audioURL url: URL, thread: OTRThreadOwner) {
|
| 371 |
internalQueue.async {
|
| 372 |
self.send(url: url, thread: thread, type: .audio) |
| 373 |
} |
| 374 |
} |
| 375 |
|
| 376 |
@objc public func send(image: UIImage, thread: OTRThreadOwner) {
|
| 377 |
internalQueue.async {
|
| 378 |
guard let service = self.servers.first, service.maxSize > 0 else {
|
| 379 |
DDLogError("No HTTP upload service available!")
|
| 380 |
return |
| 381 |
} |
| 382 |
let filename = "\(UUID().uuidString).jpg" |
| 383 |
let imageItem = OTRImageItem(filename: filename, size: image.size, mimeType: "image/jpeg", isIncoming: false) |
| 384 |
guard let message = self.newOutgoingMessage(to: thread, mediaItem: imageItem) else {
|
| 385 |
DDLogError("No message could be created")
|
| 386 |
return |
| 387 |
} |
| 388 |
imageItem.parentObjectKey = message.messageKey |
| 389 |
imageItem.parentObjectCollection = message.messageCollection |
| 390 |
self.connection.readWrite { transaction in
|
| 391 |
message.save(with: transaction) |
| 392 |
imageItem.save(with: transaction) |
| 393 |
} |
| 394 |
guard let ourImageData = image.jpegData(dataSize: .unlimited, resize: UIImage.Quality.high, jpeg: UIImage.Quality.high, maxTries: 10) else {
|
| 395 |
DDLogError("Could not make JPEG out of image!")
|
| 396 |
return |
| 397 |
} |
| 398 |
OTRMediaFileManager.shared.setData(ourImageData, for: imageItem, buddyUniqueId: thread.threadIdentifier, completion: { (bytesWritten: Int, error: Error?) in
|
| 399 |
self.connection.readWrite({ (transaction) in
|
| 400 |
imageItem.touchParentMessage(with: transaction) |
| 401 |
if let error = error {
|
| 402 |
message.messageError = error |
| 403 |
message.save(with: transaction) |
| 404 |
} |
| 405 |
}) |
| 406 |
if let imageData = image.jpegData(dataSize: .maxBytes(service.maxSize), resize: UIImage.Quality.medium, jpeg: UIImage.Quality.medium, maxTries: 10) {
|
| 407 |
self.send(mediaItem: imageItem, prefetchedData: imageData, message: message) |
| 408 |
} else {
|
| 409 |
DDLogError("Could not make JPEG out of image! Bad size")
|
| 410 |
message.messageError = FileTransferError.exceedsMaxSize |
| 411 |
self.connection.readWrite { transaction in
|
| 412 |
message.save(with: transaction) |
| 413 |
} |
| 414 |
} |
| 415 |
}, completionQueue: self.internalQueue) |
| 416 |
} |
| 417 |
} |
| 418 |
|
| 419 |
private func newOutgoingMessage(to thread: OTRThreadOwner, mediaItem: OTRMediaItem) -> OTRMessageProtocol? {
|
| 420 |
if let buddy = thread as? OTRBuddy {
|
| 421 |
let message = OTROutgoingMessage()! |
| 422 |
var security: OTRMessageTransportSecurity = .invalid |
| 423 |
self.connection.read({ (transaction) in
|
| 424 |
security = buddy.preferredTransportSecurity(with: transaction) |
| 425 |
}) |
| 426 |
message.buddyUniqueId = buddy.uniqueId |
| 427 |
message.mediaItemUniqueId = mediaItem.uniqueId |
| 428 |
message.messageSecurityInfo = OTRMessageEncryptionInfo(messageSecurity: security) |
| 429 |
return message |
| 430 |
} else if let room = thread as? OTRXMPPRoom {
|
| 431 |
let message = OTRXMPPRoomMessage()! |
| 432 |
message.messageDate = Date() |
| 433 |
message.roomUniqueId = room.threadIdentifier |
| 434 |
message.roomJID = room.jid |
| 435 |
message.senderJID = room.ownJID |
| 436 |
message.state = .needsSending |
| 437 |
message.mediaItemId = mediaItem.uniqueId |
| 438 |
return message |
| 439 |
} |
| 440 |
return nil |
| 441 |
} |
| 442 |
|
| 443 |
@objc public func send(mediaItem: OTRMediaItem, prefetchedData: Data?, message: OTRMessageProtocol) {
|
| 444 |
var shouldEncrypt = false |
| 445 |
switch message.messageSecurity {
|
| 446 |
case .OMEMO, .OTR: |
| 447 |
shouldEncrypt = true |
| 448 |
case .invalid, .plaintext, .plaintextWithOTR: |
| 449 |
shouldEncrypt = false |
| 450 |
} |
| 451 |
|
| 452 |
self.upload(mediaItem: mediaItem, shouldEncrypt: shouldEncrypt, prefetchedData: prefetchedData, completion: { (_url: URL?, error: Error?) in
|
| 453 |
guard let url = _url else {
|
| 454 |
if let error = error {
|
| 455 |
DDLogError("Error uploading: \(error)")
|
| 456 |
} |
| 457 |
self.connection.readWrite({ (transaction) in
|
| 458 |
message.messageError = error |
| 459 |
message.save(with: transaction) |
| 460 |
}) |
| 461 |
return |
| 462 |
} |
| 463 |
self.connection.readWrite({ (transaction) in
|
| 464 |
mediaItem.transferProgress = 1.0 |
| 465 |
message.messageText = url.absoluteString |
| 466 |
mediaItem.save(with: transaction) |
| 467 |
message.save(with: transaction) |
| 468 |
}) |
| 469 |
self.queueOutgoingMessage(message: message) |
| 470 |
}) |
| 471 |
} |
| 472 |
|
| 473 |
private func queueOutgoingMessage(message: OTRMessageProtocol) {
|
| 474 |
let sendAction = OTRYapMessageSendAction(forMessage: message, date: nil) |
| 475 |
self.connection.readWrite { transaction in
|
| 476 |
message.save(with: transaction) |
| 477 |
sendAction.save(with: transaction) |
| 478 |
if let thread = message.threadOwner(with: transaction) {
|
| 479 |
if let buddy = thread as? OTRBuddy {
|
| 480 |
buddy.lastMessageId = message.uniqueId |
| 481 |
buddy.save(with: transaction) |
| 482 |
} |
| 483 |
} |
| 484 |
} |
| 485 |
} |
| 486 |
|
| 487 |
// MARK: - Private Methods |
| 488 |
|
| 489 |
private func serversFromCapabilities(capabilities: [XMPPJID : XMLElement]) -> [HTTPServer] {
|
| 490 |
var servers: [HTTPServer] = [] |
| 491 |
for (jid, element) in capabilities {
|
| 492 |
let supported = element.supportsHTTPUpload() |
| 493 |
let maxSize = element.maxHTTPUploadSize() |
| 494 |
if supported && maxSize > 0 {
|
| 495 |
let server = HTTPServer(jid: jid, maxSize: maxSize) |
| 496 |
servers.append(server) |
| 497 |
} |
| 498 |
} |
| 499 |
return servers |
| 500 |
} |
| 501 |
|
| 502 |
// MARK: - OTRServerCapabilitiesDelegate |
| 503 |
|
| 504 |
public func serverCapabilities(_ sender: OTRServerCapabilities, didDiscoverCapabilities capabilities: [XMPPJID : XMLElement]) {
|
| 505 |
servers = serversFromCapabilities(capabilities: capabilities) |
| 506 |
} |
| 507 |
} |
| 508 |
|
| 509 |
// MARK: - Scanning and downloading incoming media |
| 510 |
extension FileTransferManager {
|
| 511 |
|
| 512 |
/** creates downloadmessages and then downloads if needed. parent message should already be saved! @warn Do not call from within an existing db transaction! */ |
| 513 |
@objc public func createAndDownloadItemsIfNeeded(message: OTRMessageProtocol, force: Bool, transaction: YapDatabaseReadWriteTransaction) {
|
| 514 |
if message.messageMediaItemKey != nil || message.messageText?.count == 0 || message.downloadableURLs.count == 0 {
|
| 515 |
//DDLogVerbose("Download of message not needed \(message.messageKey)")
|
| 516 |
return |
| 517 |
} |
| 518 |
var downloads: [OTRDownloadMessage] = [] |
| 519 |
var disableAutomaticURLFetching = false |
| 520 |
if !force {
|
| 521 |
downloads = message.existingDownloads(with: transaction) |
| 522 |
if let thread = message.threadOwner(with: transaction), let account = OTRAccount.fetchObject(withUniqueID: thread.threadAccountIdentifier, transaction: transaction) {
|
| 523 |
disableAutomaticURLFetching = account.disableAutomaticURLFetching |
| 524 |
} |
| 525 |
} |
| 526 |
if downloads.count == 0 {
|
| 527 |
downloads = message.downloads() |
| 528 |
if downloads.count == 0 {
|
| 529 |
return |
| 530 |
} |
| 531 |
for download in downloads {
|
| 532 |
if disableAutomaticURLFetching, |
| 533 |
let filename = download.downloadableURL?.absoluteString {
|
| 534 |
let media = OTRMediaItem.incomingItem(withFilename: filename, mimeType: nil) |
| 535 |
media.parentObjectKey = download.uniqueId |
| 536 |
media.parentObjectCollection = download.messageCollection |
| 537 |
media.save(with: transaction) |
| 538 |
download.messageMediaItemKey = media.uniqueId |
| 539 |
download.messageError = FileTransferError.automaticDownloadsDisabled |
| 540 |
} |
| 541 |
download.save(with: transaction) |
| 542 |
} |
| 543 |
message.touch(with: transaction) |
| 544 |
} |
| 545 |
if disableAutomaticURLFetching {
|
| 546 |
DDLogVerbose("Automatic URL fetching disabled \(message.messageKey)")
|
| 547 |
return |
| 548 |
} |
| 549 |
for download in downloads {
|
| 550 |
self.downloadMediaIfNeeded(download) |
| 551 |
} |
| 552 |
} |
| 553 |
|
| 554 |
/** Downloads media for a single downloadmessage */ |
| 555 |
public func downloadMediaIfNeeded(_ downloadMessage: OTRDownloadMessage) {
|
| 556 |
// Bail out if we've already downloaded the media |
| 557 |
if downloadMessage.messageMediaItemKey != nil && |
| 558 |
downloadMessage.messageError == nil {
|
| 559 |
// DDLogWarn("Already downloaded media for this item")
|
| 560 |
return |
| 561 |
} |
| 562 |
guard let url = downloadMessage.downloadableURL else {
|
| 563 |
DDLogWarn("Attempted to download message but couldn't parse a URL \(downloadMessage)")
|
| 564 |
return |
| 565 |
} |
| 566 |
self.sessionManager.session.getTasksWithCompletionHandler { (tasks, _, _) in
|
| 567 |
// Bail out if we've already got a task for this |
| 568 |
for task in tasks where task.originalRequest?.url == url {
|
| 569 |
DDLogWarn("Already have outstanding task: \(task)")
|
| 570 |
return |
| 571 |
} |
| 572 |
self.sessionManager.request(url, method: .head) |
| 573 |
.validate() |
| 574 |
.responseData(queue: self.internalQueue) { response in
|
| 575 |
switch response.result {
|
| 576 |
case .success: |
| 577 |
DDLogInfo("HEAD response: \(String(describing: response.response?.allHeaderFields))")
|
| 578 |
if let headers = response.response?.allHeaderFields {
|
| 579 |
let contentType = headers["Content-Type"] as? String |
| 580 |
let contentLength = headers["Content-Length"] as? UInt ?? 0 |
| 581 |
self.continueDownloading(downloadMessage: downloadMessage, url: url, contentType: contentType, contentLength: contentLength) |
| 582 |
} |
| 583 |
case .failure(let error): |
| 584 |
self.setError(error, onMessage: downloadMessage) |
| 585 |
DDLogError("HEAD error: \(error)")
|
| 586 |
} |
| 587 |
} |
| 588 |
} |
| 589 |
} |
| 590 |
|
| 591 |
private func setError(_ error: Error, onMessage downloadMessage: OTRDownloadMessage) {
|
| 592 |
self.connection.readWrite { transaction in
|
| 593 |
if let message = downloadMessage.refetch(with: transaction) {
|
| 594 |
message.messageError = error |
| 595 |
message.save(with: transaction) |
| 596 |
} |
| 597 |
} |
| 598 |
} |
| 599 |
|
| 600 |
private func continueDownloading(downloadMessage: OTRDownloadMessage, url: URL, contentType: String?, contentLength: UInt) {
|
| 601 |
var mediaItem: OTRMediaItem? = nil |
| 602 |
self.connection.readWrite { transaction in
|
| 603 |
// Remove placeholder media item |
| 604 |
mediaItem = OTRMediaItem(forMessage: downloadMessage, transaction: transaction) |
| 605 |
mediaItem?.remove(with: transaction) |
| 606 |
mediaItem = OTRMediaItem.incomingItem(withFilename: url.lastPathComponent, mimeType: contentType) |
| 607 |
mediaItem?.parentObjectKey = downloadMessage.uniqueId |
| 608 |
mediaItem?.parentObjectCollection = downloadMessage.messageCollection |
| 609 |
mediaItem?.save(with: transaction) |
| 610 |
downloadMessage.messageMediaItemKey = mediaItem?.uniqueId |
| 611 |
downloadMessage.save(with: transaction) |
| 612 |
} |
| 613 |
guard let media = mediaItem else {
|
| 614 |
DDLogError("Could not unwrap media item")
|
| 615 |
self.setError(FileTransferError.fileNotFound, onMessage: downloadMessage) |
| 616 |
return |
| 617 |
} |
| 618 |
DDLogVerbose("Downloading media item at URL: \(url)")
|
| 619 |
self.sessionManager.request(url) |
| 620 |
.validate() |
| 621 |
.responseData(queue: self.internalQueue) { response in
|
| 622 |
self.finishDownload(downloadMessage: downloadMessage, mediaItem: media, inData: response.data, urlResponse: response.response, error: response.error) |
| 623 |
switch response.result {
|
| 624 |
case .success: |
| 625 |
DDLogVerbose("Download Successful")
|
| 626 |
case .failure(let error): |
| 627 |
self.setError(error, onMessage: downloadMessage) |
| 628 |
DDLogError("Download Error \(error)")
|
| 629 |
} |
| 630 |
}.downloadProgress(queue: self.internalQueue) { progress in
|
| 631 |
DDLogVerbose("Download progress \(progress.fractionCompleted)")
|
| 632 |
self.connection.asyncReadWrite { transaction in
|
| 633 |
if let media = media.refetch(with: transaction) {
|
| 634 |
media.transferProgress = Float(progress.fractionCompleted) |
| 635 |
media.save(with: transaction) |
| 636 |
media.touchParentMessage(with: transaction) |
| 637 |
} |
| 638 |
} |
| 639 |
} |
| 640 |
} |
| 641 |
|
| 642 |
private func finishDownload(downloadMessage: OTRDownloadMessage, mediaItem: OTRMediaItem, inData: Data?, urlResponse: URLResponse?, error: Error?) {
|
| 643 |
if let error = error {
|
| 644 |
self.setError(error, onMessage: downloadMessage) |
| 645 |
DDLogError("Error downloading file \(error)")
|
| 646 |
return |
| 647 |
} |
| 648 |
guard var data = inData, let response = urlResponse, let url = response.url else {
|
| 649 |
self.setError(FileTransferError.fileNotFound, onMessage: downloadMessage) |
| 650 |
DDLogError("No data or response")
|
| 651 |
return |
| 652 |
} |
| 653 |
DDLogVerbose("Received response \(response)")
|
| 654 |
let authTagSize = 16 // i'm not sure if this can be assumed, but how else would we know the size? |
| 655 |
if let (key, iv) = url.aesGcmKey, data.count > authTagSize {
|
| 656 |
DDLogVerbose("Received encrypted response, attempting decryption...")
|
| 657 |
|
| 658 |
let cryptedData = data.subdata(in: 0..<data.count - authTagSize) |
| 659 |
let authTag = data.subdata(in: data.count - authTagSize..<data.count) |
| 660 |
let cryptoData = OTRCryptoData(data: cryptedData, authTag: authTag) |
| 661 |
do {
|
| 662 |
data = try OTRCryptoUtility.decryptAESGCMData(cryptoData, key: key, iv: iv) |
| 663 |
} catch let error {
|
| 664 |
self.setError(error, onMessage: downloadMessage) |
| 665 |
DDLogError("Error decrypting data: \(error)")
|
| 666 |
return |
| 667 |
} |
| 668 |
DDLogVerbose("Decrpytion successful")
|
| 669 |
} |
| 670 |
OTRMediaFileManager.sharedInstance().setData(data, for: mediaItem, buddyUniqueId: downloadMessage.threadId, completion: { (bytesWritten, error) in
|
| 671 |
if let error = error {
|
| 672 |
self.setError(error, onMessage: downloadMessage) |
| 673 |
DDLogError("Error copying data: \(error)")
|
| 674 |
return |
| 675 |
} |
| 676 |
self.connection.asyncReadWrite({ (transaction) in
|
| 677 |
mediaItem.transferProgress = 1.0 |
| 678 |
|
| 679 |
if let audioItem = mediaItem as? OTRAudioItem, let url = OTRMediaServer.sharedInstance().url(for: mediaItem, buddyUniqueId: downloadMessage.threadId) {
|
| 680 |
audioItem.populateFromData(at: url) |
| 681 |
} |
| 682 |
|
| 683 |
mediaItem.save(with: transaction) |
| 684 |
// downloadMessage.save(with: transaction) |
| 685 |
if let message = downloadMessage.refetch(with: transaction) {
|
| 686 |
message.messageError = nil |
| 687 |
message.save(with: transaction) |
| 688 |
} else {
|
| 689 |
DDLogError("Failed to refetch download message WTF \(downloadMessage)")
|
| 690 |
} |
| 691 |
UIApplication.shared.showLocalNotification(downloadMessage, transaction: transaction) |
| 692 |
}) |
| 693 |
}, completionQueue: nil) |
| 694 |
} |
| 695 |
} |
| 696 |
|
| 697 |
extension OTRDownloadMessage {
|
| 698 |
/// Turn aesgcm links into https links |
| 699 |
var downloadableURL: URL? {
|
| 700 |
guard var downloadableURL = url else { return nil }
|
| 701 |
if downloadableURL.isAesGcm, var components = URLComponents(url: downloadableURL, resolvingAgainstBaseURL: true) {
|
| 702 |
components.scheme = URLScheme.https.rawValue |
| 703 |
if let rawURL = components.url {
|
| 704 |
downloadableURL = rawURL |
| 705 |
} |
| 706 |
} |
| 707 |
return downloadableURL |
| 708 |
} |
| 709 |
} |
| 710 |
|
| 711 |
public extension OTRMessageProtocol {
|
| 712 |
public var downloadableURLs: [URL] {
|
| 713 |
return self.messageText?.downloadableURLs ?? [] |
| 714 |
} |
| 715 |
} |
| 716 |
|
| 717 |
public extension OTRBaseMessage {
|
| 718 |
@objc public var downloadableNSURLs: [NSURL] {
|
| 719 |
return self.downloadableURLs as [NSURL] |
| 720 |
} |
| 721 |
} |
| 722 |
|
| 723 |
public extension OTRXMPPRoomMessage {
|
| 724 |
@objc public var downloadableNSURLs: [NSURL] {
|
| 725 |
return self.downloadableURLs as [NSURL] |
| 726 |
} |
| 727 |
} |
| 728 |
|
| 729 |
// MARK: - Extensions |
| 730 |
|
| 731 |
fileprivate struct HTTPServer {
|
| 732 |
/// service jid for upload service |
| 733 |
let jid: XMPPJID |
| 734 |
/// max upload size in bytes |
| 735 |
let maxSize: UInt |
| 736 |
} |
| 737 |
|
| 738 |
public extension XMLElement {
|
| 739 |
|
| 740 |
// For use on a <query> element |
| 741 |
func supportsHTTPUpload() -> Bool {
|
| 742 |
let features = self.elements(forName: "feature") |
| 743 |
var supported = false |
| 744 |
for feature in features {
|
| 745 |
if let value = feature.attributeStringValue(forName: "var"), |
| 746 |
value == XMPPHTTPFileUploadNamespace {
|
| 747 |
supported = true |
| 748 |
break |
| 749 |
} |
| 750 |
} |
| 751 |
return supported |
| 752 |
} |
| 753 |
|
| 754 |
/// Returns 0 on failure, or max file size in bytes |
| 755 |
func maxHTTPUploadSize() -> UInt {
|
| 756 |
var maxSize: UInt = 0 |
| 757 |
let xes = self.elements(forXmlns: "jabber:x:data") |
| 758 |
|
| 759 |
for x in xes {
|
| 760 |
let fields = x.elements(forName: "field") |
| 761 |
var correctXEP = false |
| 762 |
for field in fields {
|
| 763 |
if let value = field.forName("value") {
|
| 764 |
if value.stringValue == XMPPHTTPFileUploadNamespace {
|
| 765 |
correctXEP = true |
| 766 |
} |
| 767 |
if let varMaxFileSize = field.attributeStringValue(forName: "var"), varMaxFileSize == "max-file-size" {
|
| 768 |
maxSize = value.stringValueAsNSUInteger() |
| 769 |
} |
| 770 |
} |
| 771 |
} |
| 772 |
if correctXEP && maxSize > 0 {
|
| 773 |
break |
| 774 |
} |
| 775 |
} |
| 776 |
|
| 777 |
return maxSize |
| 778 |
} |
| 779 |
} |
| 780 |
|
| 781 |
enum URLScheme: String {
|
| 782 |
case https = "https" |
| 783 |
case aesgcm = "aesgcm" |
| 784 |
static let downloadableSchemes: [URLScheme] = [.https, .aesgcm] |
| 785 |
} |
| 786 |
|
| 787 |
extension URL {
|
| 788 |
|
| 789 |
/** URL scheme matches aesgcm:// */ |
| 790 |
var isAesGcm: Bool {
|
| 791 |
return scheme == URLScheme.aesgcm.rawValue |
| 792 |
} |
| 793 |
|
| 794 |
/** Has hex anchor with key and IV. 48 bytes w/ 16 iv + 32 key */ |
| 795 |
var anchorData: Data? {
|
| 796 |
guard let anchor = self.fragment else { return nil }
|
| 797 |
let data = anchor.dataFromHex() |
| 798 |
return data |
| 799 |
} |
| 800 |
|
| 801 |
var aesGcmKey: (key: Data, iv: Data)? {
|
| 802 |
guard let data = self.anchorData, data.count == 48 else { return nil }
|
| 803 |
let iv = data.subdata(in: 0..<16) |
| 804 |
let key = data.subdata(in: 16..<48) |
| 805 |
return (key, iv) |
| 806 |
} |
| 807 |
} |
| 808 |
|
| 809 |
public extension NSString {
|
| 810 |
public var isSingleURLOnly: Bool {
|
| 811 |
return (self as String).isSingleURLOnly |
| 812 |
} |
| 813 |
} |
| 814 |
|
| 815 |
public extension String {
|
| 816 |
|
| 817 |
private var urlRanges: ([URL], [NSRange]) {
|
| 818 |
guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
|
| 819 |
return ([], []) |
| 820 |
} |
| 821 |
var urls: [URL] = [] |
| 822 |
var ranges: [NSRange] = [] |
| 823 |
let matches = detector.matches(in: self, options: NSRegularExpression.MatchingOptions(rawValue: 0), range: NSMakeRange(0, self.count)) |
| 824 |
for match in matches where match.resultType == .link {
|
| 825 |
if let url = match.url {
|
| 826 |
urls.append(url) |
| 827 |
ranges.append(match.range) |
| 828 |
} |
| 829 |
} |
| 830 |
return (urls, ranges) |
| 831 |
} |
| 832 |
|
| 833 |
/** Grab any URLs from a string */ |
| 834 |
public var urls: [URL] {
|
| 835 |
let (urls, _) = urlRanges |
| 836 |
return urls |
| 837 |
} |
| 838 |
|
| 839 |
/** Returns true if the message is ONLY a single URL */ |
| 840 |
public var isSingleURLOnly: Bool {
|
| 841 |
let (_, ranges) = urlRanges |
| 842 |
guard ranges.count == 1, |
| 843 |
let range = ranges.first, |
| 844 |
range.length == self.count else {
|
| 845 |
return false |
| 846 |
} |
| 847 |
return true |
| 848 |
} |
| 849 |
|
| 850 |
/** Use this for extracting potentially downloadable URLs from a message. Currently checks for https:// and aesgcm:// */ |
| 851 |
public var downloadableURLs: [URL] {
|
| 852 |
return urlsMatchingSchemes(URLScheme.downloadableSchemes) |
| 853 |
} |
| 854 |
|
| 855 |
fileprivate func urlsMatchingSchemes(_ schemes: [URLScheme]) -> [URL] {
|
| 856 |
let urls = self.urls.filter {
|
| 857 |
guard let scheme = $0.scheme else { return false }
|
| 858 |
for inScheme in schemes where inScheme.rawValue == scheme {
|
| 859 |
return true |
| 860 |
} |
| 861 |
return false |
| 862 |
} |
| 863 |
return urls |
| 864 |
} |
| 865 |
} |
| 866 |
|
| 867 |
public extension FileTransferManager {
|
| 868 |
/// Returns whether or not message should be displayed or hidden from collection. Single incoming URLs should be hidden, for example. |
| 869 |
@objc public static func shouldDisplayMessage(_ message: OTRMessageProtocol, transaction: YapDatabaseReadTransaction) -> Bool {
|
| 870 |
// Always show media messages |
| 871 |
if message.messageMediaItemKey != nil {
|
| 872 |
return true |
| 873 |
} |
| 874 |
// Always show downloads |
| 875 |
if message is OTRDownloadMessage {
|
| 876 |
return true |
| 877 |
} |
| 878 |
// Hide non-media messages that have no text |
| 879 |
guard let messageText = message.messageText else {
|
| 880 |
return false |
| 881 |
} |
| 882 |
|
| 883 |
// Filter out messages that are aesgcm scheme file transfers |
| 884 |
if messageText.contains("aesgcm://"),
|
| 885 |
message.messageError == nil {
|
| 886 |
return false |
| 887 |
} |
| 888 |
|
| 889 |
// Filter out messages that are just URLs and have downloads |
| 890 |
if messageText.isSingleURLOnly, |
| 891 |
message.hasExistingDownloads(with: transaction) {
|
| 892 |
return false |
| 893 |
} |
| 894 |
|
| 895 |
return true |
| 896 |
} |
| 897 |
} |