// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation import Combine import GRDB import Sodium import SessionSnodeKit import SessionUtilitiesKit public enum OpenGroupAPI { // MARK: - Settings public static let legacyDefaultServerIP = "116.203.70.33" public static let defaultServer = "https://open.getsession.org" public static let defaultServerPublicKey = "a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238" public static let workQueue = DispatchQueue(label: "OpenGroupAPI.workQueue", qos: .userInitiated) // It's important that this is a serial queue // MARK: - Batching & Polling /// This is a convenience method which calls `/batch` with a pre-defined set of requests used to update an Open /// Group, currently this will retrieve: /// - Capabilities for the server /// - For each room: /// - Poll Info /// - Messages (includes additions and deletions) /// - Inbox for the server /// - Outbox for the server public static func poll( _ db: Database, server: String, hasPerformedInitialPoll: Bool, timeSinceLastPoll: TimeInterval, using dependencies: SMKDependencies = SMKDependencies() ) -> AnyPublisher<(info: ResponseInfoType, data: [Endpoint: Codable]), Error> { let lastInboxMessageId: Int64 = (try? OpenGroup .select(.inboxLatestMessageId) .filter(OpenGroup.Columns.server == server) .asRequest(of: Int64.self) .fetchOne(db)) .defaulting(to: 0) let lastOutboxMessageId: Int64 = (try? OpenGroup .select(.outboxLatestMessageId) .filter(OpenGroup.Columns.server == server) .asRequest(of: Int64.self) .fetchOne(db)) .defaulting(to: 0) let capabilities: Set = (try? Capability .select(.variant) .filter(Capability.Columns.openGroupServer == server) .asRequest(of: Capability.Variant.self) .fetchSet(db)) .defaulting(to: []) // Generate the requests let requestResponseType: [BatchRequest.Info] = [ BatchRequest.Info( request: Request( server: server, endpoint: .capabilities ), responseType: Capabilities.self ) ] .appending( // Per-room requests contentsOf: (try? OpenGroup .filter(OpenGroup.Columns.server == server.lowercased()) // Note: The `OpenGroup` type converts to lowercase in init .filter(OpenGroup.Columns.isActive == true) .filter(OpenGroup.Columns.roomToken != "") .fetchAll(db)) .defaulting(to: []) .flatMap { openGroup -> [BatchRequest.Info] in let shouldRetrieveRecentMessages: Bool = ( openGroup.sequenceNumber == 0 || ( // If it's the first poll for this launch and it's been longer than // 'maxInactivityPeriod' then just retrieve recent messages instead // of trying to get all messages since the last one retrieved !hasPerformedInitialPoll && timeSinceLastPoll > OpenGroupAPI.Poller.maxInactivityPeriod ) ) return [ BatchRequest.Info( request: Request( server: server, endpoint: .roomPollInfo(openGroup.roomToken, openGroup.infoUpdates) ), responseType: RoomPollInfo.self ), BatchRequest.Info( request: Request( server: server, endpoint: (shouldRetrieveRecentMessages ? .roomMessagesRecent(openGroup.roomToken) : .roomMessagesSince(openGroup.roomToken, seqNo: openGroup.sequenceNumber) ), queryParameters: [ .updateTypes: UpdateTypes.reaction.rawValue, .reactors: "5" ] ), responseType: [Failable].self ) ] } ) .appending( contentsOf: ( // The 'inbox' and 'outbox' only work with blinded keys so don't bother polling them if not blinded !capabilities.contains(.blind) ? [] : [ // Inbox BatchRequest.Info( request: Request( server: server, endpoint: (lastInboxMessageId == 0 ? .inbox : .inboxSince(id: lastInboxMessageId) ) ), responseType: [DirectMessage]?.self // 'inboxSince' will return a `304` with an empty response if no messages ), // Outbox BatchRequest.Info( request: Request( server: server, endpoint: (lastOutboxMessageId == 0 ? .outbox : .outboxSince(id: lastOutboxMessageId) ) ), responseType: [DirectMessage]?.self // 'outboxSince' will return a `304` with an empty response if no messages ) ] ) ) return OpenGroupAPI.batch(db, server: server, requests: requestResponseType, using: dependencies) } /// Submits multiple requests wrapped up in a single request, runs them all, then returns the result of each one /// /// Requests are performed independently, that is, if one fails the others will still be attempted - there is no guarantee on the order in which requests will be /// carried out (for sequential, related requests invoke via `/sequence` instead) /// /// For contained subrequests that specify a body (i.e. POST or PUT requests) exactly one of `json`, `b64`, or `bytes` must be provided with the request body. private static func batch( _ db: Database, server: String, requests: [BatchRequest.Info], using dependencies: SMKDependencies = SMKDependencies() ) -> AnyPublisher<(info: ResponseInfoType, data: [Endpoint: Codable]), Error> { let responseTypes = requests.map { $0.responseType } return OpenGroupAPI .send( db, request: Request( method: .post, server: server, endpoint: Endpoint.batch, body: BatchRequest(requests: requests) ), using: dependencies ) .decoded(as: responseTypes, using: dependencies) .map(requests: requests, toHashMapFor: Endpoint.self) } /// This is like `/batch`, except that it guarantees to perform requests sequentially in the order provided and will stop processing requests if the previous request /// returned a non-`2xx` response /// /// For example, this can be used to ban and delete all of a user's messages by sequencing the ban followed by the `delete_all`: if the ban fails (e.g. because /// permission is denied) then the `delete_all` will not occur. The batch body and response are identical to the `/batch` endpoint; requests that are not /// carried out because of an earlier failure will have a response code of `412` (Precondition Failed)." /// /// Like `/batch`, responses are returned in the same order as requests, but unlike `/batch` there may be fewer elements in the response list (if requests were /// stopped because of a non-2xx response) - In such a case, the final, non-2xx response is still included as the final response value private static func sequence( _ db: Database, server: String, requests: [BatchRequest.Info], using dependencies: SMKDependencies = SMKDependencies() ) -> AnyPublisher<(info: ResponseInfoType, data: [Endpoint: Codable]), Error> { let responseTypes = requests.map { $0.responseType } return OpenGroupAPI .send( db, request: Request( method: .post, server: server, endpoint: Endpoint.sequence, body: BatchRequest(requests: requests) ), using: dependencies ) .decoded(as: responseTypes, using: dependencies) .map(requests: requests, toHashMapFor: Endpoint.self) } // MARK: - Capabilities /// Return the list of server features/capabilities /// /// Optionally takes a `required` parameter containing a comma-separated list of capabilites; if any are not satisfied a 412 (Precondition Failed) response /// will be returned with missing requested capabilities in the `missing` key /// /// Eg. `GET /capabilities` could return `{"capabilities": ["sogs", "batch"]}` `GET /capabilities?required=magic,batch` /// could return: `{"capabilities": ["sogs", "batch"], "missing": ["magic"]}` public static func preparedCapabilities( _ db: Database, server: String, forceBlinded: Bool = false, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( db, request: Request( server: server, endpoint: .capabilities ), responseType: Capabilities.self, forceBlinded: forceBlinded, using: dependencies ) } // MARK: - Room /// Returns a list of available rooms on the server /// /// Rooms to which the user does not have access (e.g. because they are banned, or the room has restricted access permissions) are not included public static func preparedRooms( _ db: Database, server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData<[Room]> { return try OpenGroupAPI .prepareSendData( db, request: Request( server: server, endpoint: .rooms ), responseType: [Room].self, using: dependencies ) } /// Returns the details of a single room /// /// **Note:** This is the direct request to retrieve a room so should only be called from either the `poll()` or `joinRoom()` methods, in order to call /// this directly remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handlePollInfo` /// method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func preparedRoom( _ db: Database, for roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( db, request: Request( server: server, endpoint: .room(roomToken) ), responseType: Room.self, using: dependencies ) } /// Polls a room for metadata updates /// /// The endpoint polls room metadata for this room, always including the instantaneous room details (such as the user's permission and current /// number of active users), and including the full room metadata if the room's info_updated counter has changed from the provided value /// /// **Note:** This is the direct request to retrieve room updates so should be retrieved automatically from the `poll()` method, in order to call /// this directly remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handlePollInfo` /// method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func preparedRoomPollInfo( _ db: Database, lastUpdated: Int64, for roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( db, request: Request( server: server, endpoint: .roomPollInfo(roomToken, lastUpdated) ), responseType: RoomPollInfo.self, using: dependencies ) } public typealias CapabilitiesAndRoomResponse = ( info: ResponseInfoType, data: ( capabilities: (info: ResponseInfoType, data: Capabilities), room: (info: ResponseInfoType, data: Room) ) ) /// This is a convenience method which constructs a `/sequence` of the `capabilities` and `room` requests, refer to those /// methods for the documented behaviour of each method public static func capabilitiesAndRoom( _ db: Database, for roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) -> AnyPublisher { let requestResponseType: [BatchRequest.Info] = [ // Get the latest capabilities for the server (in case it's a new server or the cached ones are stale) BatchRequest.Info( request: Request( server: server, endpoint: .capabilities ), responseType: Capabilities.self ), // And the room info BatchRequest.Info( request: Request( server: server, endpoint: .room(roomToken) ), responseType: Room.self ) ] return OpenGroupAPI .sequence( db, server: server, requests: requestResponseType, using: dependencies ) .tryMap { (info: ResponseInfoType, data: [Endpoint: Codable]) -> CapabilitiesAndRoomResponse in let maybeCapabilities: HTTP.BatchSubResponse? = (data[.capabilities] as? HTTP.BatchSubResponse) let maybeRoomResponse: Codable? = data .first(where: { key, _ in switch key { case .room: return true default: return false } }) .map { _, value in value } let maybeRoom: HTTP.BatchSubResponse? = (maybeRoomResponse as? HTTP.BatchSubResponse) guard let capabilitiesInfo: ResponseInfoType = maybeCapabilities?.responseInfo, let capabilities: Capabilities = maybeCapabilities?.body, let roomInfo: ResponseInfoType = maybeRoom?.responseInfo, let room: Room = maybeRoom?.body else { throw HTTPError.parsingFailed } return ( info: info, data: ( capabilities: (info: capabilitiesInfo, data: capabilities), room: (info: roomInfo, data: room) ) ) } .eraseToAnyPublisher() } /// This is a convenience method which constructs a `/sequence` of the `capabilities` and `rooms` requests, refer to those /// methods for the documented behaviour of each method public static func capabilitiesAndRooms( _ db: Database, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) -> AnyPublisher<(capabilities: (info: ResponseInfoType, data: Capabilities), rooms: (info: ResponseInfoType, data: [Room])), Error> { let requestResponseType: [BatchRequest.Info] = [ // Get the latest capabilities for the server (in case it's a new server or the cached ones are stale) BatchRequest.Info( request: Request( server: server, endpoint: .capabilities ), responseType: Capabilities.self ), // And the room info BatchRequest.Info( request: Request( server: server, endpoint: .rooms ), responseType: [Room].self ) ] return OpenGroupAPI .sequence( db, server: server, requests: requestResponseType, using: dependencies ) .tryMap { (info: ResponseInfoType, data: [Endpoint: Codable]) -> (capabilities: (info: ResponseInfoType, data: Capabilities), rooms: (info: ResponseInfoType, data: [Room])) in let maybeCapabilities: HTTP.BatchSubResponse? = (data[.capabilities] as? HTTP.BatchSubResponse) let maybeRooms: HTTP.BatchSubResponse<[Room]>? = data .first(where: { key, _ in switch key { case .rooms: return true default: return false } }) .map { _, value in value as? HTTP.BatchSubResponse<[Room]> } guard let capabilitiesInfo: ResponseInfoType = maybeCapabilities?.responseInfo, let capabilities: Capabilities = maybeCapabilities?.body, let roomsInfo: ResponseInfoType = maybeRooms?.responseInfo, let rooms: [Room] = maybeRooms?.body else { throw HTTPError.parsingFailed } return ( capabilities: (info: capabilitiesInfo, data: capabilities), rooms: (info: roomsInfo, data: rooms) ) } .eraseToAnyPublisher() } // MARK: - Messages /// Posts a new message to a room public static func preparedSend( _ db: Database, plaintext: Data, to roomToken: String, on server: String, whisperTo: String?, whisperMods: Bool, fileIds: [String]?, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData { guard let signResult: (publicKey: String, signature: Bytes) = sign(db, messageBytes: plaintext.bytes, for: server, fallbackSigningType: .standard, using: dependencies) else { throw OpenGroupAPIError.signingFailed } return try OpenGroupAPI .prepareSendData( db, request: Request( method: .post, server: server, endpoint: Endpoint.roomMessage(roomToken), body: SendMessageRequest( data: plaintext, signature: Data(signResult.signature), whisperTo: whisperTo, whisperMods: whisperMods, fileIds: fileIds ) ), responseType: Message.self, using: dependencies ) } /// Returns a single message by ID public static func preparedMessage( _ db: Database, id: Int64, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( db, request: Request( server: server, endpoint: .roomMessageIndividual(roomToken, id: id) ), responseType: Message.self, using: dependencies ) } /// Edits a message, replacing its existing content with new content and a new signature /// /// **Note:** This edit may only be initiated by the creator of the post, and the poster must currently have write permissions in the room public static func preparedMessageUpdate( _ db: Database, id: Int64, plaintext: Data, fileIds: [Int64]?, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData { guard let signResult: (publicKey: String, signature: Bytes) = sign(db, messageBytes: plaintext.bytes, for: server, fallbackSigningType: .standard, using: dependencies) else { throw OpenGroupAPIError.signingFailed } return try OpenGroupAPI .prepareSendData( db, request: Request( method: .put, server: server, endpoint: Endpoint.roomMessageIndividual(roomToken, id: id), body: UpdateMessageRequest( data: plaintext, signature: Data(signResult.signature), fileIds: fileIds ) ), responseType: NoResponse.self, using: dependencies ) } public static func preparedMessageDelete( _ db: Database, id: Int64, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( db, request: Request( method: .delete, server: server, endpoint: .roomMessageIndividual(roomToken, id: id) ), responseType: NoResponse.self, using: dependencies ) } /// **Note:** This is the direct request to retrieve recent messages so should be retrieved automatically from the `poll()` method, in order to call /// this directly remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handleMessages` /// method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func preparedRecentMessages( _ db: Database, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData<[Message]> { return try OpenGroupAPI .prepareSendData( db, request: Request( server: server, endpoint: .roomMessagesRecent(roomToken) ), responseType: [Message].self, using: dependencies ) } /// **Note:** This is the direct request to retrieve recent messages before a given message and is currently unused, in order to call this directly /// remove the `@available` line and make sure to route the response of this method to the `OpenGroupManager.handleMessages` /// method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func preparedMessagesBefore( _ db: Database, messageId: Int64, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData<[Message]> { return try OpenGroupAPI .prepareSendData( db, request: Request( server: server, endpoint: .roomMessagesBefore(roomToken, id: messageId) ), responseType: [Message].self, using: dependencies ) } /// **Note:** This is the direct request to retrieve messages since a given message `seqNo` so should be retrieved automatically from the /// `poll()` method, in order to call this directly remove the `@available` line and make sure to route the response of this method to the /// `OpenGroupManager.handleMessages` method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func preparedMessagesSince( _ db: Database, seqNo: Int64, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData<[Message]> { return try OpenGroupAPI .prepareSendData( db, request: Request( server: server, endpoint: .roomMessagesSince(roomToken, seqNo: seqNo), queryParameters: [ .updateTypes: UpdateTypes.reaction.rawValue, .reactors: "20" ] ), responseType: [Message].self, using: dependencies ) } /// Deletes all messages from a given sessionId within the provided rooms (or globally) on a server /// /// - Parameters: /// - sessionId: The sessionId (either standard or blinded) of the user whose messages should be deleted /// /// - roomToken: The room token from which the messages should be deleted /// /// The invoking user **must** be a moderator of the given room or an admin if trying to delete the messages /// of another admin. /// /// - server: The server to delete messages from /// /// - dependencies: Injected dependencies (used for unit testing) public static func preparedMessagesDeleteAll( _ db: Database, sessionId: String, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( db, request: Request( method: .delete, server: server, endpoint: Endpoint.roomDeleteMessages(roomToken, sessionId: sessionId) ), responseType: NoResponse.self, using: dependencies ) } // MARK: - Reactions public static func preparedReactors( _ db: Database, emoji: String, id: Int64, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. /// The raw emoji will come back when calling url.path guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { throw OpenGroupAPIError.invalidEmoji } return try OpenGroupAPI .prepareSendData( db, request: Request( method: .get, server: server, endpoint: .reactors(roomToken, id: id, emoji: encodedEmoji) ), responseType: NoResponse.self, using: dependencies ) } public static func preparedReactionAdd( _ db: Database, emoji: String, id: Int64, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. /// The raw emoji will come back when calling url.path guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { throw OpenGroupAPIError.invalidEmoji } return try OpenGroupAPI .prepareSendData( db, request: Request( method: .put, server: server, endpoint: .reaction(roomToken, id: id, emoji: encodedEmoji) ), responseType: ReactionAddResponse.self, using: dependencies ) } public static func preparedReactionDelete( _ db: Database, emoji: String, id: Int64, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. /// The raw emoji will come back when calling url.path guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { throw OpenGroupAPIError.invalidEmoji } return try OpenGroupAPI .prepareSendData( db, request: Request( method: .delete, server: server, endpoint: .reaction(roomToken, id: id, emoji: encodedEmoji) ), responseType: ReactionRemoveResponse.self, using: dependencies ) } public static func preparedReactionDeleteAll( _ db: Database, emoji: String, id: Int64, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. /// The raw emoji will come back when calling url.path guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { throw OpenGroupAPIError.invalidEmoji } return try OpenGroupAPI .prepareSendData( db, request: Request( method: .delete, server: server, endpoint: .reactionDelete(roomToken, id: id, emoji: encodedEmoji) ), responseType: ReactionRemoveAllResponse.self, using: dependencies ) } // MARK: - Pinning /// Adds a pinned message to this room /// /// **Note:** Existing pinned messages are not removed: the new message is added to the pinned message list (If you want to remove existing /// pins then build a sequence request that first calls .../unpin/all) /// /// The user must have admin (not just moderator) permissions in the room in order to pin messages /// /// Pinned messages that are already pinned will be re-pinned (that is, their pin timestamp and pinning admin user will be updated) - because pinned /// messages are returned in pinning-order this allows admins to order multiple pinned messages in a room by re-pinning (via this endpoint) in the /// order in which pinned messages should be displayed public static func preparedPinMessage( _ db: Database, id: Int64, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( db, request: Request( method: .post, server: server, endpoint: .roomPinMessage(roomToken, id: id) ), responseType: NoResponse.self, using: dependencies ) } /// Remove a message from this room's pinned message list /// /// The user must have `admin` (not just `moderator`) permissions in the room public static func preparedUnpinMessage( _ db: Database, id: Int64, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( db, request: Request( method: .post, server: server, endpoint: .roomUnpinMessage(roomToken, id: id) ), responseType: NoResponse.self, using: dependencies ) } /// Removes _all_ pinned messages from this room /// /// The user must have `admin` (not just `moderator`) permissions in the room public static func preparedUnpinAll( _ db: Database, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( db, request: Request( method: .post, server: server, endpoint: .roomUnpinAll(roomToken) ), responseType: NoResponse.self, using: dependencies ) } // MARK: - Files public static func preparedUploadFile( _ db: Database, bytes: [UInt8], fileName: String? = nil, to roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( db, request: Request( method: .post, server: server, endpoint: Endpoint.roomFile(roomToken), headers: [ .contentDisposition: [ "attachment", fileName.map { "filename=\"\($0)\"" } ] .compactMap{ $0 } .joined(separator: "; "), .contentType: "application/octet-stream" ], body: bytes ), responseType: FileUploadResponse.self, timeout: FileServerAPI.fileUploadTimeout, using: dependencies ) } public static func preparedDownloadFile( _ db: Database, fileId: String, from roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( db, request: Request( server: server, endpoint: .roomFileIndividual(roomToken, fileId) ), responseType: Data.self, timeout: FileServerAPI.fileDownloadTimeout, using: dependencies ) } // MARK: - Inbox/Outbox (Message Requests) /// Retrieves all of the user's current DMs (up to limit) /// /// **Note:** This is the direct request to retrieve DMs for a specific Open Group so should be retrieved automatically from the `poll()` /// method, in order to call this directly remove the `@available` line and make sure to route the response of this method to the /// `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func preparedInbox( _ db: Database, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData<[DirectMessage]?> { return try OpenGroupAPI .prepareSendData( db, request: Request( server: server, endpoint: .inbox ), responseType: [DirectMessage]?.self, using: dependencies ) } /// Polls for any DMs received since the given id, this method will return a `304` with an empty response if there are no messages /// /// **Note:** This is the direct request to retrieve messages requests for a specific Open Group since a given messages so should be retrieved /// automatically from the `poll()` method, in order to call this directly remove the `@available` line and make sure to route the response /// of this method to the `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func preparedInboxSince( _ db: Database, id: Int64, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData<[DirectMessage]?> { return try OpenGroupAPI .prepareSendData( db, request: Request( server: server, endpoint: .inboxSince(id: id) ), responseType: [DirectMessage]?.self, using: dependencies ) } /// Delivers a direct message to a user via their blinded Session ID /// /// The body of this request is a JSON object containing a message key with a value of the encrypted-then-base64-encoded message to deliver public static func preparedSend( _ db: Database, ciphertext: Data, toInboxFor blindedSessionId: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( db, request: Request( method: .post, server: server, endpoint: Endpoint.inboxFor(sessionId: blindedSessionId), body: SendDirectMessageRequest( message: ciphertext ) ), responseType: SendDirectMessageResponse.self, using: dependencies ) } /// Retrieves all of the user's sent DMs (up to limit) /// /// **Note:** This is the direct request to retrieve DMs sent by the user for a specific Open Group so should be retrieved automatically /// from the `poll()` method, in order to call this directly remove the `@available` line and make sure to route the response of /// this method to the `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func preparedOutbox( _ db: Database, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData<[DirectMessage]?> { return try OpenGroupAPI .prepareSendData( db, request: Request( server: server, endpoint: .outbox ), responseType: [DirectMessage]?.self, using: dependencies ) } /// Polls for any DMs sent since the given id, this method will return a `304` with an empty response if there are no messages /// /// **Note:** This is the direct request to retrieve messages requests sent by the user for a specific Open Group since a given messages so /// should be retrieved automatically from the `poll()` method, in order to call this directly remove the `@available` line and make sure /// to route the response of this method to the `OpenGroupManager.handleDirectMessages` method to ensure things are processed correctly @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") public static func preparedOutboxSince( _ db: Database, id: Int64, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData<[DirectMessage]?> { return try OpenGroupAPI .prepareSendData( db, request: Request( server: server, endpoint: .outboxSince(id: id) ), responseType: [DirectMessage]?.self, using: dependencies ) } // MARK: - Users /// Applies a ban of a user from specific rooms, or from the server globally /// /// The invoking user must have `moderator` (or `admin`) permission in all given rooms when specifying rooms, and must be a /// `globalModerator` (or `globalAdmin`) if using the global parameter /// /// **Note:** The user's messages are not deleted by this request - In order to ban and delete all messages use the `/sequence` endpoint to /// bundle a `/user/.../ban` with a `/user/.../deleteMessages` request /// /// - Parameters: /// - sessionId: The sessionId (either standard or blinded) of the user whose messages should be deleted /// /// - timeout: Value specifying a time limit on the ban, in seconds /// /// The applied ban will expire and be removed after the given interval - If omitted (or `null`) then the ban is permanent /// /// If this endpoint is called multiple times then the timeout of the last call takes effect (eg. a permanent ban can be replaced /// with a time-limited ban by calling the endpoint again with a timeout value, and vice versa) /// /// - roomTokens: List of one or more room tokens from which the user should be banned from /// /// The invoking user **must** be a moderator of all of the given rooms. /// /// This may be set to the single-element list `["*"]` to ban the user from all rooms in which the current user has moderator /// permissions (the call will succeed if the calling user is a moderator in at least one channel) /// /// **Note:** You can ban from all rooms on a server by providing a `nil` value for this parameter (the invoking user must be a /// global moderator in order to add a global ban) /// /// - server: The server to delete messages from /// /// - dependencies: Injected dependencies (used for unit testing) public static func preparedUserBan( _ db: Database, sessionId: String, for timeout: TimeInterval? = nil, from roomTokens: [String]? = nil, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( db, request: Request( method: .post, server: server, endpoint: Endpoint.userBan(sessionId), body: UserBanRequest( rooms: roomTokens, global: (roomTokens == nil ? true : nil), timeout: timeout ) ), responseType: NoResponse.self, using: dependencies ) } /// Removes a user ban from specific rooms, or from the server globally /// /// The invoking user must have `moderator` (or `admin`) permission in all given rooms when specifying rooms, and must be a global server `moderator` /// (or `admin`) if using the `global` parameter /// /// **Note:** Room and global bans are independent: if a user is banned globally and has a room-specific ban then removing the global ban does not remove /// the room specific ban, and removing the room-specific ban does not remove the global ban (to fully unban a user globally and from all rooms, submit a /// `/sequence` request with a global unban followed by a "rooms": ["*"] unban) /// /// - Parameters: /// - sessionId: The sessionId (either standard or blinded) of the user whose messages should be deleted /// /// - roomTokens: List of one or more room tokens from which the user should be unbanned from /// /// The invoking user **must** be a moderator of all of the given rooms. /// /// This may be set to the single-element list `["*"]` to unban the user from all rooms in which the current user has moderator /// permissions (the call will succeed if the calling user is a moderator in at least one channel) /// /// **Note:** You can ban from all rooms on a server by providing a `nil` value for this parameter /// /// - server: The server to delete messages from /// /// - dependencies: Injected dependencies (used for unit testing) public static func preparedUserUnban( _ db: Database, sessionId: String, from roomTokens: [String]?, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData { return try OpenGroupAPI .prepareSendData( db, request: Request( method: .post, server: server, endpoint: Endpoint.userUnban(sessionId), body: UserUnbanRequest( rooms: roomTokens, global: (roomTokens == nil ? true : nil) ) ), responseType: NoResponse.self, using: dependencies ) } /// Appoints or removes a moderator or admin /// /// This endpoint is used to appoint or remove moderator/admin permissions either for specific rooms or for server-wide global moderator permissions /// /// Admins/moderators of rooms can only be appointed or removed by a user who has admin permissions in the room (including global admins) /// /// Global admins/moderators may only be appointed by a global admin /// /// The admin/moderator paramters interact as follows: /// - **admin=true, moderator omitted:** This adds admin permissions, which automatically also implies moderator permissions /// - **admin=true, moderator=true:** Exactly the same as above /// - **admin=false, moderator=true:** Removes any existing admin permissions from the rooms (or globally), if present, and adds /// moderator permissions to the rooms/globally (if not already present) /// - **admin=false, moderator omitted:** This removes admin permissions but leaves moderator permissions, if present (this /// effectively "downgrades" an admin to a moderator). Unlike the above this does **not** add moderator permissions to matching rooms /// if not already present /// - **moderator=true, admin omitted:** Adds moderator permissions to the given rooms (or globally), if not already present. If /// the user already has admin permissions this does nothing (that is, admin permission is *not* removed, unlike the above) /// - **moderator=false, admin omitted:** This removes moderator **and** admin permissions from all given rooms (or globally) /// - **moderator=false, admin=false:** Exactly the same as above /// - **moderator=false, admin=true:** This combination is **not permitted** (because admin permissions imply moderator /// permissions) and will result in Bad Request error if given /// /// - Parameters: /// - sessionId: The sessionId (either standard or blinded) of the user to modify the permissions of /// /// - moderator: Value indicating that this user should have moderator permissions added (true), removed (false), or left alone (null) /// /// - admin: Value indicating that this user should have admin permissions added (true), removed (false), or left alone (null) /// /// Granting admin permission automatically includes granting moderator permission (and thus it is an error to use admin=true with /// moderator=false) /// /// - visible: Value indicating whether the moderator/admin should be made publicly visible as a moderator/admin of the room(s) /// (if true) or hidden (false) /// /// Hidden moderators/admins still have all the same permissions as visible moderators/admins, but are visible only to other /// moderators/admins; regular users in the room will not know their moderator status /// /// - roomTokens: List of one or more room tokens to which the permission changes should be applied /// /// The invoking user **must** be an admin of all of the given rooms. /// /// This may be set to the single-element list `["*"]` to add or remove the moderator from all rooms in which the current user has admin /// permissions (the call will succeed if the calling user is an admin in at least one channel) /// /// **Note:** You can specify a change to global permisisons by providing a `nil` value for this parameter /// /// - server: The server to perform the permission changes on /// /// - dependencies: Injected dependencies (used for unit testing) public static func preparedUserModeratorUpdate( _ db: Database, sessionId: String, moderator: Bool? = nil, admin: Bool? = nil, visible: Bool, for roomTokens: [String]?, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData { guard (moderator != nil && admin == nil) || (moderator == nil && admin != nil) else { throw HTTPError.generic } return try OpenGroupAPI .prepareSendData( db, request: Request( method: .post, server: server, endpoint: Endpoint.userModerator(sessionId), body: UserModeratorRequest( rooms: roomTokens, global: (roomTokens == nil ? true : nil), moderator: moderator, admin: admin, visible: visible ) ), responseType: NoResponse.self, using: dependencies ) } /// This is a convenience method which constructs a `/sequence` of the `userBan` and `userDeleteMessages` requests, refer to those /// methods for the documented behaviour of each method public static func userBanAndDeleteAllMessages( _ db: Database, sessionId: String, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() ) -> AnyPublisher<(info: ResponseInfoType, data: [Endpoint: ResponseInfoType]), Error> { let banRequestBody: UserBanRequest = UserBanRequest( rooms: [roomToken], global: nil, timeout: nil ) // Generate the requests let requestResponseType: [BatchRequest.Info] = [ BatchRequest.Info( request: Request( method: .post, server: server, endpoint: .userBan(sessionId), body: banRequestBody ) ), BatchRequest.Info( request: Request( method: .delete, server: server, endpoint: Endpoint.roomDeleteMessages(roomToken, sessionId: sessionId) ) ) ] return OpenGroupAPI .sequence( db, server: server, requests: requestResponseType, using: dependencies ) .map { info, data -> (info: ResponseInfoType, data: [Endpoint: ResponseInfoType]) in ( info, data.compactMapValues { ($0 as? BatchSubResponseType)?.responseInfo } ) } .eraseToAnyPublisher() } // MARK: - Authentication /// Sign a message to be sent to SOGS (handles both un-blinded and blinded signing based on the server capabilities) private static func sign( _ db: Database, messageBytes: Bytes, for serverName: String, fallbackSigningType signingType: SessionId.Prefix, forceBlinded: Bool = false, using dependencies: SMKDependencies = SMKDependencies() ) -> (publicKey: String, signature: Bytes)? { guard let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), let serverPublicKey: String = try? OpenGroup .select(.publicKey) .filter(OpenGroup.Columns.server == serverName.lowercased()) .asRequest(of: String.self) .fetchOne(db) else { return nil } let capabilities: Set = (try? Capability .select(.variant) .filter(Capability.Columns.openGroupServer == serverName.lowercased()) .asRequest(of: Capability.Variant.self) .fetchSet(db)) .defaulting(to: []) // If we have no capabilities or if the server supports blinded keys then sign using the blinded key if forceBlinded || capabilities.isEmpty || capabilities.contains(.blind) { guard let blindedKeyPair: KeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else { return nil } guard let signatureResult: Bytes = dependencies.sodium.sogsSignature(message: messageBytes, secretKey: userEdKeyPair.secretKey, blindedSecretKey: blindedKeyPair.secretKey, blindedPublicKey: blindedKeyPair.publicKey) else { return nil } return ( publicKey: SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString, signature: signatureResult ) } // Otherwise sign using the fallback type switch signingType { case .unblinded: guard let signatureResult: Bytes = dependencies.sign.signature(message: messageBytes, secretKey: userEdKeyPair.secretKey) else { return nil } return ( publicKey: SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString, signature: signatureResult ) // Default to using the 'standard' key default: guard let userKeyPair: KeyPair = Identity.fetchUserKeyPair(db) else { return nil } guard let signatureResult: Bytes = try? dependencies.ed25519.sign(data: messageBytes, keyPair: userKeyPair) else { return nil } return ( publicKey: SessionId(.standard, publicKey: userKeyPair.publicKey).hexString, signature: signatureResult ) } } /// Sign a request to be sent to SOGS (handles both un-blinded and blinded signing based on the server capabilities) private static func sign( _ db: Database, request: URLRequest, for serverName: String, with serverPublicKey: String, forceBlinded: Bool = false, using dependencies: SMKDependencies = SMKDependencies() ) -> URLRequest? { guard let url: URL = request.url else { return nil } var updatedRequest: URLRequest = request let path: String = url.path .appending(url.query.map { value in "?\(value)" }) let method: String = (request.httpMethod ?? "GET") let timestamp: Int = Int(floor(dependencies.date.timeIntervalSince1970)) let nonce: Data = Data(dependencies.nonceGenerator16.nonce()) let serverPublicKeyData: Data = Data(hex: serverPublicKey) guard !serverPublicKeyData.isEmpty, let timestampBytes: Bytes = "\(timestamp)".data(using: .ascii)?.bytes else { return nil } /// Get a hash of any body content let bodyHash: Bytes? = { guard let body: Data = request.httpBody else { return nil } return dependencies.genericHash.hash(message: body.bytes, outputLength: 64) }() /// Generate the signature message /// "ServerPubkey || Nonce || Timestamp || Method || Path || Blake2b Hash(Body) /// `ServerPubkey` /// `Nonce` /// `Timestamp` is the bytes of an ascii decimal string /// `Method` /// `Path` /// `Body` is a Blake2b hash of the data (if there is a body) let messageBytes: Bytes = serverPublicKeyData.bytes .appending(contentsOf: nonce.bytes) .appending(contentsOf: timestampBytes) .appending(contentsOf: method.bytes) .appending(contentsOf: path.bytes) .appending(contentsOf: bodyHash ?? []) /// Sign the above message guard let signResult: (publicKey: String, signature: Bytes) = sign(db, messageBytes: messageBytes, for: serverName, fallbackSigningType: .unblinded, forceBlinded: forceBlinded, using: dependencies) else { return nil } updatedRequest.allHTTPHeaderFields = (request.allHTTPHeaderFields ?? [:]) .updated(with: [ HTTPHeader.sogsPubKey: signResult.publicKey, HTTPHeader.sogsTimestamp: "\(timestamp)", HTTPHeader.sogsNonce: nonce.base64EncodedString(), HTTPHeader.sogsSignature: signResult.signature.toBase64() ]) return updatedRequest } // MARK: - Convenience private static func prepareSendData( _ db: Database, request: Request, responseType: R.Type, forceBlinded: Bool = false, timeout: TimeInterval = HTTP.defaultTimeout, using dependencies: SMKDependencies = SMKDependencies() ) throws -> PreparedSendData { let urlRequest: URLRequest = try request.generateUrlRequest() let maybePublicKey: String? = try? OpenGroup .select(.publicKey) .filter(OpenGroup.Columns.server == request.server.lowercased()) .asRequest(of: String.self) .fetchOne(db) guard let publicKey: String = maybePublicKey else { throw OpenGroupAPIError.noPublicKey } // Attempt to sign the request with the new auth guard let signedRequest: URLRequest = sign(db, request: urlRequest, for: request.server, with: publicKey, forceBlinded: forceBlinded, using: dependencies) else { throw OpenGroupAPIError.signingFailed } return PreparedSendData( request: signedRequest, endpoint: request.endpoint, server: request.server, publicKey: publicKey, responseType: responseType, timeout: timeout ) } private static func send( _ db: Database, request: Request, forceBlinded: Bool = false, timeout: TimeInterval = HTTP.defaultTimeout, using dependencies: SMKDependencies = SMKDependencies() ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { let urlRequest: URLRequest do { urlRequest = try request.generateUrlRequest() } catch { return Fail(error: error) .eraseToAnyPublisher() } let maybePublicKey: String? = try? OpenGroup .select(.publicKey) .filter(OpenGroup.Columns.server == request.server.lowercased()) .asRequest(of: String.self) .fetchOne(db) guard let publicKey: String = maybePublicKey else { return Fail(error: OpenGroupAPIError.noPublicKey) .eraseToAnyPublisher() } // Attempt to sign the request with the new auth guard let signedRequest: URLRequest = sign(db, request: urlRequest, for: request.server, with: publicKey, forceBlinded: forceBlinded, using: dependencies) else { return Fail(error: OpenGroupAPIError.signingFailed) .eraseToAnyPublisher() } // We want to avoid blocking the db write thread so we dispatch the API call to a different thread return Just(()) .setFailureType(to: Error.self) .flatMap { dependencies.onionApi.sendOnionRequest(signedRequest, to: request.server, with: publicKey, timeout: timeout) } .eraseToAnyPublisher() } public static func send( data: PreparedSendData?, using dependencies: SMKDependencies = SMKDependencies() ) -> AnyPublisher<(ResponseInfoType, R), Error> { guard let validData: PreparedSendData = data else { return Fail(error: OpenGroupAPIError.invalidPreparedData) .eraseToAnyPublisher() } return dependencies.onionApi .sendOnionRequest( validData.request, to: validData.server, with: validData.publicKey, timeout: validData.timeout ) .decoded(with: validData, using: dependencies) .eraseToAnyPublisher() } }