diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 70546c16f..3b5e8241e 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -1461,10 +1461,15 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers var lastSize: CGSize = .zero self.tableView.afterNextLayoutSubviews( - when: { [weak self] a, b, updatedContentSize in - guard (CACurrentMediaTime() - initialUpdateTime) < 2 && lastSize != updatedContentSize else { - return true - } + when: { [weak self] numSections, numRowInSections, updatedContentSize in + // If too much time has passed or the section/row count doesn't match then + // just stop the callback + guard + (CACurrentMediaTime() - initialUpdateTime) < 2 && + lastSize != updatedContentSize && + numSections > targetIndexPath.section && + numRowInSections[targetIndexPath.section] > targetIndexPath.row + else { return true } lastSize = updatedContentSize diff --git a/Session/Open Groups/OpenGroupSuggestionGrid.swift b/Session/Open Groups/OpenGroupSuggestionGrid.swift index 81cf042b0..ee9e83ed7 100644 --- a/Session/Open Groups/OpenGroupSuggestionGrid.swift +++ b/Session/Open Groups/OpenGroupSuggestionGrid.swift @@ -245,7 +245,7 @@ extension OpenGroupSuggestionGrid { label.text = room.name // Only continue if we have a room image - guard let imageId: Int64 = room.imageId else { + guard let imageId: String = room.imageId else { imageView.isHidden = true return } diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift index 41d9109d9..a0c268851 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift @@ -141,7 +141,7 @@ public enum SMKLegacy { internal var sender: String? internal var groupPublicKey: String? internal var openGroupServerMessageID: UInt64? - internal var openGroupServerTimestamp: UInt64? + internal var openGroupServerTimestamp: UInt64? // Not used for anything internal var serverHash: String? // MARK: NSCoding @@ -175,7 +175,6 @@ public enum SMKLegacy { result.sender = self.sender result.groupPublicKey = self.groupPublicKey result.openGroupServerMessageId = self.openGroupServerMessageID - result.openGroupServerTimestamp = self.openGroupServerTimestamp result.serverHash = self.serverHash return result diff --git a/SessionMessagingKit/File Server/FileServerAPI.swift b/SessionMessagingKit/File Server/FileServerAPI.swift index c1aed7116..c92c9499e 100644 --- a/SessionMessagingKit/File Server/FileServerAPI.swift +++ b/SessionMessagingKit/File Server/FileServerAPI.swift @@ -41,11 +41,11 @@ public final class FileServerAPI: NSObject { .decoded(as: FileUploadResponse.self, on: .global(qos: .userInitiated)) } - public static func download(_ file: Int64, useOldServer: Bool) -> Promise { + public static func download(_ fileId: String, useOldServer: Bool) -> Promise { let serverPublicKey: String = (useOldServer ? oldServerPublicKey : serverPublicKey) let request = Request( server: (useOldServer ? oldServer : server), - endpoint: .fileIndividual(fileId: file) + endpoint: .fileIndividual(fileId: fileId) ) return send(request, serverPublicKey: serverPublicKey) diff --git a/SessionMessagingKit/File Server/Types/FSEndpoint.swift b/SessionMessagingKit/File Server/Types/FSEndpoint.swift index d2c9aa668..5e242bea8 100644 --- a/SessionMessagingKit/File Server/Types/FSEndpoint.swift +++ b/SessionMessagingKit/File Server/Types/FSEndpoint.swift @@ -5,7 +5,7 @@ import Foundation extension FileServerAPI { public enum Endpoint: EndpointType { case file - case fileIndividual(fileId: Int64) + case fileIndividual(fileId: String) case sessionVersion var path: String { diff --git a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift index 33f5e0b93..c420db071 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -87,8 +87,10 @@ public enum AttachmentDownloadJob: JobExecutor { let downloadPromise: Promise = { guard let downloadUrl: String = attachment.downloadUrl, - let fileAsString: String = downloadUrl.split(separator: "/").last.map({ String($0) }), - let file: Int64 = Int64(fileAsString) + let fileId: String = downloadUrl + .split(separator: "/") + .last + .map({ String($0) }) else { return Promise(error: AttachmentDownloadError.invalidUrl) } @@ -98,13 +100,13 @@ public enum AttachmentDownloadJob: JobExecutor { return nil // Not an open group so just use standard FileServer upload } - return OpenGroupAPI.downloadFile(db, fileId: file, from: openGroup.roomToken, on: openGroup.server) + return OpenGroupAPI.downloadFile(db, fileId: fileId, from: openGroup.roomToken, on: openGroup.server) .map { _, data in data } }) return ( maybeOpenGroupDownloadPromise ?? - FileServerAPI.download(file, useOldServer: downloadUrl.contains(FileServerAPI.oldServer)) + FileServerAPI.download(fileId, useOldServer: downloadUrl.contains(FileServerAPI.oldServer)) ) }() diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index 0f09de1e0..e33da9647 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -14,7 +14,6 @@ public class Message: Codable { public var sender: String? public var groupPublicKey: String? public var openGroupServerMessageId: UInt64? - public var openGroupServerTimestamp: UInt64? public var serverHash: String? public var ttl: UInt64 { 14 * 24 * 60 * 60 * 1000 } @@ -41,7 +40,6 @@ public class Message: Codable { sender: String? = nil, groupPublicKey: String? = nil, openGroupServerMessageId: UInt64? = nil, - openGroupServerTimestamp: UInt64? = nil, serverHash: String? = nil ) { self.id = id @@ -52,7 +50,6 @@ public class Message: Codable { self.sender = sender self.groupPublicKey = groupPublicKey self.openGroupServerMessageId = openGroupServerMessageId - self.openGroupServerTimestamp = openGroupServerTimestamp self.serverHash = serverHash } diff --git a/SessionMessagingKit/Open Groups/Models/Room.swift b/SessionMessagingKit/Open Groups/Models/Room.swift index c791e04dd..f1a2f32d6 100644 --- a/SessionMessagingKit/Open Groups/Models/Room.swift +++ b/SessionMessagingKit/Open Groups/Models/Room.swift @@ -70,7 +70,7 @@ extension OpenGroupAPI { /// File ID of an uploaded file containing the room's image /// /// Omitted if there is no image - public let imageId: Int64? + public let imageId: String? /// Array of pinned message information (omitted entirely if there are no pinned messages) public let pinnedMessages: [PinnedMessage]? @@ -150,6 +150,12 @@ extension OpenGroupAPI.Room { public init(from decoder: Decoder) throws { let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + // This logic is to future-proof the transition from int-based to string-based image ids + let maybeImageId: String? = ( + ((try? container.decode(Int64.self, forKey: .imageId)).map { "\($0)" }) ?? + (try? container.decode(String.self, forKey: .imageId)) + ) + self = OpenGroupAPI.Room( token: try container.decode(String.self, forKey: .token), name: try container.decode(String.self, forKey: .name), @@ -160,7 +166,7 @@ extension OpenGroupAPI.Room { activeUsers: try container.decode(Int64.self, forKey: .activeUsers), activeUsersCutoff: try container.decode(Int64.self, forKey: .activeUsersCutoff), - imageId: try? container.decode(Int64.self, forKey: .imageId), + imageId: maybeImageId, pinnedMessages: try? container.decode([OpenGroupAPI.PinnedMessage].self, forKey: .pinnedMessages), admin: ((try? container.decode(Bool.self, forKey: .admin)) ?? false), diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index bfc099aaf..65190d397 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -700,7 +700,7 @@ public enum OpenGroupAPI { public static func downloadFile( _ db: Database, - fileId: Int64, + fileId: String, from roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 856003cc3..3b4f5042b 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -446,10 +446,10 @@ public final class OpenGroupManager: NSObject { /// Start downloading the room image (if we don't have one or it's been updated) if - let imageId: Int64 = pollInfo.details?.imageId, + let imageId: String = pollInfo.details?.imageId, ( openGroup.imageData == nil || - openGroup.imageId != "\(imageId)" + openGroup.imageId != imageId ) { OpenGroupManager.roomImage(db, fileId: imageId, for: roomToken, on: server, using: dependencies) @@ -786,7 +786,7 @@ public final class OpenGroupManager: NSObject { .done(on: OpenGroupAPI.workQueue) { items in dependencies.storage.writeAsync { db in items - .compactMap { room -> (Int64, String)? in + .compactMap { room -> (String, String)? in // Try to insert an inactive version of the OpenGroup (use 'insert' rather than 'save' // as we want it to fail if the room already exists) do { @@ -797,7 +797,7 @@ public final class OpenGroupManager: NSObject { isActive: false, name: room.name, roomDescription: room.roomDescription, - imageId: room.imageId.map { "\($0)" }, + imageId: room.imageId, imageData: nil, userCount: room.activeUsers, infoUpdates: room.infoUpdates, @@ -809,7 +809,7 @@ public final class OpenGroupManager: NSObject { } catch {} - guard let imageId: Int64 = room.imageId else { return nil } + guard let imageId: String = room.imageId else { return nil } return (imageId, room.token) } @@ -845,7 +845,7 @@ public final class OpenGroupManager: NSObject { public static func roomImage( _ db: Database, - fileId: Int64, + fileId: String, for roomToken: String, on server: String, using dependencies: OGMDependencies = OGMDependencies() diff --git a/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift b/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift index 1c1ee52dc..052b2fe80 100644 --- a/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift +++ b/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift @@ -35,7 +35,7 @@ extension OpenGroupAPI { // Files case roomFile(String) - case roomFileIndividual(String, Int64) + case roomFileIndividual(String, String) // Inbox/Outbox (Message Requests) diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 2eda1e7ab..4524b75d2 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -146,7 +146,6 @@ public enum MessageReceiver { message.sentTimestamp = envelope.timestamp message.receivedTimestamp = UInt64((Date().timeIntervalSince1970) * 1000) message.groupPublicKey = groupPublicKey - message.openGroupServerTimestamp = (isOpenGroupMessage ? envelope.serverTimestamp : nil) message.openGroupServerMessageId = openGroupMessageServerId.map { UInt64($0) } // Validate diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index e84f370d7..c20391a42 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -339,11 +339,17 @@ public final class MessageSender { .joined(separator: ".") } + // Note: It's possible to send a message and then delete the open group you sent the message to + // which would go into this case, so rather than handling it as an invalid state we just want to + // error in a non-retryable way guard let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: threadId), let userEdKeyPair: Box.KeyPair = Identity.fetchUserEd25519KeyPair(db), case .openGroup(let roomToken, let server, let whisperTo, let whisperMods, let fileIds) = destination - else { preconditionFailure() } + else { + seal.reject(MessageSenderError.invalidMessage) + return promise + } message.sender = { let capabilities: [Capability.Variant] = (try? Capability diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index ac25ce0f9..e163268a4 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -145,15 +145,15 @@ public struct ProfileManager { // Download already in flight; ignore return } - guard - let profileUrlStringAtStart: String = profile.profilePictureUrl, - let profileUrlAtStart: URL = URL(string: profileUrlStringAtStart) - else { + guard let profileUrlStringAtStart: String = profile.profilePictureUrl else { SNLog("Skipping downloading avatar for \(profile.id) because url is not set") return } guard - let fileId: Int64 = Int64(profileUrlAtStart.lastPathComponent), + let fileId: String = profileUrlStringAtStart + .split(separator: "/") + .last + .map({ String($0) }), let profileKeyAtStart: OWSAES256Key = profile.profileEncryptionKey, profileKeyAtStart.keyData.count > 0 else { diff --git a/SessionSnodeKit/Models/OnionRequestAPIError.swift b/SessionSnodeKit/Models/OnionRequestAPIError.swift index 6f555542c..3b75fe124 100644 --- a/SessionSnodeKit/Models/OnionRequestAPIError.swift +++ b/SessionSnodeKit/Models/OnionRequestAPIError.swift @@ -14,8 +14,11 @@ public enum OnionRequestAPIError: LocalizedError { public var errorDescription: String? { switch self { - case .httpRequestFailedAtDestination(let statusCode, _, let destination): + case .httpRequestFailedAtDestination(let statusCode, let data, let destination): if statusCode == 429 { return "Rate limited." } + if let errorResponse: String = String(data: data, encoding: .utf8) { + return "HTTP request failed at destination (\(destination)) with status code: \(statusCode), error body: \(errorResponse)." + } return "HTTP request failed at destination (\(destination)) with status code: \(statusCode)." diff --git a/SessionSnodeKit/Models/SnodeAPIEndpoint.swift b/SessionSnodeKit/Models/SnodeAPIEndpoint.swift index 1e46b10c0..1028740fe 100644 --- a/SessionSnodeKit/Models/SnodeAPIEndpoint.swift +++ b/SessionSnodeKit/Models/SnodeAPIEndpoint.swift @@ -10,4 +10,5 @@ public enum SnodeAPIEndpoint: String { case oxenDaemonRPCCall = "oxend_request" case getInfo = "info" case clearAllData = "delete_all" + case expire = "expire" } diff --git a/SessionSnodeKit/OnionRequestAPI.swift b/SessionSnodeKit/OnionRequestAPI.swift index ce6cc7b46..0d69334d4 100644 --- a/SessionSnodeKit/OnionRequestAPI.swift +++ b/SessionSnodeKit/OnionRequestAPI.swift @@ -650,8 +650,11 @@ public enum OnionRequestAPI: OnionRequestAPIType { } if let bodyAsString = json["body"] as? String { - guard let bodyAsData = bodyAsString.data(using: .utf8), let body = try JSONSerialization.jsonObject(with: bodyAsData, options: [ .fragmentsAllowed ]) as? JSON else { - return seal.reject(HTTP.Error.invalidJSON) + guard let bodyAsData = bodyAsString.data(using: .utf8) else { + return seal.reject(HTTP.Error.invalidResponse) + } + guard let body = try? JSONSerialization.jsonObject(with: bodyAsData, options: [ .fragmentsAllowed ]) as? JSON else { + return seal.reject(OnionRequestAPIError.httpRequestFailedAtDestination(statusCode: UInt(statusCode), data: bodyAsData, destination: destination)) } if let timestamp = body["t"] as? Int64 { diff --git a/SessionSnodeKit/SnodeAPI.swift b/SessionSnodeKit/SnodeAPI.swift index 5a4af04dc..2e5b5fc1f 100644 --- a/SessionSnodeKit/SnodeAPI.swift +++ b/SessionSnodeKit/SnodeAPI.swift @@ -719,6 +719,99 @@ public final class SnodeAPI { return promise } + // MARK: Edit + + public static func updateExpiry( + publicKey: String, + edKeyPair: Box.KeyPair, + updatedExpiryMs: UInt64, + serverHashes: [String] + ) -> Promise<[String: (hashes: [String], expiry: UInt64)]> { + let publicKey = (Features.useTestnet ? publicKey.removingIdPrefixIfNeeded() : publicKey) + + return attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { + getSwarm(for: publicKey) + .then2 { swarm -> Promise<[String: (hashes: [String], expiry: UInt64)]> in + // "expire" || expiry || messages[0] || ... || messages[N] + let verificationBytes = SnodeAPIEndpoint.expire.rawValue.bytes + .appending(contentsOf: "\(updatedExpiryMs)".data(using: .ascii)?.bytes) + .appending(contentsOf: serverHashes.joined().bytes) + + guard + let snode = swarm.randomElement(), + let signature = sodium.sign.signature( + message: verificationBytes, + secretKey: edKeyPair.secretKey + ) + else { + throw SnodeAPIError.signingFailed + } + + let parameters: JSON = [ + "pubkey" : publicKey, + "pubkey_ed25519" : edKeyPair.publicKey.toHexString(), + "expiry": updatedExpiryMs, + "messages": serverHashes, + "signature": signature.toBase64() + ] + + return attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { + invoke(.expire, on: snode, associatedWith: publicKey, parameters: parameters) + .map2 { responseData -> [String: (hashes: [String], expiry: UInt64)] in + guard let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON else { + throw HTTP.Error.invalidJSON + } + guard let swarm = responseJson["swarm"] as? JSON else { throw HTTP.Error.invalidJSON } + + var result: [String: (hashes: [String], expiry: UInt64)] = [:] + + for (snodePublicKey, rawJSON) in swarm { + guard let json = rawJSON as? JSON else { throw HTTP.Error.invalidJSON } + guard (json["failed"] as? Bool ?? false) == false else { + if let reason = json["reason"] as? String, let statusCode = json["code"] as? String { + SNLog("Couldn't delete data from: \(snodePublicKey) due to error: \(reason) (\(statusCode)).") + } + else { + SNLog("Couldn't delete data from: \(snodePublicKey).") + } + result[snodePublicKey] = ([], 0) + continue + } + + guard + let hashes: [String] = json["updated"] as? [String], + let expiryApplied: UInt64 = json["expiry"] as? UInt64, + let signature: String = json["signature"] as? String + else { + throw HTTP.Error.invalidJSON + } + + // The signature format is ( PUBKEY_HEX || EXPIRY || RMSG[0] || ... || RMSG[N] || UMSG[0] || ... || UMSG[M] ) + let verificationBytes = publicKey.bytes + .appending(contentsOf: "\(expiryApplied)".data(using: .ascii)?.bytes) + .appending(contentsOf: serverHashes.joined().bytes) + .appending(contentsOf: hashes.joined().bytes) + let isValid = sodium.sign.verify( + message: verificationBytes, + publicKey: Bytes(Data(hex: snodePublicKey)), + signature: Bytes(Data(base64Encoded: signature)!) + ) + + // Ensure the signature is valid + guard isValid else { + throw SnodeAPIError.signatureVerificationFailed + } + + result[snodePublicKey] = (hashes, expiryApplied) + } + + return result + } + } + } + } + } + // MARK: Delete public static func deleteMessage(publicKey: String, serverHashes: [String]) -> Promise<[String: Bool]> { @@ -732,10 +825,16 @@ public final class SnodeAPI { return attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { getSwarm(for: publicKey) .then2 { swarm -> Promise<[String: Bool]> in + // "delete" || messages... + let verificationBytes = SnodeAPIEndpoint.deleteMessage.rawValue.bytes + .appending(contentsOf: serverHashes.joined().bytes) + guard let snode = swarm.randomElement(), - let verificationData = (SnodeAPIEndpoint.deleteMessage.rawValue + serverHashes.joined()).data(using: String.Encoding.utf8), - let signature = sodium.sign.signature(message: Bytes(verificationData), secretKey: userED25519KeyPair.secretKey) + let signature = sodium.sign.signature( + message: verificationBytes, + secretKey: userED25519KeyPair.secretKey + ) else { throw SnodeAPIError.signingFailed } @@ -771,15 +870,11 @@ public final class SnodeAPI { } // The signature format is ( PUBKEY_HEX || RMSG[0] || ... || RMSG[N] || DMSG[0] || ... || DMSG[M] ) - let verificationData = [ - userX25519PublicKey, - serverHashes.joined(), - hashes.joined() - ] - .joined() - .data(using: String.Encoding.utf8)! + let verificationBytes = userX25519PublicKey.bytes + .appending(contentsOf: serverHashes.joined().bytes) + .appending(contentsOf: hashes.joined().bytes) let isValid = sodium.sign.verify( - message: Bytes(verificationData), + message: verificationBytes, publicKey: Bytes(Data(hex: snodePublicKey)), signature: Bytes(Data(base64Encoded: signature)!) ) diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index 16d482baf..1317224f0 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -411,7 +411,7 @@ public class PagedDatabaseObserver: TransactionObserver where guard targetIndex > halfPageSize else { return 0 } guard targetIndex < (totalCount - halfPageSize) else { - return (totalCount - currentPageInfo.pageSize) + return max(0, (totalCount - currentPageInfo.pageSize)) } return (targetIndex - halfPageSize) diff --git a/SessionUtilitiesKit/General/SessionId.swift b/SessionUtilitiesKit/General/SessionId.swift index 3f81bc6b6..7e251876e 100644 --- a/SessionUtilitiesKit/General/SessionId.swift +++ b/SessionUtilitiesKit/General/SessionId.swift @@ -7,8 +7,8 @@ import Curve25519Kit public struct SessionId { public enum Prefix: String, CaseIterable { case standard = "05" // Used for identified users, open groups, etc. - case blinded = "15" // Used for participants in open groups with blinding enabled - case unblinded = "00" // Used for participants in open groups with blinding disabled + case blinded = "15" // Used for authentication and participants in open groups with blinding enabled + case unblinded = "00" // Used for authentication in open groups with blinding disabled public init?(from stringValue: String?) { guard let stringValue: String = stringValue else { return nil }