mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
Build link previews.
This commit is contained in:
parent
d87fc27e78
commit
31ea64bdaf
|
@ -3983,6 +3983,7 @@ typedef enum : NSUInteger {
|
|||
message = [ThreadUtil enqueueMessageWithText:text
|
||||
inThread:self.thread
|
||||
quotedReplyModel:self.inputToolbar.quotedReply
|
||||
linkPreview:nil
|
||||
transaction:transaction];
|
||||
}];
|
||||
}
|
||||
|
|
|
@ -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(), ^{
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
#import "OWSQuotedReplyModel.h"
|
||||
#import "OWSUnreadIndicator.h"
|
||||
#import "TSUnreadIndicatorInteraction.h"
|
||||
#import <PromiseKit/AnyPromise.h>
|
||||
#import <SignalCoreKit/NSDate+OWS.h>
|
||||
#import <SignalMessaging/OWSProfileManager.h>
|
||||
#import <SignalMessaging/SignalMessaging-Swift.h>
|
||||
|
@ -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];
|
||||
|
||||
|
|
|
@ -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<AnyObject, AnyObject> = 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<AnyObject, OWSLinkPreviewInfo> = 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:
|
||||
//
|
||||
// <meta property="og:title" content="Randomness is Random - Numberphile">
|
||||
// <meta property="og:image" content="https://i.ytimg.com/vi/tP-Ipsat90c/maxresdefault.jpg">
|
||||
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: "<meta property=\"og:title\" content=\"([^\"]+)\">", text: linkText)
|
||||
Logger.verbose("title: \(String(describing: title))")
|
||||
|
||||
guard let imageUrlString = parseFirstMatch(pattern: "<meta property=\"og:image\" content=\"([^\"]+)\">", 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<NSString *> *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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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<TSAttachmentStream *> *quotedThumbnailAttachments = @[];
|
||||
|
|
54
SignalServiceKit/src/Network/ReverseProxy.swift
Normal file
54
SignalServiceKit/src/Network/ReverseProxy.swift
Normal file
|
@ -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
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue