You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
session-ios/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift

488 lines
22 KiB
Swift

// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtil
import SessionSnodeKit
import SessionUtilitiesKit
// MARK: - Size Restrictions
public extension LibSession {
static var sizeMaxGroupDescriptionBytes: Int { GROUP_INFO_DESCRIPTION_MAX_LENGTH }
static func isTooLong(groupDescription: String) -> Bool {
return (groupDescription.utf8CString.count > LibSession.sizeMaxGroupDescriptionBytes)
}
}
// MARK: - Group Info Handling
internal extension LibSession {
static let columnsRelatedToGroupInfo: [ColumnExpression] = [
ClosedGroup.Columns.name,
ClosedGroup.Columns.groupDescription,
ClosedGroup.Columns.displayPictureUrl,
ClosedGroup.Columns.displayPictureEncryptionKey,
DisappearingMessagesConfiguration.Columns.isEnabled,
DisappearingMessagesConfiguration.Columns.type,
DisappearingMessagesConfiguration.Columns.durationSeconds
]
}
// MARK: - Incoming Changes
private struct InteractionInfo: Codable, FetchableRecord {
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression {
case id
case serverHash
}
let id: Int64
let serverHash: String?
}
internal extension LibSessionCacheType {
func handleGroupInfoUpdate(
_ db: Database,
in config: LibSession.Config?,
groupSessionId: SessionId,
serverTimestampMs: Int64
) throws {
guard configNeedsDump(config) else { return }
guard case .groupInfo(let conf) = config else { throw LibSessionError.invalidConfigObject }
// If the group is destroyed then mark the group as kicked in the USER_GROUPS config and remove
// the group data (want to keep the group itself around because the UX of conversations randomly
// disappearing isn't great) - no other changes matter and this can't be reversed
guard !groups_info_is_destroyed(conf) else {
try markAsDestroyed(db, groupSessionIds: [groupSessionId.hexString], using: dependencies)
try ClosedGroup.removeData(
db,
threadIds: [groupSessionId.hexString],
dataToRemove: [
.poller, .pushNotifications, .messages, .members,
.encryptionKeys, .authDetails, .libSessionState
],
using: dependencies
)
return
}
// A group must have a name so if this is null then it's invalid and can be ignored
guard let groupNamePtr: UnsafePointer<CChar> = groups_info_get_name(conf) else { return }
let groupDescPtr: UnsafePointer<CChar>? = groups_info_get_description(conf)
let groupName: String = String(cString: groupNamePtr)
let groupDesc: String? = groupDescPtr.map { String(cString: $0) }
let formationTimestamp: TimeInterval = TimeInterval(groups_info_get_created(conf))
// The `displayPic.key` can contain junk data so if the `displayPictureUrl` is null then just
// set the `displayPictureKey` to null as well
let displayPic: user_profile_pic = groups_info_get_pic(conf)
let displayPictureUrl: String? = displayPic.get(\.url, nullIfEmpty: true)
let displayPictureKey: Data? = (displayPictureUrl == nil ? nil : displayPic.get(\.key, nullIfEmpty: true))
// Update the group name
let existingGroup: ClosedGroup? = try? ClosedGroup
.filter(id: groupSessionId.hexString)
.fetchOne(db)
let needsDisplayPictureUpdate: Bool = (
existingGroup?.displayPictureUrl != displayPictureUrl ||
existingGroup?.displayPictureEncryptionKey != displayPictureKey
)
let groupChanges: [ConfigColumnAssignment] = [
((existingGroup?.name == groupName) ? nil :
ClosedGroup.Columns.name.set(to: groupName)
),
((existingGroup?.groupDescription == groupDesc) ? nil :
ClosedGroup.Columns.groupDescription.set(to: groupDesc)
),
// Only update the 'formationTimestamp' if we don't have one (don't want to override the 'joinedAt'
// timestamp with the groups creation timestamp
(formationTimestamp < (existingGroup?.formationTimestamp ?? 0) ? nil :
ClosedGroup.Columns.formationTimestamp.set(to: formationTimestamp)
),
// If we are removing the display picture do so here
(!needsDisplayPictureUpdate || displayPictureUrl != nil ? nil :
ClosedGroup.Columns.displayPictureUrl.set(to: nil)
),
(!needsDisplayPictureUpdate || displayPictureUrl != nil ? nil :
ClosedGroup.Columns.displayPictureFilename.set(to: nil)
),
(!needsDisplayPictureUpdate || displayPictureUrl != nil ? nil :
ClosedGroup.Columns.displayPictureEncryptionKey.set(to: nil)
),
(!needsDisplayPictureUpdate || displayPictureUrl != nil ? nil :
ClosedGroup.Columns.lastDisplayPictureUpdate.set(to: (serverTimestampMs / 1000))
)
].compactMap { $0 }
if !groupChanges.isEmpty {
try ClosedGroup
.filter(id: groupSessionId.hexString)
.updateAllAndConfig(
db,
groupChanges,
using: dependencies
)
}
// If we have a display picture then start downloading it
if needsDisplayPictureUpdate, let url: String = displayPictureUrl, let key: Data = displayPictureKey {
dependencies[singleton: .jobRunner].add(
db,
job: Job(
variant: .displayPictureDownload,
shouldBeUnique: true,
details: DisplayPictureDownloadJob.Details(
target: .group(id: groupSessionId.hexString, url: url, encryptionKey: key),
timestamp: TimeInterval(Double(serverTimestampMs) / 1000)
)
),
canStartJob: true
)
}
// Update the disappearing messages configuration
let targetExpiry: Int32 = groups_info_get_expiry_timer(conf)
let localConfig: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration
.fetchOne(db, id: groupSessionId.hexString)
.defaulting(to: DisappearingMessagesConfiguration.defaultWith(groupSessionId.hexString))
let updatedConfig: DisappearingMessagesConfiguration = DisappearingMessagesConfiguration
.defaultWith(groupSessionId.hexString)
.with(
isEnabled: (targetExpiry > 0),
durationSeconds: TimeInterval(targetExpiry),
type: .disappearAfterSend
)
if localConfig != updatedConfig {
try updatedConfig
.saved(db)
.clearUnrelatedControlMessages(
db,
threadVariant: .group,
using: dependencies
)
}
// Check if the user is an admin in the group
var messageHashesToDelete: Set<String> = []
let isAdmin: Bool = ((try? ClosedGroup
.filter(id: groupSessionId.hexString)
.select(.groupIdentityPrivateKey)
.asRequest(of: Data.self)
.fetchOne(db)) != nil)
// If there is a `delete_before` setting then delete all messages before the provided timestamp
let deleteBeforeTimestamp: Int64 = groups_info_get_delete_before(conf)
if deleteBeforeTimestamp > 0 {
let interactionInfo: [InteractionInfo] = (try? Interaction
.filter(Interaction.Columns.threadId == groupSessionId.hexString)
.filter(Interaction.Columns.timestampMs < (TimeInterval(deleteBeforeTimestamp) * 1000))
.select(.id, .serverHash)
.asRequest(of: InteractionInfo.self)
.fetchAll(db))
.defaulting(to: [])
let interactionIdsToRemove: Set<Int64> = Set(interactionInfo.map { $0.id })
let reactionHashes: Set<String> = try Reaction
.filter(interactionIdsToRemove.contains(Reaction.Columns.interactionId))
.filter(Reaction.Columns.serverHash != nil)
.select(.serverHash)
.asRequest(of: String.self)
.fetchSet(db)
try Interaction.markAsDeleted(
db,
threadId: groupSessionId.hexString,
threadVariant: .group,
interactionIds: Set(interactionIdsToRemove),
localOnly: false,
using: dependencies
)
if !interactionInfo.isEmpty {
Log.info(.libSession, "Deleted \(interactionInfo.count) message(s) from \(groupSessionId.hexString) due to 'delete_before' value.")
}
if isAdmin {
messageHashesToDelete.insert(contentsOf: Set(interactionInfo.compactMap { $0.serverHash }))
messageHashesToDelete.insert(contentsOf: reactionHashes)
}
}
// If there is a `attach_delete_before` setting then delete all messages that have attachments before
// the provided timestamp and schedule a garbage collection job
let attachDeleteBeforeTimestamp: Int64 = groups_info_get_attach_delete_before(conf)
if attachDeleteBeforeTimestamp > 0 {
let interactionInfo: [InteractionInfo] = (try? Interaction
.filter(Interaction.Columns.threadId == groupSessionId.hexString)
.filter(Interaction.Columns.timestampMs < (TimeInterval(attachDeleteBeforeTimestamp) * 1000))
.joining(
required: Interaction.interactionAttachments.joining(
required: InteractionAttachment.attachment
.filter(Attachment.Columns.variant != Attachment.Variant.voiceMessage)
)
)
.select(.id, .serverHash)
.asRequest(of: InteractionInfo.self)
.fetchAll(db))
.defaulting(to: [])
let interactionIdsToRemove: Set<Int64> = Set(interactionInfo.map { $0.id })
let reactionHashes: Set<String> = try Reaction
.filter(interactionIdsToRemove.contains(Reaction.Columns.interactionId))
.filter(Reaction.Columns.serverHash != nil)
.select(.serverHash)
.asRequest(of: String.self)
.fetchSet(db)
try Interaction.markAsDeleted(
db,
threadId: groupSessionId.hexString,
threadVariant: .group,
interactionIds: Set(interactionIdsToRemove),
localOnly: false,
using: dependencies
)
if !interactionInfo.isEmpty {
Log.info(.libSession, "Deleted \(interactionInfo.count) message(s) with attachments from \(groupSessionId.hexString) due to 'attach_delete_before' value.")
// Schedule a grabage collection job to clean up any now-orphaned attachment files
dependencies[singleton: .jobRunner].add(
db,
job: Job(
variant: .garbageCollection,
details: GarbageCollectionJob.Details(
typesToCollect: [.orphanedAttachments, .orphanedAttachmentFiles]
)
),
canStartJob: true
)
}
if isAdmin {
messageHashesToDelete.insert(contentsOf: Set(interactionInfo.compactMap { $0.serverHash }))
messageHashesToDelete.insert(contentsOf: reactionHashes)
}
}
// If the current user is a group admin and there are message hashes which should be deleted then
// send a fire-and-forget API call to delete the messages from the swarm
if isAdmin && !messageHashesToDelete.isEmpty {
(try? Authentication.with(
db,
swarmPublicKey: groupSessionId.hexString,
using: dependencies
)).map { authMethod in
try? SnodeAPI
.preparedDeleteMessages(
serverHashes: Array(messageHashesToDelete),
requireSuccessfulDeletion: false,
authMethod: authMethod,
using: dependencies
)
.send(using: dependencies)
.subscribe(on: DispatchQueue.global(qos: .background), using: dependencies)
.sinkUntilComplete()
}
}
}
}
// MARK: - Outgoing Changes
internal extension LibSession {
static func updatingGroupInfo<T>(
_ db: Database,
_ updated: [T],
using dependencies: Dependencies
) throws -> [T] {
guard let updatedGroups: [ClosedGroup] = updated as? [ClosedGroup] else { throw StorageError.generic }
// Exclude legacy groups as they aren't managed via LibSession and groups where the current user isn't an
// admin (non-admins can't update `GroupInfo` anyway)
let targetGroups: [ClosedGroup] = updatedGroups
.filter { (try? SessionId(from: $0.id))?.prefix == .group }
.filter { isAdmin(groupSessionId: SessionId(.group, hex: $0.id), using: dependencies) }
// If we only updated the current user contact then no need to continue
guard !targetGroups.isEmpty else { return updated }
// Loop through each of the groups and update their settings
try targetGroups.forEach { group in
try dependencies.mutate(cache: .libSession) { cache in
let groupSessionId: SessionId = SessionId(.group, hex: group.threadId)
/// Don't update the group info if the current user isn't an admin (doing so would throw which would revert this database
/// transaction)
guard cache.isAdmin(groupSessionId: groupSessionId) else { return }
try cache.performAndPushChange(db, for: .groupInfo, sessionId: groupSessionId) { config in
guard case .groupInfo(let conf) = config else { throw LibSessionError.invalidConfigObject }
guard
var cGroupName: [CChar] = group.name.cString(using: .utf8),
var cGroupDesc: [CChar] = (group.groupDescription ?? "").cString(using: .utf8)
else { throw LibSessionError.invalidCConversion }
/// Update the name
///
/// **Note:** We indentionally only update the `GROUP_INFO` and not the `USER_GROUPS` as once the
/// group is synced between devices we want to rely on the proper group config to get display info
groups_info_set_name(conf, &cGroupName)
groups_info_set_description(conf, &cGroupDesc)
// Either assign the updated display pic, or sent a blank pic (to remove the current one)
var displayPic: user_profile_pic = user_profile_pic()
displayPic.set(\.url, to: group.displayPictureUrl)
displayPic.set(\.key, to: group.displayPictureEncryptionKey)
groups_info_set_pic(conf, displayPic)
}
}
}
return updated
}
static func updatingDisappearingConfigsGroups<T>(
_ db: Database,
_ updated: [T],
using dependencies: Dependencies
) throws -> [T] {
guard let updatedDisappearingConfigs: [DisappearingMessagesConfiguration] = updated as? [DisappearingMessagesConfiguration] else { throw StorageError.generic }
// Filter out any disappearing config changes not related to updated groups and groups where
// the current user isn't an admin (non-admins can't update `GroupInfo` anyway)
let targetUpdatedConfigs: [DisappearingMessagesConfiguration] = updatedDisappearingConfigs
.filter { (try? SessionId.Prefix(from: $0.id)) == .group }
.filter { isAdmin(groupSessionId: SessionId(.group, hex: $0.id), using: dependencies) }
guard !targetUpdatedConfigs.isEmpty else { return updated }
// We should only sync disappearing messages configs which are associated to existing groups
let existingGroupIds: [String] = (try? ClosedGroup
.filter(ids: targetUpdatedConfigs.map { $0.id })
.select(.threadId)
.asRequest(of: String.self)
.fetchAll(db))
.defaulting(to: [])
// If none of the disappearing messages configs are associated with existing groups then ignore
// the changes (no need to do a config sync)
guard !existingGroupIds.isEmpty else { return updated }
// Loop through each of the groups and update their settings
try existingGroupIds
.compactMap { groupId in targetUpdatedConfigs.first(where: { $0.id == groupId }).map { (groupId, $0) } }
.forEach { groupId, updatedConfig in
try dependencies.mutate(cache: .libSession) { cache in
try cache.performAndPushChange(db, for: .groupInfo, sessionId: SessionId(.group, hex: groupId)) { config in
guard case .groupInfo(let conf) = config else { throw LibSessionError.invalidConfigObject }
groups_info_set_expiry_timer(conf, Int32(updatedConfig.durationSeconds))
}
}
}
return updated
}
}
// MARK: - External Outgoing Changes
public extension LibSession {
static func update(
_ db: Database,
groupSessionId: SessionId,
disappearingConfig: DisappearingMessagesConfiguration?,
using dependencies: Dependencies
) throws {
try dependencies.mutate(cache: .libSession) { cache in
try cache.performAndPushChange(db, for: .groupInfo, sessionId: groupSessionId) { config in
guard case .groupInfo(let conf) = config else { throw LibSessionError.invalidConfigObject }
if let config: DisappearingMessagesConfiguration = disappearingConfig {
groups_info_set_expiry_timer(conf, Int32(config.durationSeconds))
}
}
}
}
static func deleteMessagesBefore(
_ db: Database,
groupSessionId: SessionId,
timestamp: TimeInterval,
using dependencies: Dependencies
) throws {
try dependencies.mutate(cache: .libSession) { cache in
try cache.performAndPushChange(db, for: .groupInfo, sessionId: groupSessionId) { config in
guard case .groupInfo(let conf) = config else { throw LibSessionError.invalidConfigObject }
// Do nothing if the timestamp isn't newer than the current value
guard Int64(timestamp) > groups_info_get_delete_before(conf) else { return }
groups_info_set_delete_before(conf, Int64(timestamp))
}
}
}
static func deleteAttachmentsBefore(
_ db: Database,
groupSessionId: SessionId,
timestamp: TimeInterval,
using dependencies: Dependencies
) throws {
try dependencies.mutate(cache: .libSession) { cache in
try cache.performAndPushChange(db, for: .groupInfo, sessionId: groupSessionId) { config in
guard case .groupInfo(let conf) = config else { throw LibSessionError.invalidConfigObject }
// Do nothing if the timestamp isn't newer than the current value
guard Int64(timestamp) > groups_info_get_attach_delete_before(conf) else { return }
groups_info_set_attach_delete_before(conf, Int64(timestamp))
}
}
}
}
public extension LibSessionCacheType {
func deleteGroupForEveryone(_ db: Database, groupSessionId: SessionId) throws {
try performAndPushChange(db, for: .groupInfo, sessionId: groupSessionId) { config in
guard case .groupInfo(let conf) = config else { throw LibSessionError.invalidConfigObject }
groups_info_destroy_group(conf)
}
}
}
// MARK: - Direct Values
extension LibSession {
static func groupName(in config: Config?) throws -> String {
guard
case .groupInfo(let conf) = config,
let groupNamePtr: UnsafePointer<CChar> = groups_info_get_name(conf)
else { throw LibSessionError.invalidConfigObject }
return String(cString: groupNamePtr)
}
static func groupDeleteBefore(in config: Config?) throws -> TimeInterval {
guard case .groupInfo(let conf) = config else { throw LibSessionError.invalidConfigObject }
return TimeInterval(groups_info_get_delete_before(conf))
}
static func groupAttachmentDeleteBefore(in config: Config?) throws -> TimeInterval {
guard case .groupInfo(let conf) = config else { throw LibSessionError.invalidConfigObject }
return TimeInterval(groups_info_get_attach_delete_before(conf))
}
}