diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index 798311d1a..7b45cbdc7 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -3983,6 +3983,7 @@ typedef enum : NSUInteger { message = [ThreadUtil enqueueMessageWithText:text inThread:self.thread quotedReplyModel:self.inputToolbar.quotedReply + linkPreview:nil transaction:transaction]; }]; } diff --git a/Signal/src/ViewControllers/DebugUI/DebugUIMessages.m b/Signal/src/ViewControllers/DebugUI/DebugUIMessages.m index 9a9a62718..b0a086305 100644 --- a/Signal/src/ViewControllers/DebugUI/DebugUIMessages.m +++ b/Signal/src/ViewControllers/DebugUI/DebugUIMessages.m @@ -359,7 +359,11 @@ NS_ASSUME_NONNULL_BEGIN NSString *text = [[[@(counter) description] stringByAppendingString:@" "] stringByAppendingString:randomText]; __block TSOutgoingMessage *message; [self.dbConnection readWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - message = [ThreadUtil enqueueMessageWithText:text inThread:thread quotedReplyModel:nil transaction:transaction]; + message = [ThreadUtil enqueueMessageWithText:text + inThread:thread + quotedReplyModel:nil + linkPreview:nil + transaction:transaction]; }]; OWSLogError(@"sendTextMessageInThread timestamp: %llu.", message.timestamp); } @@ -3884,6 +3888,7 @@ typedef OWSContact * (^OWSContactBlock)(YapDatabaseReadWriteTransaction *transac [ThreadUtil enqueueMessageWithText:[@(counter) description] inThread:thread quotedReplyModel:nil + linkPreview:nil transaction:transaction]; }]; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)1.f * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ diff --git a/Signal/src/network/GiphyAPI.swift b/Signal/src/network/GiphyAPI.swift index 3ca844f00..25121883c 100644 --- a/Signal/src/network/GiphyAPI.swift +++ b/Signal/src/network/GiphyAPI.swift @@ -1,5 +1,5 @@ // -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. // import Foundation @@ -280,32 +280,8 @@ extension GiphyError: LocalizedError { private let kGiphyBaseURL = "https://api.giphy.com/" - public class func giphySessionConfiguration() -> URLSessionConfiguration { - let configuration = URLSessionConfiguration.ephemeral - let proxyHost = "giphy-proxy-production.whispersystems.org" - let proxyPort = 80 - configuration.connectionProxyDictionary = [ - "HTTPEnable": 1, - "HTTPProxy": proxyHost, - "HTTPPort": proxyPort, - "HTTPSEnable": 1, - "HTTPSProxy": proxyHost, - "HTTPSPort": proxyPort - ] - return configuration - } - private func giphyAPISessionManager() -> AFHTTPSessionManager? { - guard let baseUrl = NSURL(string: kGiphyBaseURL) else { - Logger.error("Invalid base URL.") - return nil - } - let sessionManager = AFHTTPSessionManager(baseURL: baseUrl as URL, - sessionConfiguration: GiphyAPI.giphySessionConfiguration()) - sessionManager.requestSerializer = AFJSONRequestSerializer() - sessionManager.responseSerializer = AFJSONResponseSerializer() - - return sessionManager + return ReverseProxy.jsonSessionManager(baseUrl: kGiphyBaseURL) } // MARK: Search diff --git a/Signal/src/network/GiphyDownloader.swift b/Signal/src/network/GiphyDownloader.swift index 0556ff6cc..86c94ae91 100644 --- a/Signal/src/network/GiphyDownloader.swift +++ b/Signal/src/network/GiphyDownloader.swift @@ -1,5 +1,5 @@ // -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. // import Foundation @@ -415,7 +415,7 @@ extension URLSessionTask { private lazy var giphyDownloadSession: URLSession = { AssertIsOnMainThread() - let configuration = GiphyAPI.giphySessionConfiguration() + let configuration = ReverseProxy.sessionConfiguration() configuration.urlCache = nil configuration.requestCachePolicy = .reloadIgnoringCacheData configuration.httpMaximumConnectionsPerHost = 10 diff --git a/Signal/src/util/Pastelog.m b/Signal/src/util/Pastelog.m index a70559829..f737cf06b 100644 --- a/Signal/src/util/Pastelog.m +++ b/Signal/src/util/Pastelog.m @@ -1,5 +1,5 @@ // -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. // #import "Pastelog.h" @@ -591,6 +591,7 @@ typedef void (^DebugLogUploadFailure)(DebugLogUploader *uploader, NSError *error [ThreadUtil enqueueMessageWithText:url.absoluteString inThread:thread quotedReplyModel:nil + linkPreview:nil transaction:transaction]; }]; }); @@ -615,6 +616,7 @@ typedef void (^DebugLogUploadFailure)(DebugLogUploader *uploader, NSError *error [ThreadUtil enqueueMessageWithText:url.absoluteString inThread:thread quotedReplyModel:nil + linkPreview:nil transaction:transaction]; }]; } else { diff --git a/SignalMessaging/utils/ThreadUtil.h b/SignalMessaging/utils/ThreadUtil.h index fe6348fc6..6a20274ee 100644 --- a/SignalMessaging/utils/ThreadUtil.h +++ b/SignalMessaging/utils/ThreadUtil.h @@ -1,11 +1,12 @@ // -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. // NS_ASSUME_NONNULL_BEGIN @class OWSBlockingManager; @class OWSContactsManager; +@class OWSLinkPreview; @class OWSMessageSender; @class OWSUnreadIndicator; @class SignalAttachment; @@ -47,6 +48,7 @@ NS_ASSUME_NONNULL_BEGIN + (TSOutgoingMessage *)enqueueMessageWithText:(NSString *)text inThread:(TSThread *)thread quotedReplyModel:(nullable OWSQuotedReplyModel *)quotedReplyModel + linkPreview:(nullable OWSLinkPreview *)linkPreview transaction:(YapDatabaseReadTransaction *)transaction; + (TSOutgoingMessage *)enqueueMessageWithAttachment:(SignalAttachment *)attachment diff --git a/SignalMessaging/utils/ThreadUtil.m b/SignalMessaging/utils/ThreadUtil.m index 7a071b428..7d46bc6dd 100644 --- a/SignalMessaging/utils/ThreadUtil.m +++ b/SignalMessaging/utils/ThreadUtil.m @@ -8,6 +8,7 @@ #import "OWSQuotedReplyModel.h" #import "OWSUnreadIndicator.h" #import "TSUnreadIndicatorInteraction.h" +#import #import #import #import @@ -65,9 +66,101 @@ NS_ASSUME_NONNULL_BEGIN #pragma mark - Durable Message Enqueue +// TODO: Move this elsewhere. ++ (void)ensureLinkPreviewForMessage:(TSOutgoingMessage *)message completion:(dispatch_block_t)completion +{ + OWSAssert(message); + OWSAssert(completion); + + if (message.linkPreview != nil) { + // Message already has link preview. + completion(); + return; + } + + [OWSLinkPreview + tryToBuildPreviewInfoForMessageBodyText:message.body + completion:^(OWSLinkPreviewInfo *_Nullable linkPreviewInfo) { + if (!linkPreviewInfo) { + completion(); + return; + } + + [self.dbConnection + asyncReadWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { + NSError *linkPreviewError; + OWSLinkPreview *_Nullable linkPreview = [OWSLinkPreview + buildValidatedLinkPreviewFromInfo:linkPreviewInfo + transaction:transaction + error:&linkPreviewError]; + if (linkPreviewError + && ![OWSLinkPreview isNoPreviewError:linkPreviewError]) { + OWSFailDebug(@"linkPreviewError: %@", linkPreviewError); + completion(); + return; + } + if (!linkPreview) { + OWSFailDebug(@"Missing linkPreview."); + completion(); + return; + } + + [message updateWithLinkPreview:linkPreview transaction:transaction]; + completion(); + }]; + }]; +} + +//+ (TSOutgoingMessage *)enqueueMessageWithText:(NSString *)text +// inThread:(TSThread *)thread +// quotedReplyModel:(nullable OWSQuotedReplyModel *)quotedReplyModel +// linkPreview:(nullable OWSLinkPreview *)linkPreview +// transaction:(YapDatabaseReadTransaction *)transaction +//{ +// OWSDisappearingMessagesConfiguration *configuration = +// [OWSDisappearingMessagesConfiguration fetchObjectWithUniqueID:thread.uniqueId transaction:transaction]; +// +// uint32_t expiresInSeconds = (configuration.isEnabled ? configuration.durationSeconds : 0); +// +// TSOutgoingMessage *message = +// [TSOutgoingMessage outgoingMessageInThread:thread +// messageBody:text +// attachmentId:nil +// expiresInSeconds:expiresInSeconds +// quotedMessage:[quotedReplyModel buildQuotedMessageForSending] +// linkPreview:linkPreview]; +// +// [BenchManager benchAsyncWithTitle:@"Saving outgoing message" block:^(void (^benchmarkCompletion)(void)) { +// // To avoid blocking the send flow, we dispatch an async write from within this read transaction +// AnyPromise *promise = [[AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) { +// [self.dbConnection asyncReadWriteWithBlock:^(YapDatabaseReadWriteTransaction *writeTransaction) { +// [message saveWithTransaction:writeTransaction]; +// } +// completionBlock:^{ +// resolve(@(1)); +// }]; +// }].thenInBackground(^{ +// return [AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) { +// [ThreadUtil ensureLinkPreviewForMessage:message +// completion:^{ +// resolve(@(1)); +// }]; +// }]; +// }].thenInBackground(^{ +// [self.messageSenderJobQueue addMessage:message +// transaction:writeTransaction]; +// }].thenInBackground(^{ +// benchmarkCompletion(); +// }) retainUntilComplete]; +// }]; +// +// return message; +//} + + (TSOutgoingMessage *)enqueueMessageWithText:(NSString *)text inThread:(TSThread *)thread quotedReplyModel:(nullable OWSQuotedReplyModel *)quotedReplyModel + linkPreview:(nullable OWSLinkPreview *)linkPreview transaction:(YapDatabaseReadTransaction *)transaction { OWSDisappearingMessagesConfiguration *configuration = @@ -80,7 +173,8 @@ NS_ASSUME_NONNULL_BEGIN messageBody:text attachmentId:nil expiresInSeconds:expiresInSeconds - quotedMessage:[quotedReplyModel buildQuotedMessageForSending]]; + quotedMessage:[quotedReplyModel buildQuotedMessageForSending] + linkPreview:linkPreview]; [BenchManager benchAsyncWithTitle:@"Saving outgoing message" block:^(void (^benchmarkCompletion)(void)) { // To avoid blocking the send flow, we dispatch an async write from within this read transaction @@ -193,6 +287,7 @@ NS_ASSUME_NONNULL_BEGIN // MARK: Non-Durable Sending +// We might want to generate a link preview here. + (TSOutgoingMessage *)sendMessageNonDurablyWithText:(NSString *)text inThread:(TSThread *)thread quotedReplyModel:(nullable OWSQuotedReplyModel *)quotedReplyModel @@ -213,7 +308,8 @@ NS_ASSUME_NONNULL_BEGIN messageBody:text attachmentId:nil expiresInSeconds:expiresInSeconds - quotedMessage:[quotedReplyModel buildQuotedMessageForSending]]; + quotedMessage:[quotedReplyModel buildQuotedMessageForSending] + linkPreview:nil]; [messageSender sendMessage:message success:successHandler failure:failureHandler]; diff --git a/SignalServiceKit/src/Messages/Interactions/OWSLinkPreview.swift b/SignalServiceKit/src/Messages/Interactions/OWSLinkPreview.swift index 716ea807a..f6c806ab5 100644 --- a/SignalServiceKit/src/Messages/Interactions/OWSLinkPreview.swift +++ b/SignalServiceKit/src/Messages/Interactions/OWSLinkPreview.swift @@ -7,12 +7,48 @@ import Foundation @objc public enum LinkPreviewError: Int, Error { case invalidInput - case assertionFailure case noPreview } -@objc(OWSLinkPreview) +// MARK: - OWSLinkPreviewInfo + +// This contains the info for a link preview "draft". +public class OWSLinkPreviewInfo: NSObject { + @objc + public var urlString: String + + @objc + public var title: String? + + @objc + public var imageFilePath: String? + + @objc + public init(urlString: String, title: String?, imageFilePath: String? = nil) { + self.urlString = urlString + self.title = title + self.imageFilePath = imageFilePath + + super.init() + } + + fileprivate func isValid() -> Bool { + var hasTitle = false + if let titleValue = title { + hasTitle = titleValue.count > 0 + } + let hasImage = imageFilePath != nil + return hasTitle || hasImage + } +} + +// MARK: - OWSLinkPreview + +@objc public class OWSLinkPreview: MTLModel { + @objc + public static let featureEnabled = true + @objc public var urlString: String? @@ -31,6 +67,11 @@ public class OWSLinkPreview: MTLModel { super.init() } + @objc + public override init() { + super.init() + } + @objc public required init!(coder: NSCoder) { super.init(coder: coder) @@ -53,6 +94,9 @@ public class OWSLinkPreview: MTLModel { public class func buildValidatedLinkPreview(dataMessage: SSKProtoDataMessage, body: String?, transaction: YapDatabaseReadWriteTransaction) throws -> OWSLinkPreview { + guard OWSLinkPreview.featureEnabled else { + throw LinkPreviewError.noPreview + } guard let previewProto = dataMessage.preview else { throw LinkPreviewError.noPreview } @@ -73,7 +117,11 @@ public class OWSLinkPreview: MTLModel { throw LinkPreviewError.invalidInput } - // TODO: Verify that url host is in whitelist. + guard isValidLinkUrl(urlString) else { + Logger.verbose("Invalid link URL \(urlString).") + Logger.error("Invalid link URL.") + throw LinkPreviewError.invalidInput + } let title: String? = previewProto.title?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -88,17 +136,70 @@ public class OWSLinkPreview: MTLModel { } } + let linkPreview = OWSLinkPreview(urlString: urlString, title: title, imageAttachmentId: imageAttachmentId) + + guard linkPreview.isValid() else { + owsFailDebug("Preview has neither title nor image.") + throw LinkPreviewError.invalidInput + } + + return linkPreview + } + + @objc + public class func buildValidatedLinkPreview(fromInfo info: OWSLinkPreviewInfo, + transaction: YapDatabaseReadWriteTransaction) throws -> OWSLinkPreview { + guard OWSLinkPreview.featureEnabled else { + throw LinkPreviewError.noPreview + } + let imageAttachmentId = OWSLinkPreview.saveAttachmentIfPossible(forFilePath: info.imageFilePath, + transaction: transaction) + + let linkPreview = OWSLinkPreview(urlString: info.urlString, title: info.title, imageAttachmentId: imageAttachmentId) + + guard linkPreview.isValid() else { + owsFailDebug("Preview has neither title nor image.") + throw LinkPreviewError.invalidInput + } + + return linkPreview + } + + private class func saveAttachmentIfPossible(forFilePath filePath: String?, + transaction: YapDatabaseReadWriteTransaction) -> String? { + guard let filePath = filePath else { + return nil + } + guard let fileSize = OWSFileSystem.fileSize(ofPath: filePath) else { + owsFailDebug("Unknown file size for path: \(filePath)") + return nil + } + guard fileSize.uint32Value > 0 else { + owsFailDebug("Invalid file size for path: \(filePath)") + return nil + } + let filename = (filePath as NSString).lastPathComponent + let fileExtension = (filename as NSString).pathExtension + guard fileExtension.count > 0 else { + owsFailDebug("Invalid file extension for path: \(filePath)") + return nil + } + guard let contentType = MIMETypeUtil.mimeType(forFileExtension: fileExtension) else { + owsFailDebug("Invalid content type for path: \(filePath)") + return nil + } + let attachment = TSAttachmentStream(contentType: contentType, byteCount: fileSize.uint32Value, sourceFilename: nil, caption: nil, albumMessageId: nil) + attachment.save(with: transaction) + return attachment.uniqueId + } + + private func isValid() -> Bool { var hasTitle = false if let titleValue = title { hasTitle = titleValue.count > 0 } let hasImage = imageAttachmentId != nil - if !hasTitle && !hasImage { - Logger.error("Preview has neither title nor image.") - throw LinkPreviewError.invalidInput - } - - return OWSLinkPreview(urlString: urlString, title: title, imageAttachmentId: imageAttachmentId) + return hasTitle || hasImage } @objc @@ -117,20 +218,30 @@ public class OWSLinkPreview: MTLModel { // MARK: - Domain Whitelist private static let linkDomainWhitelist = [ - "youtube.com", - "reddit.com", - "imgur.com", - "instagram.com" + "youtube.com", + "reddit.com", + "imgur.com", + "instagram.com" ] private static let mediaDomainWhitelist = [ "ytimg.com", "cdninstagram.com" - ] + ] private static let protocolWhitelist = [ "https" - ] + ] + + // *.giphy.com + // *.youtube.com + // *.youtu.be + // *.ytimg.com + // *.reddit.com + // *.reddi.it + // *.imgur.com + // *.instagram.com + // *.cdninstagram.com @objc public class func isValidLinkUrl(_ urlString: String) -> Bool { @@ -147,7 +258,7 @@ public class OWSLinkPreview: MTLModel { return false } return isUrlInDomainWhitelist(url: url, - domainWhitelist: OWSLinkPreview.linkDomainWhitelist + OWSLinkPreview.mediaDomainWhitelist) + domainWhitelist: OWSLinkPreview.linkDomainWhitelist + OWSLinkPreview.mediaDomainWhitelist) } private class func isUrlInDomainWhitelist(url: URL, domainWhitelist: [String]) -> Bool { @@ -176,19 +287,257 @@ public class OWSLinkPreview: MTLModel { return false } + // MARK: - Serial Queue + + private static let serialQueue = DispatchQueue(label: "org.signal.linkPreview") + + private class func assertIsOnSerialQueue() { + if _isDebugAssertConfiguration(), #available(iOS 10.0, *) { + assertOnQueue(serialQueue) + } + } + // MARK: - Text Parsing - @objc - public class func previewUrl(forMessageBodyText body: String?) -> String? { + // This cache should only be accessed on serialQueue. + private static var previewUrlCache: NSCache = NSCache() + + private class func previewUrl(forMessageBodyText body: String?) -> String? { + assertIsOnSerialQueue() + + guard OWSLinkPreview.featureEnabled else { + return nil + } + guard let body = body else { return nil } + if let cachedUrl = previewUrlCache.object(forKey: body as AnyObject) as? String { + Logger.verbose("URL parsing cache hit.") + guard cachedUrl.count > 0 else { + return nil + } + return cachedUrl + } let components = body.components(separatedBy: .whitespacesAndNewlines) for component in components { if isValidLinkUrl(component) { + previewUrlCache.setObject(component as AnyObject, forKey: body as AnyObject) return component } } return nil } + + // MARK: - Preview Construction + + // This cache should only be accessed on serialQueue. + private static var linkPreviewInfoCache: NSCache = NSCache() + + // Completion will always be invoked exactly once. + // + // The completion is called with a link preview if one can be built for + // the message body. It building the preview fails, completion will be + // called with nil to avoid failing the message send. + // + // NOTE: Completion might be invoked on any thread. + @objc + public class func tryToBuildPreviewInfo(forMessageBodyText body: String?, + completion: @escaping (OWSLinkPreviewInfo?) -> Void) { + guard OWSLinkPreview.featureEnabled else { + completion(nil) + return + } + guard let body = body else { + completion(nil) + return + } + serialQueue.async { + guard let previewUrl = previewUrl(forMessageBodyText: body) else { + completion(nil) + return + } + + if let cachedInfo = linkPreviewInfoCache.object(forKey: previewUrl as AnyObject) { + Logger.verbose("Link preview info cache hit.") + completion(cachedInfo) + return + } + downloadContents(ofUrl: previewUrl, completion: { (data) in + DispatchQueue.global().async { + guard let data = data else { + completion(nil) + return + } + parse(linkData: data, linkUrlString: previewUrl) { (linkPreviewInfo) in + guard let linkPreviewInfo = linkPreviewInfo else { + completion(nil) + return + } + guard linkPreviewInfo.isValid() else { + completion(nil) + return + } + serialQueue.async { + previewUrlCache.setObject(linkPreviewInfo, forKey: previewUrl as AnyObject) + + DispatchQueue.global().async { + completion(linkPreviewInfo) + } + } + } + } + }) + } + } + + private class func downloadContents(ofUrl url: String, + completion: @escaping (Data?) -> Void, + remainingRetries: UInt = 3) { + + Logger.verbose("url: \(url)") + + guard let sessionManager: AFHTTPSessionManager = ReverseProxy.sessionManager(baseUrl: nil) else { + owsFailDebug("Couldn't create session manager.") + completion(nil) + return + } + sessionManager.requestSerializer = AFHTTPRequestSerializer() + sessionManager.responseSerializer = AFHTTPResponseSerializer() + + // Remove all headers from the request. + for headerField in sessionManager.requestSerializer.httpRequestHeaders.keys { + sessionManager.requestSerializer.setValue(nil, forHTTPHeaderField: headerField) + } + + sessionManager.get(url, + parameters: {}, + progress: nil, + success: { _, value in + + guard let data = value as? Data else { + Logger.warn("Result is not data: \(type(of: value)).") + completion(nil) + return + } + completion(data) + }, + failure: { _, error in + Logger.verbose("Error: \(error)") + + guard isRetryable(error: error) else { + Logger.warn("Error is not retryable.") + completion(nil) + return + } + + guard remainingRetries > 0 else { + Logger.warn("No more retries.") + completion(nil) + return + } + OWSLinkPreview.downloadContents(ofUrl: url, completion: completion, remainingRetries: remainingRetries - 1) + }) + + } + + private class func isRetryable(error: Error) -> Bool { + let nsError = error as NSError + if nsError.domain == kCFErrorDomainCFNetwork as String { + // Network failures are retried. + return true + } + return false + } + + // Example: + // + // + // + private class func parse(linkData: Data, + linkUrlString: String, + completion: @escaping (OWSLinkPreviewInfo?) -> Void) { + guard let linkText = String(bytes: linkData, encoding: .utf8) else { + owsFailDebug("Could not parse link text.") + completion(nil) + return + } + Logger.verbose("linkText: \(linkText)") + + let title = parseFirstMatch(pattern: "", text: linkText) + Logger.verbose("title: \(String(describing: title))") + + guard let imageUrlString = parseFirstMatch(pattern: "", text: linkText) else { + return completion(OWSLinkPreviewInfo(urlString: linkUrlString, title: title)) + } + Logger.verbose("imageUrlString: \(imageUrlString)") + guard let imageUrl = URL(string: imageUrlString) else { + Logger.error("Could not parse image URL.") + return completion(OWSLinkPreviewInfo(urlString: linkUrlString, title: title)) + } + let imageFilename = imageUrl.lastPathComponent + let imageFileExtension = (imageFilename as NSString).pathExtension.lowercased() + guard let imageMimeType = MIMETypeUtil.mimeType(forFileExtension: imageFileExtension) else { + Logger.error("Image URL has unknown content type: \(imageFileExtension).") + return completion(OWSLinkPreviewInfo(urlString: linkUrlString, title: title)) + } + let kValidMimeTypes = [ + OWSMimeTypeImagePng, + OWSMimeTypeImageJpeg + ] + guard kValidMimeTypes.contains(imageMimeType) else { + Logger.error("Image URL has invalid content type: \(imageMimeType).") + return completion(OWSLinkPreviewInfo(urlString: linkUrlString, title: title)) + } + + downloadContents(ofUrl: imageUrlString, + completion: { (imageData) in + guard let imageData = imageData else { + Logger.error("Could not download image.") + return completion(OWSLinkPreviewInfo(urlString: linkUrlString, title: title)) + } + let imageFilePath = OWSFileSystem.temporaryFilePath(withFileExtension: imageFileExtension) + do { + try imageData.write(to: NSURL.fileURL(withPath: imageFilePath), options: .atomicWrite) + } catch let error as NSError { + owsFailDebug("file write failed: \(imageFilePath), \(error)") + return completion(OWSLinkPreviewInfo(urlString: linkUrlString, title: title)) + } + // NOTE: imageSize(forFilePath:...) will call ows_isValidImage(...). + let imageSize = NSData.imageSize(forFilePath: imageFilePath, mimeType: imageMimeType) + let kMaxImageSize: CGFloat = 2048 + guard imageSize.width > 0, + imageSize.height > 0, + imageSize.width < kMaxImageSize, + imageSize.height < kMaxImageSize else { + Logger.error("Image has invalid size: \(imageSize).") + return completion(OWSLinkPreviewInfo(urlString: linkUrlString, title: title)) + } + + let linkPreviewInfo = OWSLinkPreviewInfo(urlString: linkUrlString, title: title, imageFilePath: imageFilePath) + completion(linkPreviewInfo) + }) + } + + private class func parseFirstMatch(pattern: String, + text: String) -> String? { + do { + let regex = try NSRegularExpression(pattern: pattern) + guard let match = regex.firstMatch(in: text, + options: [], + range: NSRange(location: 0, length: text.count)) else { + return nil + } + let matchRange = match.range(at: 1) + guard let textRange = Range(matchRange, in: text) else { + owsFailDebug("Invalid match.") + return nil + } + let substring = String(text[textRange]) + return substring + } catch { + Logger.error("Error: \(error)") + return nil + } + } } diff --git a/SignalServiceKit/src/Messages/Interactions/TSMessage.h b/SignalServiceKit/src/Messages/Interactions/TSMessage.h index cf353f3ca..55297e154 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSMessage.h +++ b/SignalServiceKit/src/Messages/Interactions/TSMessage.h @@ -61,6 +61,8 @@ NS_ASSUME_NONNULL_BEGIN - (void)updateWithExpireStartedAt:(uint64_t)expireStartedAt transaction:(YapDatabaseReadWriteTransaction *)transaction; +- (void)updateWithLinkPreview:(OWSLinkPreview *)linkPreview transaction:(YapDatabaseReadWriteTransaction *)transaction; + @end NS_ASSUME_NONNULL_END diff --git a/SignalServiceKit/src/Messages/Interactions/TSMessage.m b/SignalServiceKit/src/Messages/Interactions/TSMessage.m index a1a6979b2..53def73d2 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSMessage.m +++ b/SignalServiceKit/src/Messages/Interactions/TSMessage.m @@ -48,6 +48,8 @@ static const NSUInteger OWSMessageSchemaVersion = 4; */ @property (nonatomic, readonly) NSUInteger schemaVersion; +@property (nonatomic, nullable) OWSLinkPreview *linkPreview; + @end #pragma mark - @@ -419,6 +421,17 @@ static const NSUInteger OWSMessageSchemaVersion = 4; }]; } +- (void)updateWithLinkPreview:(OWSLinkPreview *)linkPreview transaction:(YapDatabaseReadWriteTransaction *)transaction +{ + OWSAssertDebug(linkPreview); + OWSAssertDebug(transaction); + + [self applyChangeToSelfAndLatestCopy:transaction + changeBlock:^(TSOutgoingMessage *message) { + message.linkPreview = linkPreview; + }]; +} + @end NS_ASSUME_NONNULL_END diff --git a/SignalServiceKit/src/Messages/Interactions/TSOutgoingMessage.h b/SignalServiceKit/src/Messages/Interactions/TSOutgoingMessage.h index 1bf16def3..b007d7fc4 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSOutgoingMessage.h +++ b/SignalServiceKit/src/Messages/Interactions/TSOutgoingMessage.h @@ -108,7 +108,8 @@ typedef NS_ENUM(NSInteger, TSGroupMetaMessage) { messageBody:(nullable NSString *)body attachmentId:(nullable NSString *)attachmentId expiresInSeconds:(uint32_t)expiresInSeconds - quotedMessage:(nullable TSQuotedMessage *)quotedMessage; + quotedMessage:(nullable TSQuotedMessage *)quotedMessage + linkPreview:(nullable OWSLinkPreview *)linkPreview; + (instancetype)outgoingMessageInThread:(nullable TSThread *)thread groupMetaMessage:(TSGroupMetaMessage)groupMetaMessage diff --git a/SignalServiceKit/src/Messages/Interactions/TSOutgoingMessage.m b/SignalServiceKit/src/Messages/Interactions/TSOutgoingMessage.m index 085469b63..81e90e1ba 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSOutgoingMessage.m +++ b/SignalServiceKit/src/Messages/Interactions/TSOutgoingMessage.m @@ -231,7 +231,8 @@ NSString *NSStringForOutgoingMessageRecipientState(OWSOutgoingMessageRecipientSt messageBody:body attachmentId:attachmentId expiresInSeconds:0 - quotedMessage:nil]; + quotedMessage:nil + linkPreview:nil]; } + (instancetype)outgoingMessageInThread:(nullable TSThread *)thread @@ -243,7 +244,8 @@ NSString *NSStringForOutgoingMessageRecipientState(OWSOutgoingMessageRecipientSt messageBody:body attachmentId:attachmentId expiresInSeconds:expiresInSeconds - quotedMessage:nil]; + quotedMessage:nil + linkPreview:nil]; } + (instancetype)outgoingMessageInThread:(nullable TSThread *)thread @@ -251,6 +253,7 @@ NSString *NSStringForOutgoingMessageRecipientState(OWSOutgoingMessageRecipientSt attachmentId:(nullable NSString *)attachmentId expiresInSeconds:(uint32_t)expiresInSeconds quotedMessage:(nullable TSQuotedMessage *)quotedMessage + linkPreview:(nullable OWSLinkPreview *)linkPreview { NSMutableArray *attachmentIds = [NSMutableArray new]; if (attachmentId) { @@ -268,7 +271,7 @@ NSString *NSStringForOutgoingMessageRecipientState(OWSOutgoingMessageRecipientSt groupMetaMessage:TSGroupMetaMessageUnspecified quotedMessage:quotedMessage contactShare:nil - linkPreview:nil]; + linkPreview:linkPreview]; } + (instancetype)outgoingMessageInThread:(nullable TSThread *)thread @@ -964,6 +967,32 @@ NSString *NSStringForOutgoingMessageRecipientState(OWSOutgoingMessageRecipientSt } } + // Link Preview + if (self.linkPreview) { + SSKProtoDataMessagePreviewBuilder *previewBuilder = + [SSKProtoDataMessagePreview builderWithUrl:self.linkPreview.urlString]; + if (self.linkPreview.title.length > 0) { + [previewBuilder setTitle:self.linkPreview.title]; + } + if (self.linkPreview.imageAttachmentId) { + SSKProtoAttachmentPointer *_Nullable attachmentProto = + [TSAttachmentStream buildProtoForAttachmentId:self.linkPreview.imageAttachmentId]; + if (!attachmentProto) { + OWSFailDebug(@"Could not build link preview image protobuf."); + } else { + [previewBuilder setImage:attachmentProto]; + } + } + + NSError *error; + SSKProtoDataMessagePreview *_Nullable previewProto = [previewBuilder buildAndReturnError:&error]; + if (error || !previewProto) { + OWSFailDebug(@"Could not build link preview protobuf: %@.", error); + } else { + [builder setPreview:previewProto]; + } + } + return builder; } diff --git a/SignalServiceKit/src/Messages/OWSMessageSender.m b/SignalServiceKit/src/Messages/OWSMessageSender.m index 977f1cc4c..7e4b32aaa 100644 --- a/SignalServiceKit/src/Messages/OWSMessageSender.m +++ b/SignalServiceKit/src/Messages/OWSMessageSender.m @@ -354,6 +354,66 @@ NSString *const OWSMessageSenderRateLimitedException = @"RateLimitedException"; OWSAssertDebug([message.body lengthOfBytesUsingEncoding:NSUTF8StringEncoding] <= kOversizeTextMessageSizeThreshold); } + [self ensureLinkPreviewForMessage:message + completion:^{ + [self prepareMessageAndEnqueue:message success:successHandler failure:failureHandler]; + }]; +} + +- (void)ensureLinkPreviewForMessage:(TSOutgoingMessage *)message completion:(dispatch_block_t)completion +{ + OWSAssert(message); + OWSAssert(completion); + + if (message.linkPreview != nil) { + // Message already has link preview. + completion(); + return; + } + + [OWSLinkPreview + tryToBuildPreviewInfoForMessageBodyText:message.body + completion:^(OWSLinkPreviewInfo *_Nullable linkPreviewInfo) { + if (!linkPreviewInfo) { + completion(); + return; + } + + [self.dbConnection + asyncReadWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { + NSError *linkPreviewError; + OWSLinkPreview *_Nullable linkPreview = [OWSLinkPreview + buildValidatedLinkPreviewFromInfo:linkPreviewInfo + transaction:transaction + error:&linkPreviewError]; + if (linkPreviewError + && ![OWSLinkPreview isNoPreviewError:linkPreviewError]) { + OWSFailDebug(@"linkPreviewError: %@", linkPreviewError); + completion(); + return; + } + if (!linkPreview) { + OWSFailDebug(@"Missing linkPreview."); + completion(); + return; + } + + [message updateWithLinkPreview:linkPreview transaction:transaction]; + completion(); + }]; + }]; +} + +- (void)prepareMessageAndEnqueue:(TSOutgoingMessage *)message + success:(void (^)(void))successHandler + failure:(void (^)(NSError *error))failureHandler +{ + OWSAssertDebug(message); + if (message.body.length > 0) { + OWSAssertDebug( + [message.body lengthOfBytesUsingEncoding:NSUTF8StringEncoding] <= kOversizeTextMessageSizeThreshold); + } + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ __block NSArray *quotedThumbnailAttachments = @[]; diff --git a/SignalServiceKit/src/Network/ReverseProxy.swift b/SignalServiceKit/src/Network/ReverseProxy.swift new file mode 100644 index 000000000..275c614d7 --- /dev/null +++ b/SignalServiceKit/src/Network/ReverseProxy.swift @@ -0,0 +1,54 @@ +// +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. +// + +import Foundation + +@objc +public class ReverseProxy: NSObject { + + @available(*, unavailable, message:"do not instantiate this class.") + private override init() { + } + + @objc + public class func sessionConfiguration() -> URLSessionConfiguration { + let configuration = URLSessionConfiguration.ephemeral + let proxyHost = "contentproxy.signal.org" + let proxyPort = 443 + configuration.connectionProxyDictionary = [ + "HTTPEnable": 1, + "HTTPProxy": proxyHost, + "HTTPPort": proxyPort, + "HTTPSEnable": 1, + "HTTPSProxy": proxyHost, + "HTTPSPort": proxyPort + ] + return configuration + } + + @objc + public class func sessionManager(baseUrl baseUrlString: String?) -> AFHTTPSessionManager? { + guard let baseUrlString = baseUrlString else { + return AFHTTPSessionManager(baseURL: nil, sessionConfiguration: sessionConfiguration()) + } + guard let baseUrl = URL(string: baseUrlString) else { + owsFailDebug("Invalid base URL.") + return nil + } + let sessionManager = AFHTTPSessionManager(baseURL: baseUrl, + sessionConfiguration: sessionConfiguration()) + return sessionManager + } + + @objc + public class func jsonSessionManager(baseUrl: String) -> AFHTTPSessionManager? { + guard let sessionManager = self.sessionManager(baseUrl: baseUrl) else { + owsFailDebug("Could not create session manager") + return nil + } + sessionManager.requestSerializer = AFJSONRequestSerializer() + sessionManager.responseSerializer = AFJSONResponseSerializer() + return sessionManager + } +}