diff --git a/Montana-iOS/Montana Messenger/Montana Messenger/ApiClient.swift b/Montana-iOS/Montana Messenger/Montana Messenger/ApiClient.swift new file mode 100644 index 0000000..a5e9698 --- /dev/null +++ b/Montana-iOS/Montana Messenger/Montana Messenger/ApiClient.swift @@ -0,0 +1,229 @@ +import Foundation +import Combine +import CryptoKit + +enum MontanaAPI { + static let baseURL = URL(string: "https://mess.montana.quest:8443/api")! + static let wsURL = URL(string: "wss://mess.montana.quest:8443/api/ws")! +} + +struct ApiAccount: Decodable { + let account_id: String + let name: String + let ed_pub: String + let x_pub: String + let created_at: Int +} + +struct ApiEnvelope: Decodable, Identifiable { + let id: Int + let from: String + let to: String + let nonce: String + let ciphertext: String + let sent_at: Int +} + +struct ApiInbox: Decodable { + let messages: [ApiEnvelope] +} + +enum ApiError: LocalizedError { + case http(Int, String) + case bad + case noIdentity + var errorDescription: String? { + switch self { + case .http(let c, let m): return "HTTP \(c): \(m)" + case .bad: return "Bad response" + case .noIdentity: return "Не создана идентичность" + } + } +} + +@MainActor +final class ApiClient { + static let shared = ApiClient() + private let session: URLSession + + init() { + let cfg = URLSessionConfiguration.default + cfg.waitsForConnectivity = true + cfg.timeoutIntervalForRequest = 20 + self.session = URLSession(configuration: cfg) + } + + func register(name: String, identity: Identity) async throws { + let body: [String: String] = [ + "name": name, + "ed_pub": identity.edPublicKeyB64, + "x_pub": identity.xPublicKeyB64, + ] + let url = MontanaAPI.baseURL.appendingPathComponent("accounts") + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONEncoder().encode(body) + let (_, resp) = try await session.data(for: req) + try Self.checkOK(resp) + } + + func getAccount(_ accountID: String) async throws -> ApiAccount { + let url = MontanaAPI.baseURL.appendingPathComponent("accounts").appendingPathComponent(accountID) + let (data, resp) = try await session.data(from: url) + try Self.checkOK(resp) + return try JSONDecoder().decode(ApiAccount.self, from: data) + } + + func send(to: String, nonce: String, ciphertext: String, identity: Identity) async throws -> ApiEnvelope { + let payload: [String: String] = ["to": to, "nonce": nonce, "ciphertext": ciphertext] + let body = try Self.canonicalJSON(payload) + let ts = String(Int(Date().timeIntervalSince1970)) + let sig = try identity.signingKey.signature(for: Data((ts + "\n").utf8) + body) + + var req = URLRequest(url: MontanaAPI.baseURL.appendingPathComponent("messages/send")) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue(identity.accountID, forHTTPHeaderField: "X-Montana-Account") + req.setValue("\(ts):\(sig.b64uNoPad)", forHTTPHeaderField: "X-Montana-Auth") + req.httpBody = body + let (data, resp) = try await session.data(for: req) + try Self.checkOK(resp) + return try JSONDecoder().decode(ApiEnvelope.self, from: data) + } + + func inbox(since: Int, identity: Identity) async throws -> [ApiEnvelope] { + let ts = String(Int(Date().timeIntervalSince1970)) + let body = Data("since=\(since)".utf8) + let sig = try identity.signingKey.signature(for: Data((ts + "\n").utf8) + body) + + var comps = URLComponents(url: MontanaAPI.baseURL.appendingPathComponent("messages/inbox"), resolvingAgainstBaseURL: false)! + comps.queryItems = [URLQueryItem(name: "since", value: String(since))] + var req = URLRequest(url: comps.url!) + req.setValue(identity.accountID, forHTTPHeaderField: "X-Montana-Account") + req.setValue("\(ts):\(sig.b64uNoPad)", forHTTPHeaderField: "X-Montana-Auth") + let (data, resp) = try await session.data(for: req) + try Self.checkOK(resp) + return try JSONDecoder().decode(ApiInbox.self, from: data).messages + } + + private static func checkOK(_ resp: URLResponse) throws { + guard let h = resp as? HTTPURLResponse else { throw ApiError.bad } + if !(200...299).contains(h.statusCode) { + throw ApiError.http(h.statusCode, h.url?.absoluteString ?? "") + } + } + + static func canonicalJSON(_ dict: [String: String]) throws -> Data { + let sorted = dict.sorted { $0.key < $1.key } + var s = "{" + for (i, (k, v)) in sorted.enumerated() { + if i > 0 { s += "," } + s += "\"\(k)\":\"\(escapeJSON(v))\"" + } + s += "}" + return Data(s.utf8) + } + + private static func escapeJSON(_ s: String) -> String { + var out = "" + for c in s { + switch c { + case "\\": out += "\\\\" + case "\"": out += "\\\"" + case "\n": out += "\\n" + case "\r": out += "\\r" + case "\t": out += "\\t" + case "\u{08}": out += "\\b" + case "\u{0C}": out += "\\f" + default: + if c.asciiValue != nil && c.asciiValue! < 0x20 { + out += String(format: "\\u%04x", c.asciiValue!) + } else { + out.append(c) + } + } + } + return out + } +} + +@MainActor +final class WebSocketLink: NSObject, ObservableObject, URLSessionWebSocketDelegate { + static let shared = WebSocketLink() + @Published private(set) var isConnected = false + + private var task: URLSessionWebSocketTask? + private var pingTimer: Timer? + var onEnvelope: ((ApiEnvelope) -> Void)? + + func connect(identity: Identity) { + disconnect() + let cfg = URLSessionConfiguration.default + let session = URLSession(configuration: cfg, delegate: self, delegateQueue: .main) + let task = session.webSocketTask(with: MontanaAPI.wsURL) + self.task = task + task.resume() + + let ts = String(Int(Date().timeIntervalSince1970)) + do { + let sig = try identity.signingKey.signature(for: Data((ts + "\nws").utf8)) + let hello = ["account_id": identity.accountID, "ts": ts, "sig": sig.b64uNoPad] + let helloData = try JSONSerialization.data(withJSONObject: hello) + let helloStr = String(data: helloData, encoding: .utf8) ?? "" + task.send(.string(helloStr)) { [weak self] err in + if err != nil { self?.disconnect() } + } + receive() + startPing() + } catch { + disconnect() + } + } + + func disconnect() { + task?.cancel(with: .normalClosure, reason: nil) + task = nil + pingTimer?.invalidate() + pingTimer = nil + isConnected = false + } + + private func receive() { + task?.receive { [weak self] result in + guard let self else { return } + DispatchQueue.main.async { + switch result { + case .success(let m): + if case .string(let s) = m, let data = s.data(using: .utf8) { + if let env = try? JSONDecoder().decode(ApiEnvelope.self, from: data) { + self.onEnvelope?(env) + } else if let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any], dict["hello"] != nil { + self.isConnected = true + } + } + self.receive() + case .failure: + self.isConnected = false + } + } + } + } + + private func startPing() { + pingTimer?.invalidate() + pingTimer = Timer.scheduledTimer(withTimeInterval: 25, repeats: true) { [weak self] _ in + self?.task?.send(.string("ping")) { _ in } + } + } + + nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, + didOpenWithProtocol protocol: String?) { + Task { @MainActor in self.isConnected = true } + } + + nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, + didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + Task { @MainActor in self.isConnected = false } + } +}