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

public class BackupArchiveDistributionListRecipientArchiver: BackupArchiveProtoStreamWriter {
    typealias RecipientId = BackupArchive.RecipientId
    typealias RecipientAppId = BackupArchive.RecipientArchivingContext.Address

    typealias ArchiveMultiFrameResult = BackupArchive.ArchiveMultiFrameResult<RecipientAppId>
    private typealias ArchiveFrameError = BackupArchive.ArchiveFrameError<RecipientAppId>

    typealias RestoreFrameResult = BackupArchive.RestoreFrameResult<RecipientId>
    private typealias RestoreFrameError = BackupArchive.RestoreFrameError<RecipientId>

    private let privateStoryThreadDeletionManager: any PrivateStoryThreadDeletionManager
    private let storyStore: BackupArchiveStoryStore
    private let threadStore: BackupArchiveThreadStore

    init(
        privateStoryThreadDeletionManager: any PrivateStoryThreadDeletionManager,
        storyStore: BackupArchiveStoryStore,
        threadStore: BackupArchiveThreadStore,
    ) {
        self.privateStoryThreadDeletionManager = privateStoryThreadDeletionManager
        self.storyStore = storyStore
        self.threadStore = threadStore
    }

    func archiveAllDistributionListRecipients(
        stream: BackupArchiveProtoOutputStream,
        context: BackupArchive.RecipientArchivingContext,
    ) throws(CancellationError) -> ArchiveMultiFrameResult {
        var errors = [ArchiveFrameError]()

        do {
            // enumerate deleted threads
            for item in privateStoryThreadDeletionManager.allDeletedIdentifiers(tx: context.tx) {
                try Task.checkCancellation()
                autoreleasepool {
                    context.bencher.processFrame { frameBencher in
                        self.archiveDeletedStoryList(
                            rawDistributionId: item,
                            stream: stream,
                            frameBencher: frameBencher,
                            context: context,
                            errors: &errors,
                        )
                    }
                }
            }
            try context.bencher.wrapEnumeration(
                threadStore.enumerateStoryThreads(tx:block:),
                tx: context.tx,
            ) { storyThread, frameBencher in
                try Task.checkCancellation()
                autoreleasepool {
                    self.archiveStoryThread(
                        storyThread,
                        stream: stream,
                        frameBencher: frameBencher,
                        context: context,
                        errors: &errors,
                    )
                }

                return true
            }
        } catch let error as CancellationError {
            throw error
        } catch {
            // The enumeration of threads failed, not the processing of one single thread.
            return .completeFailure(.fatalArchiveError(.threadIteratorError(error)))
        }

        if errors.isEmpty {
            return .success
        } else {
            return .partialSuccess(errors)
        }
    }

    private func archiveStoryThread(
        _ storyThread: TSPrivateStoryThread,
        stream: BackupArchiveProtoOutputStream,
        frameBencher: BackupArchive.Bencher.FrameBencher,
        context: BackupArchive.RecipientArchivingContext,
        errors: inout [ArchiveFrameError],
    ) {
        guard let distributionId = BackupArchive.DistributionId(storyThread: storyThread) else {
            // This optionality is a result of the UUID initializer being failable.
            // This shouldn't be encountered in practice since the uniqueId is always generated from
            // a UUID (or 'My Story' identifier).  But if this is encountered, report an error and skip
            // this d-list with a generic 'missing identifier' message.
            errors.append(.archiveFrameError(
                .distributionListMissingDistributionId,
                // Spoof a random id since we don't have one but error mechanisms require it.
                .distributionList(.init(UUID())),
            ))
            return
        }

        let distributionListAppId: RecipientAppId = .distributionList(distributionId)

        let recipientDbRowIds: [SignalRecipient.RowId]
        do {
            recipientDbRowIds = try storyStore.fetchRecipientIds(for: storyThread, context: context)
        } catch {
            errors.append(.archiveFrameError(.unableToFetchDistributionListRecipients, distributionListAppId))
            return
        }

        let memberRecipientIds: [UInt64] = recipientDbRowIds.compactMap { recipientDbRowId -> UInt64? in
            guard let recipientId = context.recipientId(forRecipientDbRowId: recipientDbRowId) else {
                errors.append(.archiveFrameError(.referencedRecipientIdMissing(.distributionList(distributionId)), distributionListAppId))
                return nil
            }
            // Filter out 'Self' in distribution lists
            guard recipientId != context.localRecipientId else {
                return nil
            }
            return recipientId.value
        }

        // Ensure that explicit/blocklist have valid member recipient addresses
        let privacyMode: BackupProto_DistributionList.PrivacyMode
        switch storyThread.storyViewMode {
        case .disabled:
            // Skip deleted distribution lists
            return
        case .default:
            errors.append(.archiveFrameError(.distributionListHasDefaultViewMode, distributionListAppId))
            return
        case .explicit:
            // My story and private stories are both allowed to be explicit lists.
            // These lists can be empty.
            privacyMode = .onlyWith
        case .blockList:
            // ONLY My Story is allowed to be a blocklist.
            guard distributionId.isMyStoryId else {
                errors.append(.archiveFrameError(.customDistributionListBlocklistViewMode, distributionListAppId))
                return
            }
            // A blocklist with empty members is used to represent "all signal connections".
            // A blocklist with 1+ members is a blocklist like you'd expect.
            if memberRecipientIds.isEmpty {
                privacyMode = .all
            } else {
                privacyMode = .allExcept
            }
        }

        var distributionList = BackupProto_DistributionList()
        // Empty name specifically expected for My Story, and `storyThread.name`
        // will return the localized "My Story" string.
        distributionList.name = storyThread.isMyStory ? "" : storyThread.name
        distributionList.allowReplies = storyThread.allowsReplies
        distributionList.privacyMode = privacyMode
        distributionList.memberRecipientIds = memberRecipientIds

        var distributionListItem = BackupProto_DistributionListItem()
        distributionListItem.distributionID = distributionId.value.data
        distributionListItem.item = .distributionList(distributionList)

        let recipientId = context.assignRecipientId(to: distributionListAppId)

        Self.writeFrameToStream(
            stream,
            objectId: distributionListAppId,
            frameBencher: frameBencher,
        ) {
            var recipient = BackupProto_Recipient()
            recipient.id = recipientId.value
            recipient.destination = .distributionList(distributionListItem)

            var frame = BackupProto_Frame()
            frame.item = .recipient(recipient)
            return frame
        }.map { errors.append($0) }
    }

