Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalUI/LinkPreview/LinkPreviewFetcher.swift
1 views
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
public import SignalServiceKit

public protocol LinkPreviewFetcher {
    func fetchLinkPreview(for url: URL) async throws -> OWSLinkPreviewDraft
}

#if TESTABLE_BUILD

class MockLinkPreviewFetcher: LinkPreviewFetcher {
    var fetchedURLs: [URL] { _fetchedURLs.get() }
    let _fetchedURLs = AtomicValue<[URL]>([], lock: .init())

    var fetchLinkPreviewBlock: ((URL) async throws -> OWSLinkPreviewDraft)?

    func fetchLinkPreview(for url: URL) async throws -> OWSLinkPreviewDraft {
        _fetchedURLs.update { $0.append(url) }
        return try await fetchLinkPreviewBlock!(url)
    }
}

#endif

public class LinkPreviewFetcherImpl: LinkPreviewFetcher {
    private let authCredentialManager: any AuthCredentialManager
    private let db: any DB
    private let groupsV2: any GroupsV2
    private let linkPreviewSettingStore: LinkPreviewSettingStore
    private let tsAccountManager: any TSAccountManager

    public init(
        authCredentialManager: any AuthCredentialManager,
        db: any DB,
        groupsV2: any GroupsV2,
        linkPreviewSettingStore: LinkPreviewSettingStore,
        tsAccountManager: any TSAccountManager,
    ) {
        self.authCredentialManager = authCredentialManager
        self.db = db
        self.groupsV2 = groupsV2
        self.linkPreviewSettingStore = linkPreviewSettingStore
        self.tsAccountManager = tsAccountManager
    }

    public func fetchLinkPreview(for url: URL) async throws -> OWSLinkPreviewDraft {
        let areLinkPreviewsEnabled: Bool = self.db.read(block: linkPreviewSettingStore.areLinkPreviewsEnabled(tx:))
        guard areLinkPreviewsEnabled else {
            throw LinkPreviewError.featureDisabled
        }

        let linkPreviewDraft: OWSLinkPreviewDraft?
        if StickerPackInfo.isStickerPackShare(url) {
            linkPreviewDraft = try await self.linkPreviewDraft(forStickerShare: url)
        } else if GroupManager.isPossibleGroupInviteLink(url) {
            linkPreviewDraft = try await self.linkPreviewDraft(forGroupInviteLink: url)
        } else if let callLink = CallLink(url: url) {
            let linkName = try await self.fetchName(forCallLink: callLink)
            linkPreviewDraft = OWSLinkPreviewDraft(url: url, title: linkName, isForwarded: false)
        } else {
            linkPreviewDraft = try await self.fetchLinkPreview(forGenericUrl: url)
        }
        guard let linkPreviewDraft else {
            throw LinkPreviewError.noPreview
        }
        return linkPreviewDraft
    }

    private func fetchLinkPreview(forGenericUrl url: URL) async throws -> OWSLinkPreviewDraft? {
        let normalizedTitle: String?
        let normalizedDescription: String?
        let previewThumbnail: PreviewThumbnail?
        let dateForLinkPreview: Date?

        switch try await self.fetchStringOrImageResource(from: url) {
        case .string(let respondingUrl, let rawHtml):
            let content = HTMLMetadata.construct(parsing: rawHtml)
            let rawTitle = content.ogTitle ?? content.titleTag
            normalizedTitle = rawTitle.map { LinkPreviewHelper.normalizeString($0, maxLines: 2) }?.nilIfEmpty
            var rawDescription = content.ogDescription ?? content.description
            if rawDescription == rawTitle {
                rawDescription = nil
            }
            normalizedDescription = rawDescription.map { LinkPreviewHelper.normalizeString($0, maxLines: 3) }
            dateForLinkPreview = content.dateForLinkPreview

            if
                let imageUrlString = content.ogImageUrlString ?? content.faviconUrlString,
                let imageUrl = URL(string: imageUrlString, relativeTo: respondingUrl),
                let imageData = try? await self.fetchImageResource(from: imageUrl)
            {
                previewThumbnail = await Self.previewThumbnail(srcImageData: imageData)
            } else {
                previewThumbnail = nil
            }

        case .image(let url, let contents):
            previewThumbnail = await Self.previewThumbnail(srcImageData: contents)
            normalizedDescription = nil
            dateForLinkPreview = nil
            normalizedTitle = if previewThumbnail != nil {
                // The best we can do for a title is the filename in the URL itself,
                // but that's no worse than the body of the message.
                url.lastPathComponent.filterStringForDisplay().nilIfEmpty
            } else {
                nil
            }
        }

        guard normalizedTitle != nil || previewThumbnail != nil else {
            return nil
        }

        return OWSLinkPreviewDraft(
            url: url,
            title: normalizedTitle,
            imageData: previewThumbnail?.imageData,
            imageMimeType: previewThumbnail?.mimetype,
            previewDescription: normalizedDescription,
            date: dateForLinkPreview,
            isForwarded: false,
        )
    }

