518 lines
21 KiB
Swift
518 lines
21 KiB
Swift
//
|
|
// Copyright 2023 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Foundation
|
|
import LibSignalClient
|
|
|
|
/**
|
|
* Archives a group (``TSGroupThread``) as a ``BackupProto.Group``, which is a type of
|
|
* ``BackupProto.Recipient``.
|
|
*
|
|
* This is a bit confusing, because ``TSThread`` mostly corresponds to ``BackupProto.Chat``,
|
|
* and there will in fact _also_ be a ``BackupProto.Chat`` for the group thread. Its just that our
|
|
* ``TSGroupThread`` contains all the metadata from both the Chat and Recipient representations
|
|
* in the proto.
|
|
*/
|
|
public class MessageBackupGroupRecipientArchiver: MessageBackupRecipientDestinationArchiver {
|
|
|
|
private let disappearingMessageConfigStore: DisappearingMessagesConfigurationStore
|
|
private let groupsV2: GroupsV2
|
|
private let profileManager: MessageBackup.Shims.ProfileManager
|
|
private let storyStore: StoryStore
|
|
private let threadStore: ThreadStore
|
|
|
|
private var logger: MessageBackupLogger { .shared }
|
|
|
|
public init(
|
|
disappearingMessageConfigStore: DisappearingMessagesConfigurationStore,
|
|
groupsV2: GroupsV2,
|
|
profileManager: MessageBackup.Shims.ProfileManager,
|
|
storyStore: StoryStore,
|
|
threadStore: ThreadStore
|
|
) {
|
|
self.disappearingMessageConfigStore = disappearingMessageConfigStore
|
|
self.groupsV2 = groupsV2
|
|
self.profileManager = profileManager
|
|
self.storyStore = storyStore
|
|
self.threadStore = threadStore
|
|
}
|
|
|
|
public func archiveRecipients(
|
|
stream: MessageBackupProtoOutputStream,
|
|
context: MessageBackup.RecipientArchivingContext,
|
|
tx: DBReadTransaction
|
|
) -> ArchiveMultiFrameResult {
|
|
var errors = [ArchiveMultiFrameResult.ArchiveFrameError]()
|
|
|
|
do {
|
|
try threadStore.enumerateGroupThreads(tx: tx) { groupThread, _ in
|
|
self.archiveGroupThread(
|
|
groupThread,
|
|
stream: stream,
|
|
context: context,
|
|
errors: &errors,
|
|
tx: tx
|
|
)
|
|
}
|
|
} 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 archiveGroupThread(
|
|
_ groupThread: TSGroupThread,
|
|
stream: MessageBackupProtoOutputStream,
|
|
context: MessageBackup.RecipientArchivingContext,
|
|
errors: inout [ArchiveMultiFrameResult.ArchiveFrameError],
|
|
tx: DBReadTransaction
|
|
) {
|
|
guard let groupModel = groupThread.groupModel as? TSGroupModelV2 else {
|
|
logger.warn("Skipping archive of V1 group.")
|
|
return
|
|
}
|
|
|
|
let groupId = groupModel.groupId
|
|
let groupMembership = groupModel.groupMembership
|
|
|
|
let groupAppId: RecipientAppId = .group(groupId)
|
|
let recipientId = context.assignRecipientId(to: groupAppId)
|
|
|
|
let groupMasterKey: Data
|
|
let groupPublicKey: Data
|
|
do {
|
|
let groupSecretParams = try GroupSecretParams(contents: [UInt8](groupModel.secretParamsData))
|
|
groupMasterKey = try groupSecretParams.getMasterKey().serialize().asData
|
|
groupPublicKey = try groupSecretParams.getPublicParams().serialize().asData
|
|
} catch {
|
|
errors.append(.archiveFrameError(.groupMasterKeyError(error), groupAppId))
|
|
return
|
|
}
|
|
|
|
var group = BackupProto.Group(
|
|
masterKey: groupMasterKey,
|
|
whitelisted: profileManager.isThread(
|
|
inProfileWhitelist: groupThread, tx: tx
|
|
),
|
|
hideStory: storyStore.getOrCreateStoryContextAssociatedData(
|
|
forGroupThread: groupThread, tx: tx
|
|
).isHidden
|
|
)
|
|
group.storySendMode = { () -> BackupProto.Group.StorySendMode in
|
|
switch groupThread.storyViewMode {
|
|
case .disabled: return .DISABLED
|
|
case .explicit: return .ENABLED
|
|
default: return .DEFAULT
|
|
}
|
|
}()
|
|
group.snapshot = { () -> BackupProto.Group.GroupSnapshot in
|
|
var groupSnapshot = BackupProto.Group.GroupSnapshot(
|
|
publicKey: groupPublicKey,
|
|
avatarUrl: groupModel.avatarUrlPath ?? "",
|
|
version: groupModel.revision,
|
|
inviteLinkPassword: groupModel.inviteLinkPassword ?? Data(),
|
|
announcementsOnly: groupModel.isAnnouncementsOnly
|
|
)
|
|
groupSnapshot.title = groupModel.groupName?.nilIfEmpty.map { .buildTitle($0) }
|
|
groupSnapshot.descriptionText = groupModel.descriptionText?.nilIfEmpty.map { .buildDescriptionText($0) }
|
|
groupSnapshot.disappearingMessagesTimer = { () -> BackupProto.Group.GroupAttributeBlob? in
|
|
let durationSeconds = disappearingMessageConfigStore.durationSeconds(for: groupThread, tx: tx)
|
|
return durationSeconds > 0 ? .buildDisappearingMessageTimer(durationSeconds) : nil
|
|
}()
|
|
groupSnapshot.accessControl = groupModel.access.asBackupProtoAccessControl
|
|
groupSnapshot.members = groupMembership.fullMembers.compactMap { address -> BackupProto.Group.Member? in
|
|
guard
|
|
let aci = address.aci,
|
|
let role = groupMembership.role(for: address),
|
|
let profileKey = profileManager.getProfileKeyData(for: address, tx: tx)
|
|
else {
|
|
errors.append(.archiveFrameError(.missingRequiredGroupMemberParams, groupAppId))
|
|
return nil
|
|
}
|
|
|
|
return .build(serviceId: aci, role: role, profileKeyData: profileKey)
|
|
}
|
|
groupSnapshot.membersPendingProfileKey = groupMembership.invitedMembers.compactMap { address -> BackupProto.Group.MemberPendingProfileKey? in
|
|
guard
|
|
let serviceId = address.serviceId,
|
|
let role = groupMembership.role(for: address),
|
|
let addedByAci = groupMembership.addedByAci(forInvitedMember: address)
|
|
else {
|
|
errors.append(.archiveFrameError(.missingRequiredGroupMemberParams, groupAppId))
|
|
return nil
|
|
}
|
|
|
|
// iOS doesn't track the timestamp of the invite, so we'll
|
|
// default-populate it.
|
|
var invitedMemberProto = BackupProto.Group.MemberPendingProfileKey(
|
|
addedByUserId: addedByAci.serviceIdBinary.asData,
|
|
timestamp: 0
|
|
)
|
|
invitedMemberProto.member = .build(
|
|
serviceId: serviceId,
|
|
role: role,
|
|
profileKeyData: nil
|
|
)
|
|
return invitedMemberProto
|
|
}
|
|
groupSnapshot.membersPendingAdminApproval = groupMembership.requestingMembers.compactMap { address -> BackupProto.Group.MemberPendingAdminApproval? in
|
|
guard
|
|
let aci = address.aci,
|
|
let profileKey = profileManager.getProfileKeyData(for: address, tx: tx)
|
|
else {
|
|
errors.append(.archiveFrameError(.missingRequiredGroupMemberParams, groupAppId))
|
|
return nil
|
|
}
|
|
|
|
// iOS doesn't track the timestamp of the request, so we'll
|
|
// default-populate it.
|
|
return BackupProto.Group.MemberPendingAdminApproval(
|
|
userId: aci.serviceIdBinary.asData,
|
|
profileKey: profileKey,
|
|
timestamp: 0
|
|
)
|
|
}
|
|
groupSnapshot.membersBanned = groupMembership.bannedMembers.map { aci, bannedAtMillis -> BackupProto.Group.MemberBanned in
|
|
return BackupProto.Group.MemberBanned(
|
|
userId: aci.serviceIdBinary.asData,
|
|
timestamp: bannedAtMillis
|
|
)
|
|
}
|
|
|
|
return groupSnapshot
|
|
}()
|
|
|
|
Self.writeFrameToStream(
|
|
stream,
|
|
objectId: groupAppId,
|
|
frameBuilder: {
|
|
var recipient = BackupProto.Recipient(id: recipientId.value)
|
|
recipient.destination = .group(group)
|
|
|
|
var frame = BackupProto.Frame()
|
|
frame.item = .recipient(recipient)
|
|
return frame
|
|
}
|
|
).map { errors.append($0) }
|
|
}
|
|
|
|
static func canRestore(_ recipient: BackupProto.Recipient) -> Bool {
|
|
switch recipient.destination {
|
|
case .group:
|
|
return true
|
|
case nil, .contact, .distributionList, .selfRecipient, .releaseNotes:
|
|
return false
|
|
}
|
|
}
|
|
|
|
public func restore(
|
|
_ recipient: BackupProto.Recipient,
|
|
context: MessageBackup.RecipientRestoringContext,
|
|
tx: DBWriteTransaction
|
|
) -> RestoreFrameResult {
|
|
let groupProto: BackupProto.Group
|
|
switch recipient.destination {
|
|
case .group(let backupProtoGroup):
|
|
groupProto = backupProtoGroup
|
|
case nil, .contact, .distributionList, .selfRecipient, .releaseNotes:
|
|
return .failure([.restoreFrameError(
|
|
.developerError(OWSAssertionError("Invalid proto for class")),
|
|
recipient.recipientId
|
|
)])
|
|
}
|
|
|
|
// MARK: Assemble the group model
|
|
|
|
let groupContextInfo: GroupV2ContextInfo
|
|
do {
|
|
let masterKey = groupProto.masterKey
|
|
|
|
guard groupsV2.isValidGroupV2MasterKey(masterKey) else {
|
|
return .failure([.restoreFrameError(.invalidProtoData(.invalidGV2MasterKey), recipient.recipientId)])
|
|
}
|
|
|
|
groupContextInfo = try groupsV2.groupV2ContextInfo(forMasterKeyData: masterKey)
|
|
} catch {
|
|
return .failure([.restoreFrameError(.invalidProtoData(.invalidGV2MasterKey), recipient.recipientId)])
|
|
}
|
|
|
|
guard let groupSnapshot = groupProto.snapshot else {
|
|
return .failure([.restoreFrameError(.invalidProtoData(.missingGV2GroupSnapshot), recipient.recipientId)])
|
|
}
|
|
|
|
var groupMembershipBuilder = GroupMembership.Builder()
|
|
for fullMember in groupSnapshot.members {
|
|
guard let aci = try? Aci.parseFrom(serviceIdBinary: fullMember.userId) else {
|
|
return .failure([.restoreFrameError(.invalidProtoData(.invalidAci(protoClass: BackupProto.Group.Member.self)), recipient.recipientId)])
|
|
}
|
|
guard let role = TSGroupMemberRole(backupProtoRole: fullMember.role) else {
|
|
return .failure([.restoreFrameError(.invalidProtoData(.unrecognizedGV2MemberRole(protoClass: BackupProto.Group.Member.self)), recipient.recipientId)])
|
|
}
|
|
guard let profileKey = OWSAES256Key(data: fullMember.profileKey) else {
|
|
return .failure([.restoreFrameError(.invalidProtoData(.invalidProfileKey(protoClass: BackupProto.Group.Member.self)), recipient.recipientId)])
|
|
}
|
|
|
|
groupMembershipBuilder.addFullMember(aci, role: role)
|
|
profileManager.setProfileKey(
|
|
profileKey,
|
|
forAci: aci,
|
|
localIdentifiers: context.localIdentifiers,
|
|
tx: tx
|
|
)
|
|
}
|
|
for invitedMember in groupSnapshot.membersPendingProfileKey {
|
|
guard let memberDetails = invitedMember.member else {
|
|
return .failure([.restoreFrameError(.invalidProtoData(.invitedGV2MemberMissingMemberDetails), recipient.recipientId)])
|
|
}
|
|
guard let serviceId = try? ServiceId.parseFrom(serviceIdBinary: memberDetails.userId) else {
|
|
return .failure([.restoreFrameError(.invalidProtoData(.invalidServiceId(protoClass: BackupProto.Group.MemberPendingProfileKey.self)), recipient.recipientId)])
|
|
}
|
|
guard let role = TSGroupMemberRole(backupProtoRole: memberDetails.role) else {
|
|
return .failure([.restoreFrameError(.invalidProtoData(.unrecognizedGV2MemberRole(protoClass: BackupProto.Group.MemberPendingProfileKey.self)), recipient.recipientId)])
|
|
}
|
|
guard let addedByAci = try? Aci.parseFrom(serviceIdBinary: invitedMember.addedByUserId) else {
|
|
return .failure([.restoreFrameError(.invalidProtoData(.invalidAci(protoClass: BackupProto.Group.MemberPendingProfileKey.self)), recipient.recipientId)])
|
|
}
|
|
|
|
groupMembershipBuilder.addInvitedMember(
|
|
serviceId,
|
|
role: role,
|
|
addedByAci: addedByAci
|
|
)
|
|
}
|
|
for requestingMember in groupSnapshot.membersPendingAdminApproval {
|
|
guard let aci = try? Aci.parseFrom(serviceIdBinary: requestingMember.userId) else {
|
|
return .failure([.restoreFrameError(.invalidProtoData(.invalidAci(protoClass: BackupProto.Group.MemberPendingAdminApproval.self)), recipient.recipientId)])
|
|
}
|
|
guard let profileKey = OWSAES256Key(data: requestingMember.profileKey) else {
|
|
return .failure([.restoreFrameError(.invalidProtoData(.invalidProfileKey(protoClass: BackupProto.Group.MemberPendingAdminApproval.self)), recipient.recipientId)])
|
|
}
|
|
|
|
groupMembershipBuilder.addRequestingMember(aci)
|
|
profileManager.setProfileKey(
|
|
profileKey,
|
|
forAci: aci,
|
|
localIdentifiers: context.localIdentifiers,
|
|
tx: tx
|
|
)
|
|
}
|
|
for bannedMember in groupSnapshot.membersBanned {
|
|
guard let aci = try? Aci.parseFrom(serviceIdBinary: bannedMember.userId) else {
|
|
return .failure([.restoreFrameError(.invalidProtoData(.invalidAci(protoClass: BackupProto.Group.MemberBanned.self)), recipient.recipientId)])
|
|
}
|
|
let bannedAtTimestampMillis = bannedMember.timestamp
|
|
|
|
groupMembershipBuilder.addBannedMember(
|
|
aci,
|
|
bannedAtTimestamp: bannedAtTimestampMillis
|
|
)
|
|
}
|
|
|
|
var groupModelBuilder = TSGroupModelBuilder()
|
|
groupModelBuilder.groupId = groupContextInfo.groupId
|
|
groupModelBuilder.groupSecretParamsData = groupContextInfo.groupSecretParamsData
|
|
groupModelBuilder.groupsVersion = .V2 // We don't back up V1 groups
|
|
groupModelBuilder.groupV2Revision = groupSnapshot.version
|
|
groupModelBuilder.name = groupSnapshot.extractTitle
|
|
groupModelBuilder.descriptionText = groupSnapshot.extractDescriptionText
|
|
groupModelBuilder.avatarData = nil
|
|
groupModelBuilder.avatarUrlPath = groupSnapshot.avatarUrl.nilIfEmpty
|
|
groupModelBuilder.groupMembership = groupMembershipBuilder.build()
|
|
groupModelBuilder.groupAccess = groupSnapshot.accessControl.map(GroupAccess.init(backupProtoAccessControl:))
|
|
groupModelBuilder.inviteLinkPassword = groupSnapshot.inviteLinkPassword.nilIfEmpty
|
|
groupModelBuilder.isAnnouncementsOnly = groupSnapshot.announcementsOnly
|
|
|
|
guard let groupModel: TSGroupModelV2 = try? groupModelBuilder.buildAsV2() else {
|
|
return .failure([.restoreFrameError(.invalidProtoData(.failedToBuildGV2GroupModel), recipient.recipientId)])
|
|
}
|
|
|
|
// MARK: Use the group model to create a group thread
|
|
|
|
let groupThread = threadStore.createGroupThread(
|
|
groupModel: groupModel, tx: tx
|
|
)
|
|
|
|
// MARK: Store group properties that live outside the group model
|
|
|
|
disappearingMessageConfigStore.set(
|
|
token: .token(
|
|
forProtoExpireTimerSeconds: groupSnapshot.extractDisappearingMessageTimer
|
|
),
|
|
for: .thread(groupThread),
|
|
tx: tx
|
|
)
|
|
|
|
if groupProto.whitelisted {
|
|
profileManager.addToWhitelist(groupThread, tx: tx)
|
|
}
|
|
|
|
let isStorySendEnabled: Bool? = {
|
|
switch groupProto.storySendMode {
|
|
case nil, .DEFAULT:
|
|
// No explicit setting.
|
|
return nil
|
|
case .DISABLED:
|
|
return false
|
|
case .ENABLED:
|
|
return true
|
|
}
|
|
}()
|
|
if let isStorySendEnabled {
|
|
threadStore.update(
|
|
groupThread: groupThread,
|
|
withStorySendEnabled: isStorySendEnabled,
|
|
updateStorageService: false,
|
|
tx: tx
|
|
)
|
|
}
|
|
if groupProto.hideStory {
|
|
// We only need to actively hide, since unhidden is the default.
|
|
let storyContext = storyStore.getOrCreateStoryContextAssociatedData(
|
|
forGroupThread: groupThread, tx: tx
|
|
)
|
|
storyStore.updateStoryContext(storyContext, isHidden: true, tx: tx)
|
|
}
|
|
|
|
if groupModel.avatarUrlPath != nil {
|
|
// [Backups] TODO: Enqueue download of the group avatar.
|
|
}
|
|
|
|
// MARK: Return successfully!
|
|
|
|
context[recipient.recipientId] = .group(groupModel.groupId)
|
|
return .success
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private extension BackupProto.Group.GroupAttributeBlob {
|
|
static func buildTitle(_ title: String) -> BackupProto.Group.GroupAttributeBlob {
|
|
var blob = BackupProto.Group.GroupAttributeBlob()
|
|
blob.content = .title(title)
|
|
return blob
|
|
}
|
|
|
|
static func buildDescriptionText(_ descriptionText: String) -> BackupProto.Group.GroupAttributeBlob {
|
|
var blob = BackupProto.Group.GroupAttributeBlob()
|
|
blob.content = .descriptionText(descriptionText)
|
|
return blob
|
|
}
|
|
|
|
static func buildDisappearingMessageTimer(_ disappearingMessageDuration: UInt32) -> BackupProto.Group.GroupAttributeBlob {
|
|
var blob = BackupProto.Group.GroupAttributeBlob()
|
|
blob.content = .disappearingMessagesDuration(disappearingMessageDuration)
|
|
return blob
|
|
}
|
|
}
|
|
|
|
private extension BackupProto.Group.GroupSnapshot {
|
|
var extractTitle: String? {
|
|
switch title?.content {
|
|
case .title(let title): return title
|
|
case nil, .avatar, .descriptionText, .disappearingMessagesDuration: return nil
|
|
}
|
|
}
|
|
|
|
var extractDescriptionText: String? {
|
|
switch descriptionText?.content {
|
|
case .descriptionText(let descriptionText): return descriptionText
|
|
case nil, .title, .avatar, .disappearingMessagesDuration: return nil
|
|
}
|
|
}
|
|
|
|
var extractDisappearingMessageTimer: UInt32? {
|
|
switch disappearingMessagesTimer?.content {
|
|
case .disappearingMessagesDuration(let disappearingMessageDuration): return disappearingMessageDuration
|
|
case nil, .title, .avatar, .descriptionText: return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private extension BackupProto.Group.Member {
|
|
static func build(
|
|
serviceId: ServiceId,
|
|
role: TSGroupMemberRole,
|
|
profileKeyData: Data?
|
|
) -> BackupProto.Group.Member {
|
|
// iOS doesn't track the joinedAtRevision, so we'll default-populate it.
|
|
return BackupProto.Group.Member(
|
|
userId: serviceId.serviceIdBinary.asData,
|
|
role: role.asBackupProtoRole,
|
|
profileKey: profileKeyData ?? Data(),
|
|
joinedAtVersion: 0
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private extension TSGroupMemberRole {
|
|
init?(backupProtoRole: BackupProto.Group.Member.Role) {
|
|
switch backupProtoRole {
|
|
case .UNKNOWN: return nil
|
|
case .DEFAULT: self = .normal
|
|
case .ADMINISTRATOR: self = .administrator
|
|
}
|
|
}
|
|
|
|
var asBackupProtoRole: BackupProto.Group.Member.Role {
|
|
switch self {
|
|
case .normal: return .DEFAULT
|
|
case .administrator: return .ADMINISTRATOR
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private extension GroupV2Access {
|
|
init(backupProtoAccessRequired: BackupProto.Group.AccessControl.AccessRequired) {
|
|
switch backupProtoAccessRequired {
|
|
case .UNKNOWN: self = .unknown
|
|
case .ANY: self = .any
|
|
case .MEMBER: self = .member
|
|
case .ADMINISTRATOR: self = .administrator
|
|
case .UNSATISFIABLE: self = .unsatisfiable
|
|
}
|
|
}
|
|
|
|
var asBackupProtoAccessRequired: BackupProto.Group.AccessControl.AccessRequired {
|
|
switch self {
|
|
case .unknown: return .UNKNOWN
|
|
case .any: return .ANY
|
|
case .member: return .MEMBER
|
|
case .administrator: return .ADMINISTRATOR
|
|
case .unsatisfiable: return .UNSATISFIABLE
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension GroupAccess {
|
|
convenience init(backupProtoAccessControl: BackupProto.Group.AccessControl) {
|
|
self.init(
|
|
members: GroupV2Access(backupProtoAccessRequired: backupProtoAccessControl.members),
|
|
attributes: GroupV2Access(backupProtoAccessRequired: backupProtoAccessControl.attributes),
|
|
addFromInviteLink: GroupV2Access(backupProtoAccessRequired: backupProtoAccessControl.addFromInviteLink)
|
|
)
|
|
}
|
|
|
|
var asBackupProtoAccessControl: BackupProto.Group.AccessControl {
|
|
return BackupProto.Group.AccessControl(
|
|
attributes: attributes.asBackupProtoAccessRequired,
|
|
members: members.asBackupProtoAccessRequired,
|
|
addFromInviteLink: addFromInviteLink.asBackupProtoAccessRequired
|
|
)
|
|
}
|
|
}
|