Started working on some config contact pruning logic
Added support for a ConfirmationModal with an input field Added a mechanism on Debug builds to export the database and it's key Added logic to catch exceptions thrown within libSession (need to actually plug it in) Added a debug-only mechanism to export the users database and (encrypted) database key Added a few unit tests to check the CONTACTS config message size constraints
This commit is contained in:
parent
65bf7d7d82
commit
d2c82cb915
|
@ -0,0 +1,20 @@
|
|||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
let arguments = CommandLine.arguments
|
||||
|
||||
// First argument is the file name
|
||||
if arguments.count == 3 {
|
||||
let encryptedData = Data(base64Encoded: arguments[1].data(using: .utf8)!)!
|
||||
let hash: SHA256.Digest = SHA256.hash(data: arguments[2].data(using: .utf8)!)
|
||||
let key: SymmetricKey = SymmetricKey(data: Data(hash.makeIterator()))
|
||||
let sealedBox = try! ChaChaPoly.SealedBox(combined: encryptedData)
|
||||
let decryptedData = try! ChaChaPoly.open(sealedBox, using: key)
|
||||
|
||||
print(Array(decryptedData).map { String(format: "%02x", $0) }.joined())
|
||||
}
|
||||
else {
|
||||
print("Please provide the base64 encoded 'encrypted key' and plain text 'password' as arguments")
|
||||
}
|
|
@ -588,6 +588,8 @@
|
|||
FD2B4AFF2946C93200AB4848 /* ConfigurationSyncJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4AFE2946C93200AB4848 /* ConfigurationSyncJob.swift */; };
|
||||
FD2B4B042949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */; };
|
||||
FD3003662A25D5B300B5A5FB /* ConfigMessageReceiveJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3003652A25D5B300B5A5FB /* ConfigMessageReceiveJob.swift */; };
|
||||
FD30036A2A3ADEC100B5A5FB /* CExceptionHelper.h in Headers */ = {isa = PBXBuildFile; fileRef = FD3003692A3ADD6000B5A5FB /* CExceptionHelper.h */; settings = {ATTRIBUTES = (Public, ); }; };
|
||||
FD30036E2A3AE26000B5A5FB /* CExceptionHelper.mm in Sources */ = {isa = PBXBuildFile; fileRef = FD30036D2A3AE26000B5A5FB /* CExceptionHelper.mm */; };
|
||||
FD368A6829DE8F9C000DBF1E /* _012_AddFTSIfNeeded.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD368A6729DE8F9B000DBF1E /* _012_AddFTSIfNeeded.swift */; };
|
||||
FD368A6A29DE9E30000DBF1E /* UIContextualAction+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD368A6929DE9E30000DBF1E /* UIContextualAction+Utilities.swift */; };
|
||||
FD37E9C328A1C6F3003AE748 /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */; };
|
||||
|
@ -1727,6 +1729,8 @@
|
|||
FD2B4AFE2946C93200AB4848 /* ConfigurationSyncJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationSyncJob.swift; sourceTree = "<group>"; };
|
||||
FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QueryInterfaceRequest+Utilities.swift"; sourceTree = "<group>"; };
|
||||
FD3003652A25D5B300B5A5FB /* ConfigMessageReceiveJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigMessageReceiveJob.swift; sourceTree = "<group>"; };
|
||||
FD3003692A3ADD6000B5A5FB /* CExceptionHelper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CExceptionHelper.h; sourceTree = "<group>"; };
|
||||
FD30036D2A3AE26000B5A5FB /* CExceptionHelper.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = CExceptionHelper.mm; sourceTree = "<group>"; };
|
||||
FD368A6729DE8F9B000DBF1E /* _012_AddFTSIfNeeded.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = _012_AddFTSIfNeeded.swift; sourceTree = "<group>"; };
|
||||
FD368A6929DE9E30000DBF1E /* UIContextualAction+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIContextualAction+Utilities.swift"; sourceTree = "<group>"; };
|
||||
FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = "<group>"; };
|
||||
|
@ -3589,6 +3593,8 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */,
|
||||
FD3003692A3ADD6000B5A5FB /* CExceptionHelper.h */,
|
||||
FD30036D2A3AE26000B5A5FB /* CExceptionHelper.mm */,
|
||||
FD09796A27F6C67500936362 /* Failable.swift */,
|
||||
FD09797127FAA2F500936362 /* Optional+Utilities.swift */,
|
||||
FD09797C27FBDB2000936362 /* Notification+Utilities.swift */,
|
||||
|
@ -4457,6 +4463,7 @@
|
|||
B8856E1A256F1700001CE70E /* OWSMath.h in Headers */,
|
||||
C352A3772557864000338F3E /* NSTimer+Proxying.h in Headers */,
|
||||
C32C5B51256DC219003C73A2 /* NSNotificationCenter+OWS.h in Headers */,
|
||||
FD30036A2A3ADEC100B5A5FB /* CExceptionHelper.h in Headers */,
|
||||
C3C2A67D255388CC00C340D1 /* SessionUtilitiesKit.h in Headers */,
|
||||
C32C6018256E07F9003C73A2 /* NSUserDefaults+OWS.h in Headers */,
|
||||
B8856D8D256F1502001CE70E /* UIView+OWS.h in Headers */,
|
||||
|
@ -5639,6 +5646,7 @@
|
|||
C3D9E4D12567777D0040E4F3 /* OWSMediaUtils.swift in Sources */,
|
||||
C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Utilities.swift in Sources */,
|
||||
FD37EA1128AB34B3003AE748 /* TypedTableAlteration.swift in Sources */,
|
||||
FD30036E2A3AE26000B5A5FB /* CExceptionHelper.mm in Sources */,
|
||||
C3D9E4DA256778410040E4F3 /* UIImage+OWS.m in Sources */,
|
||||
C32C600F256E07F5003C73A2 /* NSUserDefaults+OWS.m in Sources */,
|
||||
FDE658A329418E2F00A33BC1 /* KeyPair.swift in Sources */,
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
|
@ -9,6 +10,10 @@ import SessionUtilitiesKit
|
|||
import SignalCoreKit
|
||||
|
||||
class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpViewModel.Section> {
|
||||
#if DEBUG
|
||||
private var databaseKeyEncryptionPassword: String = ""
|
||||
#endif
|
||||
|
||||
// MARK: - Section
|
||||
|
||||
public enum Section: SessionTableSection {
|
||||
|
@ -17,6 +22,9 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
|
|||
case feedback
|
||||
case faq
|
||||
case support
|
||||
#if DEBUG
|
||||
case exportDatabase
|
||||
#endif
|
||||
|
||||
var style: SessionTableSectionStyle { .padding }
|
||||
}
|
||||
|
@ -136,6 +144,28 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
|
|||
]
|
||||
)
|
||||
]
|
||||
#if DEBUG
|
||||
.appending(
|
||||
SectionModel(
|
||||
model: .exportDatabase,
|
||||
elements: [
|
||||
SessionCell.Info(
|
||||
id: .support,
|
||||
title: "Export Database",
|
||||
rightAccessory: .icon(
|
||||
UIImage(systemName: "square.and.arrow.up.trianglebadge.exclamationmark")?
|
||||
.withRenderingMode(.alwaysTemplate),
|
||||
size: .small
|
||||
),
|
||||
styling: SessionCell.StyleInfo(
|
||||
tintColor: .danger
|
||||
),
|
||||
onTapView: { [weak self] view in self?.exportDatabase(view) }
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
#endif
|
||||
}
|
||||
.removeDuplicates()
|
||||
.publisher(in: Storage.shared)
|
||||
|
@ -197,4 +227,132 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
|
|||
showShareSheet()
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private func exportDatabase(_ targetView: UIView?) {
|
||||
let generatedPassword: String = UUID().uuidString
|
||||
self.databaseKeyEncryptionPassword = generatedPassword
|
||||
|
||||
self.transitionToScreen(
|
||||
ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: "Export Database",
|
||||
body: .input(
|
||||
explanation: NSAttributedString(
|
||||
string: """
|
||||
Sharing the database and key together is dangerous!
|
||||
|
||||
We've generated a secure password for you but feel free to provide your own (we will show the generated password again after exporting)
|
||||
|
||||
This password will be used to encrypt the database decryption key and will be exported alongside the database
|
||||
"""
|
||||
),
|
||||
placeholder: "Enter a password",
|
||||
initialValue: generatedPassword,
|
||||
clearButton: true,
|
||||
onChange: { [weak self] value in self?.databaseKeyEncryptionPassword = value }
|
||||
),
|
||||
confirmTitle: "Export",
|
||||
dismissOnConfirm: false,
|
||||
onConfirm: { [weak self] modal in
|
||||
modal.dismiss(animated: true) {
|
||||
guard let password: String = self?.databaseKeyEncryptionPassword, password.count >= 6 else {
|
||||
self?.transitionToScreen(
|
||||
ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: "Error",
|
||||
body: .text("Password must be at least 6 characters")
|
||||
)
|
||||
),
|
||||
transitionType: .present
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let exportInfo = try Storage.shared.exportInfo(password: password)
|
||||
let shareVC = UIActivityViewController(
|
||||
activityItems: [
|
||||
URL(fileURLWithPath: exportInfo.dbPath),
|
||||
URL(fileURLWithPath: exportInfo.keyPath)
|
||||
],
|
||||
applicationActivities: nil
|
||||
)
|
||||
shareVC.completionWithItemsHandler = { [weak self] _, completed, _, _ in
|
||||
guard
|
||||
completed &&
|
||||
generatedPassword == self?.databaseKeyEncryptionPassword
|
||||
else { return }
|
||||
|
||||
self?.transitionToScreen(
|
||||
ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: "Password",
|
||||
body: .text("""
|
||||
The generated password was:
|
||||
\(generatedPassword)
|
||||
|
||||
Avoid sending this via the same means as the database
|
||||
"""),
|
||||
confirmTitle: "Share",
|
||||
dismissOnConfirm: false,
|
||||
onConfirm: { [weak self] modal in
|
||||
modal.dismiss(animated: true) {
|
||||
let passwordShareVC = UIActivityViewController(
|
||||
activityItems: [generatedPassword],
|
||||
applicationActivities: nil
|
||||
)
|
||||
if UIDevice.current.isIPad {
|
||||
passwordShareVC.excludedActivityTypes = []
|
||||
passwordShareVC.popoverPresentationController?.permittedArrowDirections = (targetView != nil ? [.up] : [])
|
||||
passwordShareVC.popoverPresentationController?.sourceView = targetView
|
||||
passwordShareVC.popoverPresentationController?.sourceRect = (targetView?.bounds ?? .zero)
|
||||
}
|
||||
|
||||
self?.transitionToScreen(passwordShareVC, transitionType: .present)
|
||||
}
|
||||
}
|
||||
)
|
||||
),
|
||||
transitionType: .present
|
||||
)
|
||||
}
|
||||
|
||||
if UIDevice.current.isIPad {
|
||||
shareVC.excludedActivityTypes = []
|
||||
shareVC.popoverPresentationController?.permittedArrowDirections = (targetView != nil ? [.up] : [])
|
||||
shareVC.popoverPresentationController?.sourceView = targetView
|
||||
shareVC.popoverPresentationController?.sourceRect = (targetView?.bounds ?? .zero)
|
||||
}
|
||||
|
||||
self?.transitionToScreen(shareVC, transitionType: .present)
|
||||
}
|
||||
catch {
|
||||
let message: String = {
|
||||
switch error {
|
||||
case CryptoKitError.incorrectKeySize:
|
||||
return "The password must be between 6 and 32 characters (padded to 32 bytes)"
|
||||
|
||||
default: return "Failed to export database"
|
||||
}
|
||||
}()
|
||||
|
||||
self?.transitionToScreen(
|
||||
ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: "Error",
|
||||
body: .text(message)
|
||||
)
|
||||
),
|
||||
transitionType: .present
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
),
|
||||
transitionType: .present
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
|
|
@ -55,11 +55,9 @@ enum _013_SessionUtilChanges: Migration {
|
|||
// shares the same 'id' as the 'groupId') so we can cascade delete automatically
|
||||
t.column(.groupId, .text)
|
||||
.notNull()
|
||||
.indexed() // Quicker querying
|
||||
.references(SessionThread.self, onDelete: .cascade) // Delete if Thread deleted
|
||||
t.column(.profileId, .text)
|
||||
.notNull()
|
||||
.indexed() // Quicker querying
|
||||
t.column(.role, .integer).notNull()
|
||||
t.column(.isHidden, .boolean)
|
||||
.notNull()
|
||||
|
@ -80,6 +78,11 @@ enum _013_SessionUtilChanges: Migration {
|
|||
try db.drop(table: GroupMember.self)
|
||||
try db.rename(table: TmpGroupMember.databaseTableName, to: GroupMember.databaseTableName)
|
||||
|
||||
// Need to create the indexes separately from creating 'TmpGroupMember' to ensure they
|
||||
// have the correct names
|
||||
try db.createIndex(on: GroupMember.self, columns: [.groupId])
|
||||
try db.createIndex(on: GroupMember.self, columns: [.profileId])
|
||||
|
||||
// SQLite doesn't support removing unique constraints so we need to create a new table with
|
||||
// the setup we want, copy data from the old table over, drop the old table and rename the new table
|
||||
struct TmpClosedGroupKeyPair: Codable, TableRecord, FetchableRecord, PersistableRecord, ColumnExpressible {
|
||||
|
@ -107,18 +110,16 @@ enum _013_SessionUtilChanges: Migration {
|
|||
try db.create(table: TmpClosedGroupKeyPair.self) { t in
|
||||
t.column(.threadId, .text)
|
||||
.notNull()
|
||||
.indexed() // Quicker querying
|
||||
.references(ClosedGroup.self, onDelete: .cascade) // Delete if ClosedGroup deleted
|
||||
t.column(.publicKey, .blob).notNull()
|
||||
t.column(.secretKey, .blob).notNull()
|
||||
t.column(.receivedTimestamp, .double)
|
||||
.notNull()
|
||||
.indexed() // Quicker querying
|
||||
t.column(.threadKeyPairHash, .integer)
|
||||
.notNull()
|
||||
.unique()
|
||||
.indexed()
|
||||
}
|
||||
|
||||
// Insert into the new table, drop the old table and rename the new table to be the old one
|
||||
try ClosedGroupKeyPair
|
||||
.fetchAll(db)
|
||||
|
@ -144,6 +145,12 @@ enum _013_SessionUtilChanges: Migration {
|
|||
try db.rename(table: TmpClosedGroupKeyPair.databaseTableName, to: ClosedGroupKeyPair.databaseTableName)
|
||||
|
||||
// Add an index for the 'ClosedGroupKeyPair' so we can lookup existing keys more easily
|
||||
//
|
||||
// Note: Need to create the indexes separately from creating 'TmpClosedGroupKeyPair' to ensure they
|
||||
// have the correct names
|
||||
try db.createIndex(on: ClosedGroupKeyPair.self, columns: [.threadId])
|
||||
try db.createIndex(on: ClosedGroupKeyPair.self, columns: [.receivedTimestamp])
|
||||
try db.createIndex(on: ClosedGroupKeyPair.self, columns: [.threadKeyPairHash])
|
||||
try db.createIndex(
|
||||
on: ClosedGroupKeyPair.self,
|
||||
columns: [.threadId, .threadKeyPairHash]
|
||||
|
|
|
@ -54,12 +54,13 @@ public struct Contact: Codable, Identifiable, Equatable, FetchableRecord, Persis
|
|||
isApproved: Bool = false,
|
||||
isBlocked: Bool = false,
|
||||
didApproveMe: Bool = false,
|
||||
hasBeenBlocked: Bool = false
|
||||
hasBeenBlocked: Bool = false,
|
||||
dependencies: Dependencies = Dependencies()
|
||||
) {
|
||||
self.id = id
|
||||
self.isTrusted = (
|
||||
isTrusted ||
|
||||
id == getUserHexEncodedPublicKey() // Always trust ourselves
|
||||
id == getUserHexEncodedPublicKey(dependencies: dependencies) // Always trust ourselves
|
||||
)
|
||||
self.isApproved = isApproved
|
||||
self.isBlocked = isBlocked
|
||||
|
|
|
@ -36,7 +36,7 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord,
|
|||
case pinnedPriority
|
||||
}
|
||||
|
||||
public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible {
|
||||
public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible, CaseIterable {
|
||||
case contact
|
||||
case legacyGroup
|
||||
case community
|
||||
|
|
|
@ -34,63 +34,16 @@ internal extension SessionUtil {
|
|||
mergeNeedsDump: Bool,
|
||||
latestConfigSentTimestampMs: Int64
|
||||
) throws {
|
||||
typealias ContactData = [
|
||||
String: (
|
||||
contact: Contact,
|
||||
profile: Profile,
|
||||
priority: Int32,
|
||||
created: TimeInterval
|
||||
)
|
||||
]
|
||||
|
||||
guard mergeNeedsDump else { return }
|
||||
guard conf != nil else { throw SessionUtilError.nilConfigObject }
|
||||
|
||||
var contactData: ContactData = [:]
|
||||
var contact: contacts_contact = contacts_contact()
|
||||
let contactIterator: UnsafeMutablePointer<contacts_iterator> = contacts_iterator_new(conf)
|
||||
|
||||
while !contacts_iterator_done(contactIterator, &contact) {
|
||||
let contactId: String = String(cString: withUnsafeBytes(of: contact.session_id) { [UInt8]($0) }
|
||||
.map { CChar($0) }
|
||||
.nullTerminated()
|
||||
)
|
||||
let contactResult: Contact = Contact(
|
||||
id: contactId,
|
||||
isApproved: contact.approved,
|
||||
isBlocked: contact.blocked,
|
||||
didApproveMe: contact.approved_me
|
||||
)
|
||||
let profilePictureUrl: String? = String(libSessionVal: contact.profile_pic.url, nullIfEmpty: true)
|
||||
let profileResult: Profile = Profile(
|
||||
id: contactId,
|
||||
name: String(libSessionVal: contact.name),
|
||||
lastNameUpdate: (TimeInterval(latestConfigSentTimestampMs) / 1000),
|
||||
nickname: String(libSessionVal: contact.nickname, nullIfEmpty: true),
|
||||
profilePictureUrl: profilePictureUrl,
|
||||
profileEncryptionKey: (profilePictureUrl == nil ? nil :
|
||||
Data(
|
||||
libSessionVal: contact.profile_pic.key,
|
||||
count: ProfileManager.avatarAES256KeyByteLength
|
||||
)
|
||||
),
|
||||
lastProfilePictureUpdate: (TimeInterval(latestConfigSentTimestampMs) / 1000)
|
||||
)
|
||||
|
||||
contactData[contactId] = (
|
||||
contactResult,
|
||||
profileResult,
|
||||
contact.priority,
|
||||
TimeInterval(contact.created)
|
||||
)
|
||||
contacts_iterator_advance(contactIterator)
|
||||
}
|
||||
contacts_iterator_free(contactIterator) // Need to free the iterator
|
||||
|
||||
// The current users contact data is handled separately so exclude it if it's present (as that's
|
||||
// actually a bug)
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
let targetContactData: ContactData = contactData.filter { $0.key != userPublicKey }
|
||||
let targetContactData: [String: ContactData] = extractContacts(
|
||||
from: conf,
|
||||
latestConfigSentTimestampMs: latestConfigSentTimestampMs
|
||||
).filter { $0.key != userPublicKey }
|
||||
|
||||
// Since we don't sync 100% of the data stored against the contact and profile objects we
|
||||
// need to only update the data we do have to ensure we don't overwrite anything that doesn't
|
||||
|
@ -490,6 +443,121 @@ internal extension SessionUtil {
|
|||
|
||||
return updated
|
||||
}
|
||||
|
||||
// MARK: - Pruning
|
||||
|
||||
static func pruningIfNeeded(
|
||||
_ db: Database,
|
||||
conf: UnsafeMutablePointer<config_object>?
|
||||
) throws {
|
||||
// First make sure we are actually thowing the correct size constraint error (don't want to prune contacts
|
||||
// as a result of some other random error
|
||||
do {
|
||||
try CExceptionHelper.performSafely { config_push(conf).deallocate() }
|
||||
return // If we didn't error then no need to prune
|
||||
}
|
||||
catch {
|
||||
guard (error as NSError).userInfo["NSLocalizedDescription"] as? String == "Config data is too large" else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the contact data from the config
|
||||
var allContactData: [String: ContactData] = extractContacts(
|
||||
from: conf,
|
||||
latestConfigSentTimestampMs: 0
|
||||
)
|
||||
|
||||
// Remove the current user profile info (shouldn't be in there but just in case)
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
var cUserPublicKey: [CChar] = userPublicKey.cArray.nullTerminated()
|
||||
contacts_erase(conf, &cUserPublicKey)
|
||||
|
||||
/// Do the following in stages (we want to prune as few contacts as possible because we are essentially deleting data and removing these
|
||||
/// contacts will result in not just contact data but also associated conversation data for the contact being removed from linked devices
|
||||
///
|
||||
///
|
||||
/// **Step 1** First of all we want to try to detect spam-attacks (ie. if someone creates a bunch of accounts and messages you, and you
|
||||
/// systematically block every one of those accounts - this can quickly add up)
|
||||
///
|
||||
/// We will do this by filtering the contact data to only include blocked contacts, grouping those contacts into contacts created within the
|
||||
/// same hour and then only including groups that have more than 10 contacts (ie. if you blocked 20 users within an hour we expect those
|
||||
/// contacts were spammers)
|
||||
let blockSpamBatchingResolution: TimeInterval = (60 * 60)
|
||||
// TODO: Do we want to only do this case for contacts that were created over X time ago? (to avoid unintentionally unblocking accounts that were recently blocked
|
||||
let likelySpammerContacts: [ContactData] = allContactData
|
||||
.values
|
||||
.filter { $0.contact.isBlocked }
|
||||
.grouped(by: { $0.created / blockSpamBatchingResolution })
|
||||
.filter { _, values in values.count > 20 }
|
||||
.values
|
||||
.flatMap { $0 }
|
||||
|
||||
if !likelySpammerContacts.isEmpty {
|
||||
likelySpammerContacts.forEach { contact in
|
||||
var cSessionId: [CChar] = contact.contact.id.cArray.nullTerminated()
|
||||
contacts_erase(conf, &cSessionId)
|
||||
|
||||
allContactData.removeValue(forKey: contact.contact.id)
|
||||
}
|
||||
|
||||
// If we are no longer erroring then we can stop here
|
||||
do { return try CExceptionHelper.performSafely { config_push(conf).deallocate() } }
|
||||
catch {}
|
||||
}
|
||||
|
||||
/// We retrieve the `CONVO_INFO_VOLATILE` records and one-to-one conversation message counts as they will be relevant for subsequent checks
|
||||
let volatileThreadInfo: [String: VolatileThreadInfo] = SessionUtil
|
||||
.config(for: .convoInfoVolatile, publicKey: userPublicKey)
|
||||
.wrappedValue
|
||||
.map { SessionUtil.extractConvoVolatileInfo(from: $0) }
|
||||
.defaulting(to: [])
|
||||
.reduce(into: [:]) { result, next in result[next.threadId] = next }
|
||||
let conversationMessageCounts: [String: Int] = try SessionThread
|
||||
.filter(SessionThread.Columns.variant == SessionThread.Variant.contact)
|
||||
.select(.id)
|
||||
.annotated(with: SessionThread.interactions.count)
|
||||
.asRequest(of: ThreadCount.self)
|
||||
.fetchAll(db)
|
||||
.reduce(into: [:]) { result, next in result[next.id] = next.interactionCount }
|
||||
|
||||
/// **Step 2** Next up we want to remove contact records which are likely to be invalid, this means contacts which:
|
||||
/// - Aren't blocked
|
||||
/// - Have no `name` value
|
||||
/// - Have no `CONVO_INFO_VOLATILE` record
|
||||
/// - Have no messages in their one-to-one conversations
|
||||
///
|
||||
/// Any contacts that meet the above criteria are likely either invalid contacts or are contacts which the user hasn't seen or interacted
|
||||
/// with for 30+ days
|
||||
let likelyInvalidContacts: [ContactData] = allContactData
|
||||
.values
|
||||
.filter { !$0.contact.isBlocked }
|
||||
.filter { $0.profile.name.isEmpty }
|
||||
.filter { volatileThreadInfo[$0.contact.id] == nil }
|
||||
.filter { (conversationMessageCounts[$0.contact.id] ?? 0) == 0 }
|
||||
|
||||
if !likelyInvalidContacts.isEmpty {
|
||||
likelyInvalidContacts.forEach { contact in
|
||||
var cSessionId: [CChar] = contact.contact.id.cArray.nullTerminated()
|
||||
contacts_erase(conf, &cSessionId)
|
||||
|
||||
allContactData.removeValue(forKey: contact.contact.id)
|
||||
}
|
||||
|
||||
// If we are no longer erroring then we can stop here
|
||||
do { return try CExceptionHelper.performSafely { config_push(conf).deallocate() } }
|
||||
catch {}
|
||||
}
|
||||
|
||||
|
||||
// TODO: Exclude contacts that have no profile info(?)
|
||||
// TODO: Exclude contacts that have a CONVO_INFO_VOLATILE record
|
||||
// TODO: Exclude contacts that have a conversation with messages in the database (ie. only delete "empty" contacts)
|
||||
|
||||
// TODO: Start pruning valid contacts which have really old conversations...
|
||||
|
||||
print("RAWR")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - External Outgoing Changes
|
||||
|
@ -555,3 +623,71 @@ extension SessionUtil {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ContactData
|
||||
|
||||
private struct ContactData {
|
||||
let contact: Contact
|
||||
let profile: Profile
|
||||
let priority: Int32
|
||||
let created: TimeInterval
|
||||
}
|
||||
|
||||
// MARK: - ThreadCount
|
||||
|
||||
private struct ThreadCount: Codable, FetchableRecord {
|
||||
let id: String
|
||||
let interactionCount: Int
|
||||
}
|
||||
|
||||
// MARK: - Convenience
|
||||
|
||||
private extension SessionUtil {
|
||||
static func extractContacts(
|
||||
from conf: UnsafeMutablePointer<config_object>?,
|
||||
latestConfigSentTimestampMs: Int64
|
||||
) -> [String: ContactData] {
|
||||
var result: [String: ContactData] = [:]
|
||||
var contact: contacts_contact = contacts_contact()
|
||||
let contactIterator: UnsafeMutablePointer<contacts_iterator> = contacts_iterator_new(conf)
|
||||
|
||||
while !contacts_iterator_done(contactIterator, &contact) {
|
||||
let contactId: String = String(cString: withUnsafeBytes(of: contact.session_id) { [UInt8]($0) }
|
||||
.map { CChar($0) }
|
||||
.nullTerminated()
|
||||
)
|
||||
let contactResult: Contact = Contact(
|
||||
id: contactId,
|
||||
isApproved: contact.approved,
|
||||
isBlocked: contact.blocked,
|
||||
didApproveMe: contact.approved_me
|
||||
)
|
||||
let profilePictureUrl: String? = String(libSessionVal: contact.profile_pic.url, nullIfEmpty: true)
|
||||
let profileResult: Profile = Profile(
|
||||
id: contactId,
|
||||
name: String(libSessionVal: contact.name),
|
||||
lastNameUpdate: (TimeInterval(latestConfigSentTimestampMs) / 1000),
|
||||
nickname: String(libSessionVal: contact.nickname, nullIfEmpty: true),
|
||||
profilePictureUrl: profilePictureUrl,
|
||||
profileEncryptionKey: (profilePictureUrl == nil ? nil :
|
||||
Data(
|
||||
libSessionVal: contact.profile_pic.key,
|
||||
count: ProfileManager.avatarAES256KeyByteLength
|
||||
)
|
||||
),
|
||||
lastProfilePictureUpdate: (TimeInterval(latestConfigSentTimestampMs) / 1000)
|
||||
)
|
||||
|
||||
result[contactId] = ContactData(
|
||||
contact: contactResult,
|
||||
profile: profileResult,
|
||||
priority: contact.priority,
|
||||
created: TimeInterval(contact.created)
|
||||
)
|
||||
contacts_iterator_advance(contactIterator)
|
||||
}
|
||||
contacts_iterator_free(contactIterator) // Need to free the iterator
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,71 +22,8 @@ internal extension SessionUtil {
|
|||
guard mergeNeedsDump else { return }
|
||||
guard conf != nil else { throw SessionUtilError.nilConfigObject }
|
||||
|
||||
var volatileThreadInfo: [VolatileThreadInfo] = []
|
||||
var oneToOne: convo_info_volatile_1to1 = convo_info_volatile_1to1()
|
||||
var community: convo_info_volatile_community = convo_info_volatile_community()
|
||||
var legacyGroup: convo_info_volatile_legacy_group = convo_info_volatile_legacy_group()
|
||||
let convoIterator: OpaquePointer = convo_info_volatile_iterator_new(conf)
|
||||
|
||||
while !convo_info_volatile_iterator_done(convoIterator) {
|
||||
if convo_info_volatile_it_is_1to1(convoIterator, &oneToOne) {
|
||||
volatileThreadInfo.append(
|
||||
VolatileThreadInfo(
|
||||
threadId: String(libSessionVal: oneToOne.session_id),
|
||||
variant: .contact,
|
||||
changes: [
|
||||
.markedAsUnread(oneToOne.unread),
|
||||
.lastReadTimestampMs(oneToOne.last_read)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
else if convo_info_volatile_it_is_community(convoIterator, &community) {
|
||||
let server: String = String(libSessionVal: community.base_url)
|
||||
let roomToken: String = String(libSessionVal: community.room)
|
||||
let publicKey: String = Data(
|
||||
libSessionVal: community.pubkey,
|
||||
count: OpenGroup.pubkeyByteLength
|
||||
).toHexString()
|
||||
|
||||
volatileThreadInfo.append(
|
||||
VolatileThreadInfo(
|
||||
threadId: OpenGroup.idFor(roomToken: roomToken, server: server),
|
||||
variant: .community,
|
||||
openGroupUrlInfo: OpenGroupUrlInfo(
|
||||
threadId: OpenGroup.idFor(roomToken: roomToken, server: server),
|
||||
server: server,
|
||||
roomToken: roomToken,
|
||||
publicKey: publicKey
|
||||
),
|
||||
changes: [
|
||||
.markedAsUnread(community.unread),
|
||||
.lastReadTimestampMs(community.last_read)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
else if convo_info_volatile_it_is_legacy_group(convoIterator, &legacyGroup) {
|
||||
volatileThreadInfo.append(
|
||||
VolatileThreadInfo(
|
||||
threadId: String(libSessionVal: legacyGroup.group_id),
|
||||
variant: .legacyGroup,
|
||||
changes: [
|
||||
.markedAsUnread(legacyGroup.unread),
|
||||
.lastReadTimestampMs(legacyGroup.last_read)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
else {
|
||||
SNLog("Ignoring unknown conversation type when iterating through volatile conversation info update")
|
||||
}
|
||||
|
||||
convo_info_volatile_iterator_advance(convoIterator)
|
||||
}
|
||||
convo_info_volatile_iterator_free(convoIterator) // Need to free the iterator
|
||||
|
||||
// Get the local volatile thread info from all conversations
|
||||
// Get the volatile thread info from the conf and local conversations
|
||||
let volatileThreadInfo: [VolatileThreadInfo] = extractConvoVolatileInfo(from: conf)
|
||||
let localVolatileThreadInfo: [String: VolatileThreadInfo] = VolatileThreadInfo.fetchAll(db)
|
||||
.reduce(into: [:]) { result, next in result[next.threadId] = next }
|
||||
|
||||
|
@ -572,6 +509,76 @@ public extension SessionUtil {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static func extractConvoVolatileInfo(
|
||||
from conf: UnsafeMutablePointer<config_object>?
|
||||
) -> [VolatileThreadInfo] {
|
||||
var result: [VolatileThreadInfo] = []
|
||||
var oneToOne: convo_info_volatile_1to1 = convo_info_volatile_1to1()
|
||||
var community: convo_info_volatile_community = convo_info_volatile_community()
|
||||
var legacyGroup: convo_info_volatile_legacy_group = convo_info_volatile_legacy_group()
|
||||
let convoIterator: OpaquePointer = convo_info_volatile_iterator_new(conf)
|
||||
|
||||
while !convo_info_volatile_iterator_done(convoIterator) {
|
||||
if convo_info_volatile_it_is_1to1(convoIterator, &oneToOne) {
|
||||
result.append(
|
||||
VolatileThreadInfo(
|
||||
threadId: String(libSessionVal: oneToOne.session_id),
|
||||
variant: .contact,
|
||||
changes: [
|
||||
.markedAsUnread(oneToOne.unread),
|
||||
.lastReadTimestampMs(oneToOne.last_read)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
else if convo_info_volatile_it_is_community(convoIterator, &community) {
|
||||
let server: String = String(libSessionVal: community.base_url)
|
||||
let roomToken: String = String(libSessionVal: community.room)
|
||||
let publicKey: String = Data(
|
||||
libSessionVal: community.pubkey,
|
||||
count: OpenGroup.pubkeyByteLength
|
||||
).toHexString()
|
||||
|
||||
result.append(
|
||||
VolatileThreadInfo(
|
||||
threadId: OpenGroup.idFor(roomToken: roomToken, server: server),
|
||||
variant: .community,
|
||||
openGroupUrlInfo: OpenGroupUrlInfo(
|
||||
threadId: OpenGroup.idFor(roomToken: roomToken, server: server),
|
||||
server: server,
|
||||
roomToken: roomToken,
|
||||
publicKey: publicKey
|
||||
),
|
||||
changes: [
|
||||
.markedAsUnread(community.unread),
|
||||
.lastReadTimestampMs(community.last_read)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
else if convo_info_volatile_it_is_legacy_group(convoIterator, &legacyGroup) {
|
||||
result.append(
|
||||
VolatileThreadInfo(
|
||||
threadId: String(libSessionVal: legacyGroup.group_id),
|
||||
variant: .legacyGroup,
|
||||
changes: [
|
||||
.markedAsUnread(legacyGroup.unread),
|
||||
.lastReadTimestampMs(legacyGroup.last_read)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
else {
|
||||
SNLog("Ignoring unknown conversation type when iterating through volatile conversation info update")
|
||||
}
|
||||
|
||||
convo_info_volatile_iterator_advance(convoIterator)
|
||||
}
|
||||
convo_info_volatile_iterator_free(convoIterator) // Need to free the iterator
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension [SessionUtil.VolatileThreadInfo.Change] {
|
||||
|
@ -597,3 +604,4 @@ fileprivate extension [SessionUtil.VolatileThreadInfo.Change] {
|
|||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import Sodium
|
||||
import SessionUtil
|
||||
import SessionUtilitiesKit
|
||||
|
@ -8,12 +9,248 @@ import SessionUtilitiesKit
|
|||
import Quick
|
||||
import Nimble
|
||||
|
||||
@testable import SessionMessagingKit
|
||||
|
||||
/// This spec is designed to replicate the initial test cases for the libSession-util to ensure the behaviour matches
|
||||
class ConfigContactsSpec {
|
||||
enum ContactProperty: CaseIterable {
|
||||
case name
|
||||
case nickname
|
||||
case approved
|
||||
case approved_me
|
||||
case blocked
|
||||
case profile_pic
|
||||
case created
|
||||
case notifications
|
||||
case mute_until
|
||||
}
|
||||
|
||||
// MARK: - Spec
|
||||
|
||||
static func spec() {
|
||||
it("generates Contact configs correctly") {
|
||||
context("CONTACTS") {
|
||||
// MARK: - when checking error catching
|
||||
context("when checking error catching") {
|
||||
var seed: Data!
|
||||
var identity: (ed25519KeyPair: KeyPair, x25519KeyPair: KeyPair)!
|
||||
var edSK: [UInt8]!
|
||||
var error: UnsafeMutablePointer<CChar>?
|
||||
var conf: UnsafeMutablePointer<config_object>?
|
||||
|
||||
beforeEach {
|
||||
seed = Data(hex: "0123456789abcdef0123456789abcdef")
|
||||
|
||||
// FIXME: Would be good to move these into the libSession-util instead of using Sodium separately
|
||||
identity = try! Identity.generate(from: seed)
|
||||
edSK = identity.ed25519KeyPair.secretKey
|
||||
|
||||
// Initialize a brand new, empty config because we have no dump data to deal with.
|
||||
error = nil
|
||||
conf = nil
|
||||
_ = contacts_init(&conf, &edSK, nil, 0, error)
|
||||
error?.deallocate()
|
||||
}
|
||||
|
||||
// MARK: -- it can catch size limit errors thrown when pushing
|
||||
it("can catch size limit errors thrown when pushing") {
|
||||
try (0..<10000).forEach { index in
|
||||
var contact: contacts_contact = try createContact(for: index, in: conf, maxing: .allProperties)
|
||||
contacts_set(conf, &contact)
|
||||
}
|
||||
|
||||
expect(contacts_size(conf)).to(equal(10000))
|
||||
expect(config_needs_push(conf)).to(beTrue())
|
||||
expect(config_needs_dump(conf)).to(beTrue())
|
||||
|
||||
expect {
|
||||
try CExceptionHelper.performSafely { config_push(conf).deallocate() }
|
||||
}
|
||||
.to(throwError(NSError(domain: "cpp_exception", code: -2, userInfo: ["NSLocalizedDescription": "Config data is too large"])))
|
||||
}
|
||||
|
||||
// MARK: -- can catch size limit errors thrown when dumping
|
||||
it("can catch size limit errors thrown when dumping") {
|
||||
try (0..<10000).forEach { index in
|
||||
var contact: contacts_contact = try createContact(for: index, in: conf, maxing: .allProperties)
|
||||
contacts_set(conf, &contact)
|
||||
}
|
||||
|
||||
expect(contacts_size(conf)).to(equal(10000))
|
||||
expect(config_needs_push(conf)).to(beTrue())
|
||||
expect(config_needs_dump(conf)).to(beTrue())
|
||||
|
||||
expect {
|
||||
try CExceptionHelper.performSafely {
|
||||
var dump: UnsafeMutablePointer<UInt8>? = nil
|
||||
var dumpLen: Int = 0
|
||||
config_dump(conf, &dump, &dumpLen)
|
||||
dump?.deallocate()
|
||||
}
|
||||
}
|
||||
.to(throwError(NSError(domain: "cpp_exception", code: -2, userInfo: ["NSLocalizedDescription": "Config data is too large"])))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - when checking size limits
|
||||
context("when checking size limits") {
|
||||
var numRecords: Int!
|
||||
var seed: Data!
|
||||
var identity: (ed25519KeyPair: KeyPair, x25519KeyPair: KeyPair)!
|
||||
var edSK: [UInt8]!
|
||||
var error: UnsafeMutablePointer<CChar>?
|
||||
var conf: UnsafeMutablePointer<config_object>?
|
||||
|
||||
beforeEach {
|
||||
numRecords = 0
|
||||
seed = Data(hex: "0123456789abcdef0123456789abcdef")
|
||||
|
||||
// FIXME: Would be good to move these into the libSession-util instead of using Sodium separately
|
||||
identity = try! Identity.generate(from: seed)
|
||||
edSK = identity.ed25519KeyPair.secretKey
|
||||
|
||||
// Initialize a brand new, empty config because we have no dump data to deal with.
|
||||
error = nil
|
||||
conf = nil
|
||||
_ = contacts_init(&conf, &edSK, nil, 0, error)
|
||||
error?.deallocate()
|
||||
}
|
||||
|
||||
// MARK: -- has not changed the max empty records
|
||||
it("has not changed the max empty records") {
|
||||
for index in (0..<10000) {
|
||||
var contact: contacts_contact = try createContact(for: index, in: conf)
|
||||
contacts_set(conf, &contact)
|
||||
|
||||
do { try CExceptionHelper.performSafely { config_push(conf).deallocate() } }
|
||||
catch { break }
|
||||
|
||||
// We successfully inserted a contact and didn't hit the limit so increment the counter
|
||||
numRecords += 1
|
||||
}
|
||||
|
||||
// Check that the record count matches the maximum when we last checked
|
||||
expect(numRecords).to(equal(1775))
|
||||
}
|
||||
|
||||
// MARK: -- has not changed the max name only records
|
||||
it("has not changed the max name only records") {
|
||||
for index in (0..<10000) {
|
||||
var contact: contacts_contact = try createContact(for: index, in: conf, maxing: [.name])
|
||||
contacts_set(conf, &contact)
|
||||
|
||||
do { try CExceptionHelper.performSafely { config_push(conf).deallocate() } }
|
||||
catch { break }
|
||||
|
||||
// We successfully inserted a contact and didn't hit the limit so increment the counter
|
||||
numRecords += 1
|
||||
}
|
||||
|
||||
// Check that the record count matches the maximum when we last checked
|
||||
expect(numRecords).to(equal(526))
|
||||
}
|
||||
|
||||
// MARK: -- has not changed the max name and profile pic only records
|
||||
it("has not changed the max name and profile pic only records") {
|
||||
for index in (0..<10000) {
|
||||
var contact: contacts_contact = try createContact(for: index, in: conf, maxing: [.name, .profile_pic])
|
||||
contacts_set(conf, &contact)
|
||||
|
||||
do { try CExceptionHelper.performSafely { config_push(conf).deallocate() } }
|
||||
catch { break }
|
||||
|
||||
// We successfully inserted a contact and didn't hit the limit so increment the counter
|
||||
numRecords += 1
|
||||
}
|
||||
|
||||
// Check that the record count matches the maximum when we last checked
|
||||
expect(numRecords).to(equal(184))
|
||||
}
|
||||
|
||||
// MARK: -- has not changed the max filled records
|
||||
it("has not changed the max filled records") {
|
||||
for index in (0..<10000) {
|
||||
var contact: contacts_contact = try createContact(for: index, in: conf, maxing: .allProperties)
|
||||
contacts_set(conf, &contact)
|
||||
|
||||
do { try CExceptionHelper.performSafely { config_push(conf).deallocate() } }
|
||||
catch { break }
|
||||
|
||||
// We successfully inserted a contact and didn't hit the limit so increment the counter
|
||||
numRecords += 1
|
||||
}
|
||||
|
||||
// Check that the record count matches the maximum when we last checked
|
||||
expect(numRecords).to(equal(134))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - when pruning
|
||||
context("when pruning") {
|
||||
var mockStorage: Storage!
|
||||
var seed: Data!
|
||||
var identity: (ed25519KeyPair: KeyPair, x25519KeyPair: KeyPair)!
|
||||
var edSK: [UInt8]!
|
||||
var error: UnsafeMutablePointer<CChar>?
|
||||
var conf: UnsafeMutablePointer<config_object>?
|
||||
|
||||
beforeEach {
|
||||
mockStorage = Storage(
|
||||
customWriter: try! DatabaseQueue(),
|
||||
customMigrations: [
|
||||
SNUtilitiesKit.migrations(),
|
||||
SNMessagingKit.migrations()
|
||||
]
|
||||
)
|
||||
seed = Data(hex: "0123456789abcdef0123456789abcdef")
|
||||
|
||||
// FIXME: Would be good to move these into the libSession-util instead of using Sodium separately
|
||||
identity = try! Identity.generate(from: seed)
|
||||
edSK = identity.ed25519KeyPair.secretKey
|
||||
|
||||
// Initialize a brand new, empty config because we have no dump data to deal with.
|
||||
error = nil
|
||||
conf = nil
|
||||
_ = contacts_init(&conf, &edSK, nil, 0, error)
|
||||
error?.deallocate()
|
||||
}
|
||||
|
||||
it("does something") {
|
||||
mockStorage.write { db in
|
||||
try SessionThread.fetchOrCreate(db, id: "1", variant: .contact, shouldBeVisible: true)
|
||||
try SessionThread.fetchOrCreate(db, id: "2", variant: .contact, shouldBeVisible: true)
|
||||
try SessionThread.fetchOrCreate(db, id: "3", variant: .contact, shouldBeVisible: true)
|
||||
_ = try Interaction(
|
||||
threadId: "1",
|
||||
authorId: "1",
|
||||
variant: .standardIncoming,
|
||||
body: "Test1"
|
||||
).inserted(db)
|
||||
_ = try Interaction(
|
||||
threadId: "1",
|
||||
authorId: "2",
|
||||
variant: .standardIncoming,
|
||||
body: "Test2"
|
||||
).inserted(db)
|
||||
_ = try Interaction(
|
||||
threadId: "3",
|
||||
authorId: "3",
|
||||
variant: .standardIncoming,
|
||||
body: "Test3"
|
||||
).inserted(db)
|
||||
|
||||
try SessionUtil.pruningIfNeeded(
|
||||
db,
|
||||
conf: conf
|
||||
)
|
||||
|
||||
expect(contacts_size(conf)).to(equal(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - generates config correctly
|
||||
|
||||
it("generates config correctly") {
|
||||
let createdTs: Int64 = 1680064059
|
||||
let nowTs: Int64 = Int64(Date().timeIntervalSince1970)
|
||||
let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef")
|
||||
|
@ -303,4 +540,70 @@ class ConfigContactsSpec {
|
|||
expect(nicknames2.last).to(equal("Nickname 3"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience
|
||||
|
||||
private static func createContact(
|
||||
for index: Int,
|
||||
in conf: UnsafeMutablePointer<config_object>?,
|
||||
maxing properties: [ContactProperty] = []
|
||||
) throws -> contacts_contact {
|
||||
let postPrefixId: String = "050000000000000000000000000000000000000000000000000000000000000000"
|
||||
let sessionId: String = ("05\(index)a" + postPrefixId.suffix(postPrefixId.count - "05\(index)a".count))
|
||||
var cSessionId: [CChar] = sessionId.cArray.nullTerminated()
|
||||
var contact: contacts_contact = contacts_contact()
|
||||
|
||||
guard contacts_get_or_construct(conf, &contact, &cSessionId) else {
|
||||
throw SessionUtilError.getOrConstructFailedUnexpectedly
|
||||
}
|
||||
|
||||
// Set the values to the maximum data that can fit
|
||||
properties.forEach { property in
|
||||
switch property {
|
||||
case .approved: contact.approved = true
|
||||
case .approved_me: contact.approved_me = true
|
||||
case .blocked: contact.blocked = true
|
||||
case .created: contact.created = Int64.max
|
||||
case .notifications: contact.notifications = CONVO_NOTIFY_MENTIONS_ONLY
|
||||
case .mute_until: contact.mute_until = Int64.max
|
||||
|
||||
case .name:
|
||||
contact.name = String(
|
||||
data: Data(
|
||||
repeating: "A".data(using: .utf8)![0],
|
||||
count: SessionUtil.libSessionMaxNameByteLength
|
||||
),
|
||||
encoding: .utf8
|
||||
).toLibSession()
|
||||
|
||||
case .nickname:
|
||||
contact.nickname = String(
|
||||
data: Data(
|
||||
repeating: "A".data(using: .utf8)![0],
|
||||
count: SessionUtil.libSessionMaxNameByteLength
|
||||
),
|
||||
encoding: .utf8
|
||||
).toLibSession()
|
||||
|
||||
case .profile_pic:
|
||||
contact.profile_pic = user_profile_pic(
|
||||
url: String(
|
||||
data: Data(
|
||||
repeating: "A".data(using: .utf8)![0],
|
||||
count: SessionUtil.libSessionMaxProfileUrlByteLength
|
||||
),
|
||||
encoding: .utf8
|
||||
).toLibSession(),
|
||||
key: "qwerty78901234567890123456789012".data(using: .utf8)!.toLibSession()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return contact
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension Array where Element == ConfigContactsSpec.ContactProperty {
|
||||
static var allProperties: [ConfigContactsSpec.ContactProperty] = ConfigContactsSpec.ContactProperty.allCases
|
||||
}
|
||||
|
|
|
@ -13,7 +13,8 @@ class ConfigConvoInfoVolatileSpec {
|
|||
// MARK: - Spec
|
||||
|
||||
static func spec() {
|
||||
it("generates ConvoInfoVolatile configs correctly") {
|
||||
context("CONVO_INFO_VOLATILE") {
|
||||
it("generates config correctly") {
|
||||
let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef")
|
||||
|
||||
// FIXME: Would be good to move these into the libSession-util instead of using Sodium separately
|
||||
|
@ -262,4 +263,5 @@ class ConfigConvoInfoVolatileSpec {
|
|||
]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -83,7 +83,8 @@ class ConfigUserGroupsSpec {
|
|||
.to(equal("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"))
|
||||
}
|
||||
|
||||
it("generates UserGroup configs correctly") {
|
||||
context("USER_GROUPS") {
|
||||
it("generates config correctly") {
|
||||
let createdTs: Int64 = 1680064059
|
||||
let nowTs: Int64 = Int64(Date().timeIntervalSince1970)
|
||||
let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef")
|
||||
|
@ -584,4 +585,5 @@ class ConfigUserGroupsSpec {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,8 @@ class ConfigUserProfileSpec {
|
|||
// MARK: - Spec
|
||||
|
||||
static func spec() {
|
||||
it("generates UserProfile configs correctly") {
|
||||
context("USER_PROFILE") {
|
||||
it("generates config correctly") {
|
||||
let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef")
|
||||
|
||||
// FIXME: Would be good to move these into the libSession-util instead of using Sodium separately
|
||||
|
@ -394,4 +395,5 @@ class ConfigUserProfileSpec {
|
|||
conf2?.deallocate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,9 +12,11 @@ class LibSessionSpec: QuickSpec {
|
|||
// MARK: - Spec
|
||||
|
||||
override func spec() {
|
||||
describe("libSession") {
|
||||
ConfigContactsSpec.spec()
|
||||
ConfigUserProfileSpec.spec()
|
||||
ConfigConvoInfoVolatileSpec.spec()
|
||||
ConfigUserGroupsSpec.spec()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -53,12 +53,16 @@ class ThreadSettingsViewModelSpec: QuickSpec {
|
|||
|
||||
try Profile(
|
||||
id: "05\(TestConstants.publicKey)",
|
||||
name: "TestMe"
|
||||
name: "TestMe",
|
||||
lastNameUpdate: 0,
|
||||
lastProfilePictureUpdate: 0
|
||||
).insert(db)
|
||||
|
||||
try Profile(
|
||||
id: "TestId",
|
||||
name: "TestUser"
|
||||
name: "TestUser",
|
||||
lastNameUpdate: 0,
|
||||
lastProfilePictureUpdate: 0
|
||||
).insert(db)
|
||||
}
|
||||
viewModel = ThreadSettingsViewModel(
|
||||
|
|
|
@ -4,15 +4,38 @@ import UIKit
|
|||
import SessionUtilitiesKit
|
||||
|
||||
// FIXME: Refactor as part of the Groups Rebuild
|
||||
public class ConfirmationModal: Modal {
|
||||
public class ConfirmationModal: Modal, UITextFieldDelegate {
|
||||
private static let closeSize: CGFloat = 24
|
||||
|
||||
private var internalOnConfirm: ((ConfirmationModal) -> ())? = nil
|
||||
private var internalOnCancel: ((ConfirmationModal) -> ())? = nil
|
||||
private var internalOnBodyTap: (() -> ())? = nil
|
||||
private var internalOnTextChanged: ((String) -> ())? = nil
|
||||
|
||||
// MARK: - Components
|
||||
|
||||
private lazy var contentTapGestureRecognizer: UITapGestureRecognizer = {
|
||||
let result: UITapGestureRecognizer = UITapGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(contentViewTapped)
|
||||
)
|
||||
contentView.addGestureRecognizer(result)
|
||||
result.isEnabled = false
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var imageViewTapGestureRecognizer: UITapGestureRecognizer = {
|
||||
let result: UITapGestureRecognizer = UITapGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(imageViewTapped)
|
||||
)
|
||||
imageViewContainer.addGestureRecognizer(result)
|
||||
result.isEnabled = false
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var titleLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
|
@ -36,16 +59,30 @@ public class ConfirmationModal: Modal {
|
|||
return result
|
||||
}()
|
||||
|
||||
private lazy var textFieldContainer: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.themeBorderColor = .borderSeparator
|
||||
result.layer.cornerRadius = 11
|
||||
result.layer.borderWidth = 1
|
||||
result.isHidden = true
|
||||
result.set(.height, to: 40)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var textField: UITextField = {
|
||||
let result: UITextField = UITextField()
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.themeTextColor = .textPrimary
|
||||
result.delegate = self
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var imageViewContainer: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.isHidden = true
|
||||
|
||||
let gestureRecogniser: UITapGestureRecognizer = UITapGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(imageViewTapped)
|
||||
)
|
||||
result.addGestureRecognizer(gestureRecogniser)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
|
@ -70,7 +107,7 @@ public class ConfirmationModal: Modal {
|
|||
}()
|
||||
|
||||
private lazy var contentStackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ titleLabel, explanationLabel, imageViewContainer ])
|
||||
let result = UIStackView(arrangedSubviews: [ titleLabel, explanationLabel, textFieldContainer, imageViewContainer ])
|
||||
result.axis = .vertical
|
||||
result.spacing = Values.smallSpacing
|
||||
result.isLayoutMarginsRelativeArrangement = true
|
||||
|
@ -132,13 +169,22 @@ public class ConfirmationModal: Modal {
|
|||
}
|
||||
|
||||
public override func populateContentView() {
|
||||
let gestureRecogniser: UITapGestureRecognizer = UITapGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(contentViewTapped)
|
||||
)
|
||||
contentView.addGestureRecognizer(gestureRecogniser)
|
||||
|
||||
contentView.addSubview(mainStackView)
|
||||
contentView.addSubview(closeButton)
|
||||
|
||||
textFieldContainer.addSubview(textField)
|
||||
textField.pin(to: textFieldContainer, withInset: 12)
|
||||
|
||||
imageViewContainer.addSubview(profileView)
|
||||
profileView.center(.horizontal, in: imageViewContainer)
|
||||
profileView.pin(.top, to: .top, of: imageViewContainer)//, withInset: 15)
|
||||
profileView.pin(.bottom, to: .bottom, of: imageViewContainer)//, withInset: -15)
|
||||
profileView.pin(.top, to: .top, of: imageViewContainer)
|
||||
profileView.pin(.bottom, to: .bottom, of: imageViewContainer)
|
||||
|
||||
mainStackView.pin(to: contentView)
|
||||
closeButton.pin(.top, to: .top, of: contentView, withInset: 8)
|
||||
|
@ -149,6 +195,7 @@ public class ConfirmationModal: Modal {
|
|||
|
||||
public func updateContent(with info: Info) {
|
||||
internalOnBodyTap = nil
|
||||
internalOnTextChanged = nil
|
||||
internalOnConfirm = { modal in
|
||||
if info.dismissOnConfirm {
|
||||
modal.close()
|
||||
|
@ -161,6 +208,8 @@ public class ConfirmationModal: Modal {
|
|||
|
||||
info.onCancel?(modal)
|
||||
}
|
||||
contentTapGestureRecognizer.isEnabled = true
|
||||
imageViewTapGestureRecognizer.isEnabled = false
|
||||
|
||||
// Set the content based on the provided info
|
||||
titleLabel.text = info.title
|
||||
|
@ -179,6 +228,15 @@ public class ConfirmationModal: Modal {
|
|||
explanationLabel.attributedText = attributedText
|
||||
explanationLabel.isHidden = false
|
||||
|
||||
case .input(let explanation, let placeholder, let value, let clearButton, let onTextChanged):
|
||||
explanationLabel.attributedText = explanation
|
||||
explanationLabel.isHidden = (explanation == nil)
|
||||
textField.placeholder = placeholder
|
||||
textField.text = (value ?? "")
|
||||
textField.clearButtonMode = (clearButton ? .always : .never)
|
||||
textFieldContainer.isHidden = false
|
||||
internalOnTextChanged = onTextChanged
|
||||
|
||||
case .image(let placeholder, let value, let icon, let style, let accessibility, let onClick):
|
||||
imageViewContainer.isAccessibilityElement = (accessibility != nil)
|
||||
imageViewContainer.accessibilityIdentifier = accessibility?.identifier
|
||||
|
@ -193,6 +251,8 @@ public class ConfirmationModal: Modal {
|
|||
)
|
||||
)
|
||||
internalOnBodyTap = onClick
|
||||
contentTapGestureRecognizer.isEnabled = false
|
||||
imageViewTapGestureRecognizer.isEnabled = true
|
||||
}
|
||||
|
||||
confirmButton.accessibilityLabel = info.confirmAccessibility?.label
|
||||
|
@ -216,8 +276,38 @@ public class ConfirmationModal: Modal {
|
|||
contentView.accessibilityIdentifier = info.accessibility?.identifier
|
||||
}
|
||||
|
||||
// MARK: - UITextFieldDelegate
|
||||
|
||||
public func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
textField.resignFirstResponder()
|
||||
return true
|
||||
}
|
||||
|
||||
public func textFieldShouldClear(_ textField: UITextField) -> Bool {
|
||||
internalOnTextChanged?("")
|
||||
return true
|
||||
}
|
||||
|
||||
public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
|
||||
if let text: String = textField.text, let textRange: Range = Range(range, in: text) {
|
||||
let updatedText = text.replacingCharacters(in: textRange, with: string)
|
||||
|
||||
internalOnTextChanged?(updatedText)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func contentViewTapped() {
|
||||
if textField.isFirstResponder {
|
||||
textField.resignFirstResponder()
|
||||
}
|
||||
|
||||
internalOnBodyTap?()
|
||||
}
|
||||
|
||||
@objc private func imageViewTapped() {
|
||||
internalOnBodyTap?()
|
||||
}
|
||||
|
@ -400,8 +490,14 @@ public extension ConfirmationModal.Info {
|
|||
case none
|
||||
case text(String)
|
||||
case attributedText(NSAttributedString)
|
||||
// FIXME: Implement these
|
||||
// case input(placeholder: String, value: String?)
|
||||
case input(
|
||||
explanation: NSAttributedString?,
|
||||
placeholder: String,
|
||||
initialValue: String?,
|
||||
clearButton: Bool,
|
||||
onChange: (String) -> ()
|
||||
)
|
||||
// FIXME: Implement this
|
||||
// case radio(explanation: NSAttributedString?, options: [(title: String, selected: Bool)])
|
||||
case image(
|
||||
placeholderData: Data?,
|
||||
|
@ -418,14 +514,15 @@ public extension ConfirmationModal.Info {
|
|||
case (.text(let lhsText), .text(let rhsText)): return (lhsText == rhsText)
|
||||
case (.attributedText(let lhsText), .attributedText(let rhsText)): return (lhsText == rhsText)
|
||||
|
||||
// FIXME: Implement these
|
||||
//case (.input(let lhsPlaceholder, let lhsValue), .input(let rhsPlaceholder, let rhsValue)):
|
||||
// return (
|
||||
// lhsPlaceholder == rhsPlaceholder &&
|
||||
// lhsValue == rhsValue &&
|
||||
// )
|
||||
case (.input(let lhsExplanation, let lhsPlaceholder, let lhsInitialValue, let lhsClearButton, _), .input(let rhsExplanation, let rhsPlaceholder, let rhsInitialValue, let rhsClearButton, _)):
|
||||
return (
|
||||
lhsExplanation == rhsExplanation &&
|
||||
lhsPlaceholder == rhsPlaceholder &&
|
||||
lhsInitialValue == rhsInitialValue &&
|
||||
lhsClearButton == rhsClearButton
|
||||
)
|
||||
|
||||
// FIXME: Implement these
|
||||
// FIXME: Implement this
|
||||
//case (.radio(let lhsExplanation, let lhsOptions), .radio(let rhsExplanation, let rhsOptions)):
|
||||
// return (
|
||||
// lhsExplanation == rhsExplanation &&
|
||||
|
@ -451,6 +548,12 @@ public extension ConfirmationModal.Info {
|
|||
case .text(let text): text.hash(into: &hasher)
|
||||
case .attributedText(let text): text.hash(into: &hasher)
|
||||
|
||||
case .input(let explanation, let placeholder, let initialValue, let clearButton, _):
|
||||
explanation.hash(into: &hasher)
|
||||
placeholder.hash(into: &hasher)
|
||||
initialValue.hash(into: &hasher)
|
||||
clearButton.hash(into: &hasher)
|
||||
|
||||
case .image(let placeholder, let value, let icon, let style, let accessibility, _):
|
||||
placeholder.hash(into: &hasher)
|
||||
value.hash(into: &hasher)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import Combine
|
||||
import GRDB
|
||||
import SignalCoreKit
|
||||
|
@ -492,3 +493,38 @@ public extension ValueObservation {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Debug Convenience
|
||||
|
||||
#if DEBUG
|
||||
public extension Storage {
|
||||
func exportInfo(password: String) throws -> (dbPath: String, keyPath: String) {
|
||||
var keySpec: Data = try Storage.getOrGenerateDatabaseKeySpec()
|
||||
defer { keySpec.resetBytes(in: 0..<keySpec.count) } // Reset content immediately after use
|
||||
|
||||
guard var passwordData: Data = password.data(using: .utf8) else { throw StorageError.generic }
|
||||
defer { passwordData.resetBytes(in: 0..<passwordData.count) } // Reset content immediately after use
|
||||
|
||||
/// Encrypt the `keySpec` value using a SHA256 of the password provided and a nonce then base64-encode the encrypted
|
||||
/// data and save it to a temporary file to share alongside the database
|
||||
///
|
||||
/// Decrypt the key via the termincal on macOS by running the command in the project root directory
|
||||
/// `swift ./Scropts/DecryptExportedKey.swift {BASE64_CIPHERTEXT} {PASSWORD}`
|
||||
///
|
||||
/// Where `BASE64_CIPHERTEXT` is the content of the `key.enc` file and `PASSWORD` is the password provided via the
|
||||
/// prompt during export
|
||||
let nonce: ChaChaPoly.Nonce = ChaChaPoly.Nonce()
|
||||
let hash: SHA256.Digest = SHA256.hash(data: passwordData)
|
||||
let key: SymmetricKey = SymmetricKey(data: Data(hash.makeIterator()))
|
||||
let sealedBox: ChaChaPoly.SealedBox = try ChaChaPoly.seal(keySpec, using: key, nonce: nonce, authenticating: Data())
|
||||
let keyInfoPath: String = "\(NSTemporaryDirectory())key.enc"
|
||||
let encryptedKeyBase64: String = sealedBox.combined.base64EncodedString()
|
||||
try encryptedKeyBase64.write(toFile: keyInfoPath, atomically: true, encoding: .utf8)
|
||||
|
||||
return (
|
||||
Storage.databasePath,
|
||||
keyInfoPath
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
@ -16,4 +16,5 @@ FOUNDATION_EXPORT const unsigned char SessionUtilitiesKitVersionString[];
|
|||
#import <SessionUtilitiesKit/UIImage+OWS.h>
|
||||
#import <SessionUtilitiesKit/UIView+OWS.h>
|
||||
#import <SessionUtilitiesKit/OWSBackgroundTask.h>
|
||||
#import <SessionUtilitiesKit/CExceptionHelper.h>
|
||||
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
#ifndef __CExceptionHelper_h__
|
||||
#define __CExceptionHelper_h__
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
#define noEscape __attribute__((noescape))
|
||||
|
||||
@interface CExceptionHelper: NSObject
|
||||
|
||||
+ (BOOL)performSafely:(noEscape void(^)(void))tryBlock error:(__autoreleasing NSError **)error;
|
||||
|
||||
@end
|
||||
|
||||
#endif
|
|
@ -0,0 +1,36 @@
|
|||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
//
|
||||
// This logic is not foolproof and may result in memory-leaks, when possible we should look to remove this
|
||||
// and use the native C++ <-> Swift interoperability coming with Swift 5.9
|
||||
//
|
||||
// This solution was sourced from the following link, for more information please refer to this thread:
|
||||
// https://forums.swift.org/t/pitch-a-swift-representation-for-thrown-and-caught-exceptions/54583
|
||||
|
||||
#import "CExceptionHelper.h"
|
||||
#include <exception>
|
||||
|
||||
@implementation CExceptionHelper
|
||||
|
||||
+ (BOOL)performSafely:(noEscape void(^)(void))tryBlock error:(__autoreleasing NSError **)error {
|
||||
try {
|
||||
tryBlock();
|
||||
return YES;
|
||||
}
|
||||
catch(NSException* e) {
|
||||
*error = [[NSError alloc] initWithDomain:e.name code:-1 userInfo:e.userInfo];
|
||||
return NO;
|
||||
}
|
||||
catch (std::exception& e) {
|
||||
NSString* what = [NSString stringWithUTF8String: e.what()];
|
||||
NSDictionary* userInfo = @{NSLocalizedDescriptionKey : what};
|
||||
*error = [[NSError alloc] initWithDomain:@"cpp_exception" code:-2 userInfo:userInfo];
|
||||
return NO;
|
||||
}
|
||||
catch(...) {
|
||||
NSDictionary* userInfo = @{NSLocalizedDescriptionKey:@"Other C++ exception"};
|
||||
*error = [[NSError alloc] initWithDomain:@"cpp_exception" code:-3 userInfo:userInfo];
|
||||
return NO;
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
Loading…
Reference in New Issue