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

import Foundation
public import LibSignalClient

public extension BackupArchive {
    enum Request {
        public struct SourceAttachment {
            let cdn: UInt32
            let key: String
        }

        public struct MediaItem {
            let sourceAttachment: SourceAttachment
            let objectLength: UInt32
            let mediaId: Data
            let hmacKey: Data
            let aesKey: Data

            var asParameters: [String: Any] {
                [
                    "sourceAttachment": [
                        "cdn": self.sourceAttachment.cdn,
                        "key": self.sourceAttachment.key,
                    ],
                    "objectLength": self.objectLength,
                    "mediaId": self.mediaId.asBase64Url,
                    "hmacKey": self.hmacKey.base64EncodedString(),
                    "encryptionKey": self.aesKey.base64EncodedString(),
                ]
            }
        }

        public struct DeleteMediaTarget: Codable {
            let cdn: UInt32
            let mediaId: Data

            var asParameters: [String: Any] {
                [
                    "cdn": self.cdn,
                    "mediaId": self.mediaId.asBase64Url,
                ]
            }
        }
    }

    enum Response {
        public struct BatchedBackupMediaResult: Codable {
            let status: UInt32?
            let failureReason: String?
            let cdn: UInt32?
            let mediaId: String
        }

        public struct ListMediaResult: Codable {
            let storedMediaObjects: [StoredMedia]
            let backupDir: String
            let mediaDir: String
            let cursor: String?
        }

        public struct StoredMedia: Codable {
            let cdn: UInt32
            let mediaId: String
            let objectLength: UInt64
        }

        public enum CopyToMediaTierError: Int, Error {
            case badArgument = 400
            case invalidAuth = 401
            case forbidden = 403
            case sourceObjectNotFound = 410
            case outOfCapacity = 413
            case rateLimited = 429
        }

        public enum BackupUploadFormError: Int, Error {
            case badArgument = 400
            case invalidAuth = 401
            case forbidden = 403
            /// The backup file is too large (as reported by us in `backupByteLength`.
            case tooLarge = 413
            case rateLimited = 429
        }
    }
}

public protocol BackupRequestManager {

    /// Passthrough API for ``BackupAuthCredentialManager/fetchBackupServiceAuthForRegistration``.
    func fetchBackupServiceAuthForRegistration(
        key: BackupKeyMaterial,
        localAci: Aci,
        chatServiceAuth: ChatServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> BackupServiceAuth

    /// Passthrough API for ``BackupAuthCredentialManager/fetchBackupServiceAuth``.
    func fetchBackupServiceAuth(
        for key: BackupKeyMaterial,
        localAci: Aci,
        auth: ChatServiceAuth,
        forceRefreshUnlessCachedPaidCredential: Bool,
        logger: PrefixedLogger,
    ) async throws -> BackupServiceAuth

    /// - parameter backupByteLength: length in bytes of the encrypted backup file we will upload
    func fetchBackupUploadForm(
        backupByteLength: UInt32,
        auth: BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> Upload.Form

    func fetchBackupMediaAttachmentUploadForm(
        encryptedByteLength: UInt32,
        auth: BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> Upload.Form

    func fetchMediaTierCdnRequestMetadata(
        cdn: Int32,
        auth: BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> MediaTierReadCredential

    func fetchBackupRequestMetadata(
        auth: BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> BackupReadCredential

    func copyToMediaTier(
        item: BackupArchive.Request.MediaItem,
        auth: BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> UInt32

    func copyToMediaTier(
        items: [BackupArchive.Request.MediaItem],
        auth: BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> [BackupArchive.Response.BatchedBackupMediaResult]

    func listMediaObjects(
        cursor: String?,
        limit: UInt32?,
        auth: BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> BackupArchive.Response.ListMediaResult

    func deleteMediaObjects(
        objects: [BackupArchive.Request.DeleteMediaTarget],
        auth: BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws

    func fetchSVRBAuthCredential(
        key: MessageRootBackupKey,
        chatServiceAuth auth: ChatServiceAuth,
        forceRefresh: Bool,
        logger: PrefixedLogger,
    ) async throws -> LibSignalClient.Auth
}

extension BackupRequestManager {
    public func fetchBackupServiceAuth(
        for key: BackupKeyMaterial,
        localAci: Aci,
        auth: ChatServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> BackupServiceAuth {
        return try await self.fetchBackupServiceAuth(
            for: key,
            localAci: localAci,
            auth: auth,
            forceRefreshUnlessCachedPaidCredential: false,
            logger: logger,
        )
    }
}

public struct BackupRequestManagerImpl: BackupRequestManager {

    private let backupAuthCredentialManager: BackupAuthCredentialManager
    private let backupCDNCredentialStore: BackupCDNCredentialStore
    private let backupSettingsStore: BackupSettingsStore
    private let chatConnectionManager: ChatConnectionManager
    private let dateProvider: DateProvider
    private let db: any DB
    private let kvStore: KeyValueStore
    private let networkManager: NetworkManager

    init(
        backupAuthCredentialManager: BackupAuthCredentialManager,
        backupCDNCredentialStore: BackupCDNCredentialStore,
        backupSettingsStore: BackupSettingsStore,
        chatConnectionManager: ChatConnectionManager,
        dateProvider: @escaping DateProvider,
        db: any DB,
        networkManager: NetworkManager,
    ) {
        self.backupAuthCredentialManager = backupAuthCredentialManager
        self.backupCDNCredentialStore = backupCDNCredentialStore
        self.backupSettingsStore = backupSettingsStore
        self.chatConnectionManager = chatConnectionManager
        self.dateProvider = dateProvider
        self.db = db
        self.kvStore = KeyValueStore(collection: "BackupRequestManager")
        self.networkManager = networkManager
    }

    // MARK: - Backup Auth

    public func fetchBackupServiceAuthForRegistration(
        key: BackupKeyMaterial,
        localAci: Aci,
        chatServiceAuth: ChatServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> BackupServiceAuth {
        return try await backupAuthCredentialManager.fetchBackupServiceAuthForRegistration(
            key: key,
            localAci: localAci,
            chatServiceAuth: chatServiceAuth,
            logger: logger,
        )
    }

    public func fetchBackupServiceAuth(
        for key: BackupKeyMaterial,
        localAci: Aci,
        auth: ChatServiceAuth,
        forceRefreshUnlessCachedPaidCredential: Bool,
        logger: PrefixedLogger,
    ) async throws -> BackupServiceAuth {
        return try await backupAuthCredentialManager.fetchBackupServiceAuth(
            key: key,
            localAci: localAci,
            chatServiceAuth: auth,
            forceRefreshUnlessCachedPaidCredential: forceRefreshUnlessCachedPaidCredential,
            logger: logger,
        )
    }

    // MARK: - Upload Forms

    /// CDN upload form for uploading a backup
    public func fetchBackupUploadForm(
        backupByteLength: UInt32,
        auth: BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> Upload.Form {
        owsAssertDebug(auth.type == .messages)
        do {
            return try await chatConnectionManager.withUnauthService(.backups) {
                try await $0.getUploadForm(
                    auth: auth.backupAuth,
                    uploadSize: UInt64(backupByteLength),
                )
            }.asUploadForm()
        } catch let error {
            if
                let httpStatusCode = error.httpStatusCode,
                let error = BackupArchive.Response.BackupUploadFormError(rawValue: httpStatusCode)
            {
                throw error
            } else {
                throw error
            }
        }
    }

    /// CDN upload form for uploading backup media
    public func fetchBackupMediaAttachmentUploadForm(
        encryptedByteLength: UInt32,
        auth: BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> Upload.Form {
        owsAssertDebug(auth.type == .media)
        return try await chatConnectionManager.withUnauthService(.backups) {
            try await $0.getMediaUploadForm(
                auth: auth.backupAuth,
                uploadSize: UInt64(safeCast: encryptedByteLength),
            )
        }.asUploadForm()
    }

    // MARK: - Backup Info

    private func fetchBackupCDNMetadata(
        auth: BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> BackupCDNMetadata {
        if
            let cachedCDNMetadata = db.read(block: { tx in
                backupCDNCredentialStore.backupCDNMetadata(
                    authType: auth.type,
                    now: dateProvider(),
                    tx: tx,
                )
            })
        {
            return cachedCDNMetadata
        }

        let cdnMetadata: BackupCDNMetadata = try await executeBackupService(
            auth: auth,
            requestFactory: {
                OWSRequestFactory.backupInfoRequest(auth: $0, logger: logger)
            },
        )

        await db.awaitableWrite { tx in
            backupCDNCredentialStore.setBackupCDNMetadata(
                cdnMetadata,
                authType: auth.type,
                now: dateProvider(),
                currentBackupPlan: backupSettingsStore.backupPlan(tx: tx),
                tx: tx,
            )
        }

        return cdnMetadata
    }

    // TODO: [Backups] Call this regularly, or move it somewhere it is called regularly
    /// Backup keep-alive request.  If not called, the backup may be deleted after 30 days.
    private func refreshBackupInfo(
        auth: BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws {
        _ = try await executeBackupServiceRequest(
            auth: auth,
            requestFactory: {
                OWSRequestFactory.backupRefreshInfoRequest(auth: $0, logger: logger)
            },
        )
    }

    // MARK: - Media

    /// Retrieve credentials used for reading from the CDN
    private func fetchCDNReadCredentials(
        cdn: Int32,
        auth: BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> BackupCDNReadCredential {
        if
            let cachedCDNReadCredential = db.read(block: { tx in
                backupCDNCredentialStore.backupCDNReadCredential(
                    cdnNumber: cdn,
                    authType: auth.type,
                    now: dateProvider(),
                    tx: tx,
                )
            })
        {
            return cachedCDNReadCredential
        }

        let cdnReadCredential: BackupCDNReadCredential = try await executeBackupService(
            auth: auth,
            requestFactory: { OWSRequestFactory.fetchBackupCDNCredentials(auth: $0, cdn: cdn, logger: logger) },
        )

        await db.awaitableWrite { tx in
            backupCDNCredentialStore.setBackupCDNReadCredential(
                cdnReadCredential,
                cdnNumber: cdn,
                authType: auth.type,
                currentBackupPlan: backupSettingsStore.backupPlan(tx: tx),
                tx: tx,
            )
        }

        return cdnReadCredential
    }

    public func fetchBackupRequestMetadata(
        auth: BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> BackupReadCredential {
        let metadata = try await fetchBackupCDNMetadata(auth: auth, logger: logger)
        let authCredential = try await fetchCDNReadCredentials(cdn: metadata.cdn, auth: auth, logger: logger)
        return BackupReadCredential(credential: authCredential, metadata: metadata)
    }

    public func fetchMediaTierCdnRequestMetadata(
        cdn: Int32,
        auth: BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> MediaTierReadCredential {
        owsAssertDebug(auth.type == .media)
        let metadata = try await fetchBackupCDNMetadata(auth: auth, logger: logger)
        let authCredential = try await fetchCDNReadCredentials(cdn: cdn, auth: auth, logger: logger)
        return MediaTierReadCredential(cdn: cdn, credential: authCredential, metadata: metadata)
    }

    public func copyToMediaTier(
        item: BackupArchive.Request.MediaItem,
        auth: BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> UInt32 {
        owsAssertDebug(auth.type == .media)
        do {
            let response = try await executeBackupServiceRequest(
                auth: auth,
                requestFactory: {
                    OWSRequestFactory.copyToMediaTier(
                        auth: $0,
                        item: item,
                        logger: logger,
                    )
                },
            )
            if let error = BackupArchive.Response.CopyToMediaTierError(rawValue: response.responseStatusCode) {
                throw error
            }
            guard let bodyData = response.responseBodyData else {
                throw OWSAssertionError("Missing body data")
            }
            let dict = try JSONDecoder().decode([String: UInt32].self, from: bodyData)
            guard let cdn = dict["cdn"] else {
                throw OWSAssertionError("Missing cdn")
            }
            return cdn
        } catch let error {
            if
                let responseStatusCode = error.httpStatusCode,
                let typedError = BackupArchive.Response.CopyToMediaTierError(rawValue: responseStatusCode)
            {
                throw typedError
            } else {
                throw error
            }
        }
    }

    public func copyToMediaTier(
        items: [BackupArchive.Request.MediaItem],
        auth: BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> [BackupArchive.Response.BatchedBackupMediaResult] {
        owsAssertDebug(auth.type == .media)
        return try await executeBackupService(
            auth: auth,
            requestFactory: {
                OWSRequestFactory.archiveMedia(
                    auth: $0,
                    items: items,
                    logger: logger,
                )
            },
        )
    }

    public func listMediaObjects(
        cursor: String?,
        limit: UInt32?,
        auth: BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> BackupArchive.Response.ListMediaResult {
        owsAssertDebug(auth.type == .media)
        return try await executeBackupService(
            auth: auth,
            requestFactory: {
                OWSRequestFactory.listMedia(
                    auth: $0,
                    cursor: cursor,
                    limit: limit,
                    logger: logger,
                )
            },
        )
    }

    public func deleteMediaObjects(
        objects: [BackupArchive.Request.DeleteMediaTarget],
        auth: BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws {
        owsAssertDebug(auth.type == .media)
        _ = try await executeBackupServiceRequest(
            auth: auth,
            requestFactory: {
                OWSRequestFactory.deleteMedia(
                    auth: $0,
                    objects: objects,
                    logger: logger,
                )
            },
        )
    }

    public func fetchSVRBAuthCredential(
        key: MessageRootBackupKey,
        chatServiceAuth auth: ChatServiceAuth,
        forceRefresh: Bool,
        logger: PrefixedLogger,
    ) async throws -> LibSignalClient.Auth {
        return try await backupAuthCredentialManager.fetchSVRBAuthCredential(
            key: key,
            chatServiceAuth: auth,
            forceRefresh: forceRefresh,
            logger: logger,
        )
    }

    // MARK: - Private utility methods

    private func executeBackupServiceRequest(
        auth: BackupServiceAuth,
        requestFactory: (BackupServiceAuth) -> TSRequest,
    ) async throws -> HTTPResponse {
        return try await networkManager.asyncRequest(requestFactory(auth))
    }

    private func executeBackupService<T: Decodable>(
        auth: BackupServiceAuth,
        requestFactory: (BackupServiceAuth) -> TSRequest,
    ) async throws -> T {
        let response = try await executeBackupServiceRequest(
            auth: auth,
            requestFactory: requestFactory,
        )
        guard let bodyData = response.responseBodyData else {
            throw OWSAssertionError("Missing body data")
        }
        return try JSONDecoder().decode(T.self, from: bodyData)
    }
}

public struct MediaTierReadCredential {

    public let cdn: Int32
    private let credential: BackupCDNReadCredential
    private let metadata: BackupCDNMetadata

    fileprivate init(
        cdn: Int32,
        credential: BackupCDNReadCredential,
        metadata: BackupCDNMetadata,
    ) {
        self.cdn = cdn
        self.credential = credential
        self.metadata = metadata
    }

    var isExpired: Bool {
        return credential.isExpired(now: Date())
    }

    var cdnAuthHeaders: HttpHeaders {
        return credential.headers
    }

    func mediaTierUrlPrefix() -> String {
        return "backups/\(metadata.backupDir)/\(metadata.mediaDir)"
    }
}

public struct BackupReadCredential {

    private let credential: BackupCDNReadCredential
    private let metadata: BackupCDNMetadata

    fileprivate init(
        credential: BackupCDNReadCredential,
        metadata: BackupCDNMetadata,
    ) {
        self.credential = credential
        self.metadata = metadata
    }

    var isExpired: Bool {
        return credential.isExpired(now: Date())
    }

    var cdn: Int32 {
        return metadata.cdn
    }

    var cdnAuthHeaders: HttpHeaders {
        return credential.headers
    }

    func backupLocationUrl() -> String {
        return "backups/\(metadata.backupDir)/\(metadata.backupName)"
    }
}

// MARK: -

#if TESTABLE_BUILD

public class BackupRequestManagerMock: BackupRequestManager {

    init() {}

    public func fetchBackupServiceAuthForRegistration(
        key: BackupKeyMaterial,
        localAci: Aci,
        chatServiceAuth: ChatServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> BackupServiceAuth {
        return BackupServiceAuth.mock(type: .media, backupLevel: .paid)
    }

    public func fetchBackupServiceAuth(
        for key: SignalServiceKit.BackupKeyMaterial,
        localAci: LibSignalClient.Aci,
        auth: SignalServiceKit.ChatServiceAuth,
        forceRefreshUnlessCachedPaidCredential: Bool,
        logger: PrefixedLogger,
    ) async throws -> SignalServiceKit.BackupServiceAuth {
        return BackupServiceAuth.mock(type: .media, backupLevel: .paid)
    }

    public func fetchBackupUploadForm(
        backupByteLength: UInt32,
        auth: SignalServiceKit.BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> SignalServiceKit.Upload.Form {
        fatalError("Unimplemented")
    }

    public func fetchBackupMediaAttachmentUploadForm(
        encryptedByteLength: UInt32,
        auth: SignalServiceKit.BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> SignalServiceKit.Upload.Form {
        fatalError("Unimplemented")
    }

    public func fetchMediaTierCdnRequestMetadata(
        cdn: Int32,
        auth: SignalServiceKit.BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> SignalServiceKit.MediaTierReadCredential {
        fatalError("Unimplemented")
    }

    public func fetchBackupRequestMetadata(
        auth: SignalServiceKit.BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> SignalServiceKit.BackupReadCredential {
        fatalError("Unimplemented")
    }

    public func copyToMediaTier(
        item: SignalServiceKit.BackupArchive.Request.MediaItem,
        auth: SignalServiceKit.BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> UInt32 {
        fatalError("Unimplemented")
    }

    public func copyToMediaTier(
        items: [SignalServiceKit.BackupArchive.Request.MediaItem],
        auth: SignalServiceKit.BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> [SignalServiceKit.BackupArchive.Response.BatchedBackupMediaResult] {
        fatalError("Unimplemented")
    }

    var listMediaResults = [BackupArchive.Response.ListMediaResult]()

    public func listMediaObjects(
        cursor: String?,
        limit: UInt32?,
        auth: SignalServiceKit.BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws -> SignalServiceKit.BackupArchive.Response.ListMediaResult {
        return listMediaResults.popFirst()!
    }

    public func deleteMediaObjects(
        objects: [SignalServiceKit.BackupArchive.Request.DeleteMediaTarget],
        auth: SignalServiceKit.BackupServiceAuth,
        logger: PrefixedLogger,
    ) async throws {
        fatalError("Unimplemented")
    }

    func redeemReceipt(receiptCredentialPresentation: Data) async throws {
        fatalError("Unimplemented")
    }

    public func fetchSVRBAuthCredential(
        key: SignalServiceKit.MessageRootBackupKey,
        chatServiceAuth auth: SignalServiceKit.ChatServiceAuth,
        forceRefresh: Bool,
        logger: PrefixedLogger,
    ) async throws -> LibSignalClient.Auth {
        return LibSignalClient.Auth(username: "", password: "")
    }
}

#endif