    private func archiveDeletedStoryList(
        rawDistributionId: Data,
        stream: BackupArchiveProtoOutputStream,
        frameBencher: BackupArchive.Bencher.FrameBencher,
        context: BackupArchive.RecipientArchivingContext,
        errors: inout [ArchiveFrameError],
    ) {
        guard let distributionUUID = UUID(data: rawDistributionId) else {
            // This optionality is a result of the UUID initializer being failable.
            // This shouldn't be encountered in practice since the uniqueId is always generated from
            // a UUID (or 'My Story' identifier).  But if this is encountered, report an error and skip
            // this d-list with a generic 'missing identifier' message.
            errors.append(.archiveFrameError(
                .distributionListMissingDistributionId,
                // Spoof a random id since we don't have one but error mechanisms require it.
                .distributionList(.init(UUID())),
            ))
            return
        }
        let distributionId = BackupArchive.DistributionId(distributionUUID)
        let distributionListAppId: RecipientAppId = .distributionList(distributionId)

        guard
            let deletionTimestamp = privateStoryThreadDeletionManager.deletedAtTimestamp(
                forDistributionListIdentifier: rawDistributionId,
                tx: context.tx,
            )
        else {
            errors.append(.archiveFrameError(.distributionListMissingDeletionTimestamp, distributionListAppId))
            return
        }

        guard BackupArchive.Timestamps.isValid(deletionTimestamp) else {
            errors.append(.archiveFrameError(.distributionListInvalidTimestamp, distributionListAppId))
            return
        }

        let recipientId = context.assignRecipientId(to: distributionListAppId)

        var distributionList = BackupProto_DistributionListItem()
        distributionList.distributionID = distributionId.value.data
        distributionList.item = .deletionTimestamp(deletionTimestamp)

        Self.writeFrameToStream(
            stream,
            objectId: distributionListAppId,
            frameBencher: frameBencher,
        ) {
            var recipient = BackupProto_Recipient()
            recipient.id = recipientId.value
            recipient.destination = .distributionList(distributionList)

            var frame = BackupProto_Frame()
            frame.item = .recipient(recipient)
            return frame
        }.map { errors.append($0) }
    }

    func restoreDistributionListRecipientProto(
        _ distributionListItemProto: BackupProto_DistributionListItem,
        recipient: BackupProto_Recipient,
        context: BackupArchive.RecipientRestoringContext,
    ) -> RestoreFrameResult {
        func restoreFrameError(
            _ error: RestoreFrameError.ErrorType,
            line: UInt = #line,
        ) -> RestoreFrameResult {
            return .failure([.restoreFrameError(error, recipient.recipientId, line: line)])
        }

        guard
            let distributionId =
            BackupArchive.DistributionId(distributionListItem: distributionListItemProto)
        else {
            return restoreFrameError(.invalidProtoData(.invalidDistributionListId))
        }

        let result: RestoreFrameResult
        switch distributionListItemProto.item {
        case .deletionTimestamp(let deletionTimestamp):
            // Restore deleted distribution lists identifiers. These are needed
            // to preserve the deleted distribution list entry in storage service
            // during a sync.  If these weren't backed up, there's a chance the deleted
            // list would be removed from storage service before the deletion would
            // be picked up by a linked device. Deleted lists that are too old will
            // be filtered by the `setDeletedAtTimestamp` method, so filtering isn't
            // necessary here.
            if deletionTimestamp > 0 {
                privateStoryThreadDeletionManager.recordDeletedAtTimestamp(
                    deletionTimestamp,
                    forDistributionListIdentifier: distributionId.value.data,
                    tx: context.tx,
                )
                result = .success
            } else {
                result = restoreFrameError(.invalidProtoData(.invalidDistributionListDeletionTimestamp))
            }
        case .distributionList(let distributionListItemProto):
            result = buildDistributionList(
                from: distributionListItemProto,
                distributionId: distributionId,
                recipientId: recipient.recipientId,
                context: context,
            )
        case nil:
            result = .unrecognizedEnum(BackupArchive.UnrecognizedEnumError(
                enumType: BackupProto_DistributionListItem.OneOf_Item.self,
            ))
        }

        context[recipient.recipientId] = .distributionList(distributionId)
        return result
    }

