montana/Montana-iOS/Montana Messenger/Montana Messenger/ApiClient.swift

230 lines
8.2 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 }
}
}