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

public struct LinkPreviewDataSource {
    public let metadata: OWSLinkPreview.Metadata
    public let imageDataSource: AttachmentDataSource?
    public let isForwarded: Bool
}

public struct ValidatedLinkPreviewProto {
    public let preview: OWSLinkPreview
    public let imageProto: SSKProtoAttachmentPointer?
}

public struct ValidatedLinkPreviewDataSource {
    public let preview: OWSLinkPreview
    public let imageDataSource: AttachmentDataSource?
}

// MARK: -

public protocol LinkPreviewManager {
    func validateAndBuildLinkPreview(
        from proto: SSKProtoPreview,
        dataMessage: SSKProtoDataMessage,
    ) throws -> ValidatedLinkPreviewProto

    func validateAndBuildStoryLinkPreview(
        from proto: SSKProtoPreview,
    ) throws -> ValidatedLinkPreviewProto

    func buildDataSource(
        from draft: OWSLinkPreviewDraft,
    ) async throws -> LinkPreviewDataSource

    func validateDataSource(
        dataSource: LinkPreviewDataSource,
        tx: DBWriteTransaction,
    ) throws -> ValidatedLinkPreviewDataSource

    func buildProtoForSending(
        _ linkPreview: OWSLinkPreview,
        parentMessage: TSMessage,
        tx: DBReadTransaction,
    ) throws -> SSKProtoPreview

    func buildProtoForSending(
        _ linkPreview: OWSLinkPreview,
        parentStoryMessage: StoryMessage,
        tx: DBReadTransaction,
    ) throws -> SSKProtoPreview
}

// MARK: -

class LinkPreviewManagerImpl: LinkPreviewManager {
    private let attachmentStore: AttachmentStore
    private let attachmentValidator: AttachmentContentValidator
    private let db: any DB
    private let linkPreviewSettingStore: LinkPreviewSettingStore

    init(
        attachmentStore: AttachmentStore,
        attachmentValidator: AttachmentContentValidator,
        db: any DB,
        linkPreviewSettingStore: LinkPreviewSettingStore,
    ) {
        self.attachmentStore = attachmentStore
        self.attachmentValidator = attachmentValidator
        self.db = db
        self.linkPreviewSettingStore = linkPreviewSettingStore
    }

    // MARK: - Public

    func validateAndBuildLinkPreview(
        from proto: SSKProtoPreview,
        dataMessage: SSKProtoDataMessage,
    ) throws -> ValidatedLinkPreviewProto {
        if dataMessage.attachments.count == 1, dataMessage.attachments[0].contentType != MimeType.textXSignalPlain.rawValue {
            Logger.error("Discarding link preview; message has non-text attachment.")
            throw LinkPreviewError.invalidPreview
        }
        if dataMessage.attachments.count > 1 {
            Logger.error("Discarding link preview; message has attachments.")
            throw LinkPreviewError.invalidPreview
        }
        guard let messageBody = dataMessage.body, messageBody.contains(proto.url) else {
            Logger.error("Url not present in body")
            throw LinkPreviewError.invalidPreview
        }
        guard
            LinkValidator.canParseURLs(in: messageBody),
            LinkValidator.isValidLink(linkText: proto.url)
        else {
            Logger.error("Discarding link preview; can't parse URLs in message.")
            throw LinkPreviewError.invalidPreview
        }

        return try buildValidatedLinkPreview(proto: proto)
    }

    func validateAndBuildStoryLinkPreview(
        from proto: SSKProtoPreview,
    ) throws -> ValidatedLinkPreviewProto {
        guard LinkValidator.isValidLink(linkText: proto.url) else {
            Logger.error("Discarding link preview; can't parse URLs in story message.")
            throw LinkPreviewError.invalidPreview
        }
        return try buildValidatedLinkPreview(proto: proto)
    }

    func buildDataSource(
        from draft: OWSLinkPreviewDraft,
    ) async throws -> LinkPreviewDataSource {
        let areLinkPreviewsEnabled = db.read { linkPreviewSettingStore.areLinkPreviewsEnabled(tx: $0) }
        guard draft.isForwarded || areLinkPreviewsEnabled else {
            throw LinkPreviewError.featureDisabled
        }

        let metadata = OWSLinkPreview.Metadata(
            urlString: draft.urlString,
            title: draft.title,
            previewDescription: draft.previewDescription,
            date: draft.date,
        )

        if
            let imageData = draft.imageData,
            let imageMimeType = draft.imageMimeType
        {
            let pendingAttachment = try await attachmentValidator.validateDataContents(
                imageData,
                mimeType: imageMimeType,
                renderingFlag: .default,
                sourceFilename: nil,
            )

            return LinkPreviewDataSource(
                metadata: metadata,
                imageDataSource: .pendingAttachment(pendingAttachment),
                isForwarded: draft.isForwarded,
            )
        } else {
            return LinkPreviewDataSource(
                metadata: metadata,
                imageDataSource: nil,
                isForwarded: draft.isForwarded,
            )
        }
    }