    private func buildDistributionList(
        from distributionListProto: BackupProto_DistributionList,
        distributionId: BackupArchive.DistributionId,
        recipientId: BackupArchive.RecipientId,
        context: BackupArchive.RecipientRestoringContext,
    ) -> RestoreFrameResult {
        var partialErrors = [BackupArchive.RestoreFrameError<RecipientId>]()

        let viewMode: TSThreadStoryViewMode
        let recipientDbRowIds: [SignalRecipient.RowId]

        switch distributionListProto.privacyMode {
        case .all:
            // Only My Story is allowed to use the `all` mode.
            // The way we represent this locally is a `blocklist` with 0 members.
            guard distributionId.isMyStoryId else {
                return .failure([.restoreFrameError(
                    .invalidProtoData(.customDistributionListPrivacyModeAllOrAllExcept),
                    recipientId,
                )])
            }
            viewMode = .blockList
            // Ignore any addresses in the proto, set to empty.
            recipientDbRowIds = []
        case .allExcept:
            // Only My Story is allowed to use the `allExcept` mode.
            // The way we represent this locally is a `blocklist`.
            guard distributionId.isMyStoryId else {
                return .failure([.restoreFrameError(
                    .invalidProtoData(.customDistributionListPrivacyModeAllOrAllExcept),
                    recipientId,
                )])
            }
            viewMode = .blockList
            // Note: if the addresses are empty (because the proto was empty or parsing failed).
            // this will end up behaving the same as `all`: an empty `blocklist`.
            recipientDbRowIds = readRecipientDbRowIds(
                from: distributionListProto,
                recipientId: recipientId,
                context: context,
                partialErrors: &partialErrors,
            )
        case .onlyWith:
            // All stories are allowed to use `onlyWith` aka `explicit`.
            viewMode = .explicit
            recipientDbRowIds = readRecipientDbRowIds(
                from: distributionListProto,
                recipientId: recipientId,
                context: context,
                partialErrors: &partialErrors,
            )
        case .unknown, .UNRECOGNIZED:
            // Fallback to an empty explicit list.
            viewMode = .explicit
            recipientDbRowIds = []
        }

        let storyThread: TSPrivateStoryThread

        // MyStory is created during warmCaches(), so it should be present at this point.
        // But to guard against any future changes, call getOrCreateMyStory() to ensure
        // it is present before updating with the incoming data.
        if distributionId.isMyStoryId {
            do {
                storyThread = try storyStore.createMyStory(
                    name: distributionListProto.name,
                    allowReplies: distributionListProto.allowReplies,
                    viewMode: viewMode,
                    context: context,
                )
            } catch let error {
                return .failure([.restoreFrameError(.databaseInsertionFailed(error), recipientId)])
            }
        } else {
            storyThread = TSPrivateStoryThread(
                uniqueId: distributionId.value.uuidString,
                name: distributionListProto.name,
                allowsReplies: distributionListProto.allowReplies,
                viewMode: viewMode,
            )
            do {
                try storyStore.insert(storyThread, context: context)
            } catch let error {
                return .failure([.restoreFrameError(.databaseInsertionFailed(error), recipientId)])
            }
        }

        for recipientDbRowId in recipientDbRowIds {
            do {
                try storyStore.insertRecipientId(recipientDbRowId, forStoryThreadId: storyThread.sqliteRowId!, context: context)
            } catch let error {
                return .failure([.restoreFrameError(.databaseInsertionFailed(error), recipientId)])
            }
        }

        return .success
    }

    private func readRecipientDbRowIds(
        from distributionListProto: BackupProto_DistributionList,
        recipientId: BackupArchive.RecipientId,
        context: BackupArchive.RecipientRestoringContext,
        partialErrors: inout [BackupArchive.RestoreFrameError<RecipientId>],
    ) -> [SignalRecipient.RowId] {
        distributionListProto
            .memberRecipientIds
            .compactMap {
                if let result = context.recipientDbRowId(forBackupRecipientId: RecipientId(value: $0)) {
                    return result
                } else {
                    partialErrors.append(.restoreFrameError(
                        .invalidProtoData(.invalidDistributionListMember(protoClass: BackupProto_DistributionList.self)),
                        recipientId,
                    ))
                    return nil
                }
            }
    }
}