    private func buildOWSURLSession() -> OWSURLSessionProtocol {
        let sessionConfig = URLSessionConfiguration.ephemeral
        sessionConfig.urlCache = nil
        sessionConfig.requestCachePolicy = .reloadIgnoringLocalCacheData

        // Twitter doesn't return OpenGraph tags to Signal
        // `curl -A Signal "https://twitter.com/signalapp/status/1280166087577997312?s=20"`
        // If this ever changes, we can switch back to our default User-Agent
        let userAgentString = "WhatsApp/2"
        let extraHeaders: HttpHeaders = [HttpHeaders.userAgentHeaderKey: userAgentString]

        let urlSession = OWSURLSession(
            securityPolicy: OWSURLSession.defaultSecurityPolicy,
            configuration: sessionConfig,
            extraHeaders: extraHeaders,
            maxResponseSize: Self.maxFetchedContentSize,
        )
        urlSession.allowRedirects = true
        urlSession.customRedirectHandler = { request in
            guard request.url.map({ LinkPreviewHelper.isPermittedLinkPreviewUrl($0) }) == true else {
                return nil
            }
            return request
        }
        return urlSession
    }

    enum StringOrImageResource {
        case string(url: URL, contents: String)
        case image(url: URL, contents: Data)

        static func dataForImage(_ response: HTTPResponse) -> Data? {
            guard let rawData = response.responseBodyData, rawData.count < maxFetchedContentSize else {
                return nil
            }
            return rawData
        }
    }

    func fetchStringOrImageResource(from url: URL) async throws -> StringOrImageResource {
        let response: HTTPResponse
        do {
            response = try await self.buildOWSURLSession().performRequest(url.absoluteString, method: .get, ignoreAppExpiry: true)
        } catch {
            Logger.warn("Invalid response: \(error.shortDescription).")
            throw LinkPreviewError.fetchFailure
        }
        let statusCode = response.responseStatusCode
        guard statusCode >= 200, statusCode < 300 else {
            Logger.warn("Invalid response: \(statusCode).")
            throw LinkPreviewError.fetchFailure
        }

        // TODO: Add support for HEIC, HEIF, JPEG XL, etc.
        if
            let mimeType = response.headers.value(forHeader: "Content-Type"),
            MimeTypeUtil.isSupportedImageMimeType(mimeType)
        {
            guard let imageData = StringOrImageResource.dataForImage(response) else {
                Logger.warn("Response object could not be parsed")
                throw LinkPreviewError.invalidPreview
            }
            return .image(url: response.requestUrl, contents: imageData)
        }

        guard let string = response.responseBodyString, !string.isEmpty else {
            Logger.warn("Response object could not be parsed")
            throw LinkPreviewError.invalidPreview
        }
        return .string(url: response.requestUrl, contents: string)
    }

    private func fetchImageResource(from url: URL) async throws -> Data {
        let response: HTTPResponse
        do {
            response = try await self.buildOWSURLSession().performRequest(url.absoluteString, method: .get, ignoreAppExpiry: true)
        } catch {
            Logger.warn("Invalid response: \(error.shortDescription).")
            throw LinkPreviewError.fetchFailure
        }
        let statusCode = response.responseStatusCode
        guard statusCode >= 200, statusCode < 300 else {
            Logger.warn("Invalid response: \(statusCode).")
            throw LinkPreviewError.fetchFailure
        }
        guard let rawData = StringOrImageResource.dataForImage(response) else {
            Logger.warn("Response object could not be parsed")
            throw LinkPreviewError.invalidPreview
        }
        return rawData
    }

    // MARK: - Private, Constants

    private static let maxFetchedContentSize: UInt64 = 2 * 1024 * 1024

    // MARK: - Preview Thumbnails

    private struct PreviewThumbnail {
        let imageData: Data
        let mimetype: String
    }

