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 |
} |