    func validateDataSource(
        dataSource: LinkPreviewDataSource,
        tx: DBWriteTransaction,
    ) throws -> ValidatedLinkPreviewDataSource {
        guard dataSource.isForwarded || linkPreviewSettingStore.areLinkPreviewsEnabled(tx: tx) else {
            throw LinkPreviewError.featureDisabled
        }
        return ValidatedLinkPreviewDataSource(
            preview: OWSLinkPreview(metadata: dataSource.metadata),
            imageDataSource: dataSource.imageDataSource,
        )
    }

    func buildProtoForSending(
        _ linkPreview: OWSLinkPreview,
        parentMessage: TSMessage,
        tx: DBReadTransaction,
    ) throws -> SSKProtoPreview {
        let linkPreviewReferencedAttachment = parentMessage.sqliteRowId.flatMap { id in
            return attachmentStore.fetchAnyReferencedAttachment(
                for: .messageLinkPreview(messageRowId: id),
                tx: tx,
            )
        }

        return try buildProtoForSending(
            linkPreview: linkPreview,
            linkPreviewReferencedAttachment: linkPreviewReferencedAttachment,
            tx: tx,
        )
    }

    func buildProtoForSending(
        _ linkPreview: OWSLinkPreview,
        parentStoryMessage: StoryMessage,
        tx: DBReadTransaction,
    ) throws -> SSKProtoPreview {
        let linkPreviewReferencedAttachment = parentStoryMessage.id.flatMap { id in
            return attachmentStore.fetchAnyReferencedAttachment(
                for: .storyMessageLinkPreview(storyMessageRowId: id),
                tx: tx,
            )
        }

        return try buildProtoForSending(
            linkPreview: linkPreview,
            linkPreviewReferencedAttachment: linkPreviewReferencedAttachment,
            tx: tx,
        )
    }

    private func buildValidatedLinkPreview(
        proto: SSKProtoPreview,
    ) throws -> ValidatedLinkPreviewProto {
        let urlString = proto.url

        guard let url = URL(string: urlString), LinkPreviewHelper.isPermittedLinkPreviewUrl(url) else {
            Logger.error("Could not parse preview url.")
            throw LinkPreviewError.invalidPreview
        }

        var title: String?
        var previewDescription: String?
        if let rawTitle = proto.title {
            let normalizedTitle = LinkPreviewHelper.normalizeString(rawTitle, maxLines: 2)
            if !normalizedTitle.isEmpty {
                title = normalizedTitle
            }
        }
        if let rawDescription = proto.previewDescription, proto.title != proto.previewDescription {
            let normalizedDescription = LinkPreviewHelper.normalizeString(rawDescription, maxLines: 3)
            if !normalizedDescription.isEmpty {
                previewDescription = normalizedDescription
            }
        }

        // Zero check required. Some devices in the wild will explicitly set zero to mean "no date"
        let date: Date?
        if proto.hasDate, proto.date > 0 {
            date = Date(millisecondsSince1970: proto.date)
        } else {
            date = nil
        }

        return ValidatedLinkPreviewProto(
            preview: OWSLinkPreview(metadata: OWSLinkPreview.Metadata(
                urlString: urlString,
                title: title,
                previewDescription: previewDescription,
                date: date,
            )),
            imageProto: proto.image,
        )
    }

    // MARK: - Private, generating outgoing proto

    private func buildProtoForSending(
        linkPreview: OWSLinkPreview,
        linkPreviewReferencedAttachment: ReferencedAttachment?,
        tx: DBReadTransaction,
    ) throws -> SSKProtoPreview {
        guard let urlString = linkPreview.urlString else {
            Logger.error("Preview does not have url.")
            throw LinkPreviewError.invalidPreview
        }

        let builder = SSKProtoPreview.builder(url: urlString)

        if let title = linkPreview.title {
            builder.setTitle(title)
        }

        if let previewDescription = linkPreview.previewDescription {
            builder.setPreviewDescription(previewDescription)
        }

        if
            let linkPreviewReferencedAttachment,
            let attachmentProto = linkPreviewReferencedAttachment.asProtoForSending()
        {
            builder.setImage(attachmentProto)
        }

        if let date = linkPreview.date, date.timeIntervalSince1970 > 0 {
            builder.setDate(date.ows_millisecondsSince1970)
        }

        return try builder.build()
    }
}