    private static func previewThumbnail(srcImageData: Data?) async -> PreviewThumbnail? {
        guard let srcImageData else {
            return nil
        }
        let imageSource = DataImageSource(srcImageData)
        let imageMetadata = imageSource.imageMetadata()
        guard let imageMetadata else {
            return nil
        }
        let imageFormat = imageMetadata.imageFormat
        let imageSize = imageMetadata.pixelSize

        let maxImageSize: CGFloat = 2400
        let isOriginalValid: Bool = (
            imageSize.width <= maxImageSize
                && imageSize.height <= maxImageSize
                && !imageMetadata.isAnimated
                && (imageMetadata.imageFormat == .jpeg || imageMetadata.imageFormat == .png),
        )

        if isOriginalValid {
            // If we don't need to resize or convert the file format,
            // return the original data.
            return PreviewThumbnail(imageData: srcImageData, mimetype: imageFormat.mimeType.rawValue)
        }

        let cgImageSource = CGImageSourceCreateWithData(
            srcImageData as CFData,
            [kCGImageSourceShouldCache: false] as CFDictionary,
        )
        guard let cgImageSource else {
            Logger.warn("couldn't parse image")
            return nil
        }
        let dstImage = NormalizedImage.loadImage(imageSource: cgImageSource, maxPixelSize: maxImageSize)
        guard let dstImage else {
            Logger.warn("couldn't load/resize image")
            return nil
        }

        if imageMetadata.hasAlpha {
            guard let dstData = UIImage(cgImage: dstImage).pngData() else {
                owsFailDebug("Could not write resized image to PNG.")
                return nil
            }
            return PreviewThumbnail(imageData: dstData, mimetype: MimeType.imagePng.rawValue)
        } else {
            guard let dstData = UIImage(cgImage: dstImage).jpegData(compressionQuality: 0.8) else {
                owsFailDebug("Could not write resized image to JPEG.")
                return nil
            }
            return PreviewThumbnail(imageData: dstData, mimetype: MimeType.imageJpeg.rawValue)
        }
    }

    // MARK: - Stickers

    private func linkPreviewDraft(forStickerShare url: URL) async throws -> OWSLinkPreviewDraft? {
        guard let stickerPackInfo = StickerPackInfo.parseStickerPackShare(url) else {
            Logger.error("Could not parse url.")
            throw LinkPreviewError.invalidPreview
        }
        // tryToDownloadStickerPack will use locally saved data if possible...
        let stickerPack = try await StickerManager.tryToDownloadStickerPack(stickerPackInfo: stickerPackInfo).awaitable()
        let title = stickerPack.title?.filterForDisplay.nilIfEmpty
        let coverUrl = try await StickerManager.tryToDownloadSticker(stickerInfo: stickerPack.coverInfo).awaitable()
        let coverData = try Data(contentsOf: coverUrl, options: [.mappedIfSafe])
        let previewThumbnail = await Self.previewThumbnail(srcImageData: coverData)

        guard title != nil || previewThumbnail != nil else {
            return nil
        }

        return OWSLinkPreviewDraft(
            url: url,
            title: title,
            imageData: previewThumbnail?.imageData,
            imageMimeType: previewThumbnail?.mimetype,
            isForwarded: false,
        )
    }

    // MARK: - Group Invite Links

    private func linkPreviewDraft(forGroupInviteLink url: URL) async throws -> OWSLinkPreviewDraft? {
        guard let groupInviteLinkInfo = GroupInviteLinkInfo.parseFrom(url) else {
            Logger.error("Could not parse URL.")
            throw LinkPreviewError.invalidPreview
        }
        let groupV2ContextInfo = try GroupV2ContextInfo.deriveFrom(masterKeyData: groupInviteLinkInfo.masterKey)
        let groupInviteLinkPreview = try await self.groupsV2.fetchGroupInviteLinkPreview(
            inviteLinkPassword: groupInviteLinkInfo.inviteLinkPassword,
            groupSecretParams: groupV2ContextInfo.groupSecretParams,
        )
        let previewThumbnail: PreviewThumbnail? = await {
            guard let avatarUrlPath = groupInviteLinkPreview.avatarUrlPath else {
                return nil
            }
            let avatarData: Data
            do {
                avatarData = try await self.groupsV2.fetchGroupInviteLinkAvatar(
                    avatarUrlPath: avatarUrlPath,
                    groupSecretParams: groupV2ContextInfo.groupSecretParams,
                )
            } catch {
                owsFailDebugUnlessNetworkFailure(error)
                return nil
            }
            return await Self.previewThumbnail(srcImageData: avatarData)
        }()

        let title = groupInviteLinkPreview.title.nilIfEmpty
        guard title != nil || previewThumbnail != nil else {
            return nil
        }

        return OWSLinkPreviewDraft(
            url: url,
            title: title,
            imageData: previewThumbnail?.imageData,
            imageMimeType: previewThumbnail?.mimetype,
            isForwarded: false,
        )
    }

    // MARK: - Call Links

    private func fetchName(forCallLink callLink: CallLink) async throws -> String? {
        let localIdentifiers = tsAccountManager.localIdentifiersWithMaybeSneakyTransaction!
        let authCredential = try await authCredentialManager.fetchCallLinkAuthCredential(localIdentifiers: localIdentifiers)
        let callLinkState = try await CallLinkFetcherImpl().readCallLink(callLink.rootKey, authCredential: authCredential)
        return callLinkState.name
    }
}

private extension HTMLMetadata {
    var dateForLinkPreview: Date? {
        [ogPublishDateString, articlePublishDateString, ogModifiedDateString, articleModifiedDateString]
            .first(where: { $0 != nil })?
            .flatMap {
                guard
                    let date = Date.ows_parseFromISO8601String($0),
                    date.timeIntervalSince1970 > 0
                else {
                    return nil
                }
                return date
            }
    }
}