import Foundation /// The Calls server this app signs in to: calls.oficina.works unless the /// person has chosen another in Settings. Kept in UserDefaults, because /// it is a preference and not a secret; the session for it stays in the /// Keychain, keyed to its own origin. /// /// `-CallsServer ` on the command line overrides it for one run /// (UserDefaults' argument domain), which is how the UI tests point the /// app at their own server without touching the saved setting. public enum CallsServer { public static let defaultOrigin = "https://calls.oficina.works" public static let key = "CallsServer" /// What is wrong with an address, in words a person can act on. public enum Problem: Error, Equatable { case empty, notAnAddress, insecure, notJustTheServer, credentials public var message: String { switch self { case .empty: return "Enter your Calls server’s address." case .notAnAddress: return "That isn’t a web address. It should look like https://calls.example.com." case .insecure: return "The address must start with https://. Plain http:// works only for this device, for development." case .notJustTheServer: return "Enter just the server’s address, without a page or a meeting after it." case .credentials: return "Leave out any name or password. You sign in in your browser." } } } /// The server address as the app stores and compares it — scheme, host /// and any port, the way the server's own origin is written — or what /// is wrong with it. Whitespace and trailing slashes are forgiven, and /// an address with no scheme is taken to be https. /// /// https only, except http on loopback: the same rule callscore's /// transport enforces, so nothing is accepted here that it would refuse. public static func validate(_ text: String) -> Result { var text = text.trimmingCharacters(in: .whitespacesAndNewlines) while text.hasSuffix("/"), !text.hasSuffix("://") { text.removeLast() } if text.isEmpty { return .failure(.empty) } if !text.contains("://") { text = "https://" + text } guard let url = URLComponents(string: text), let scheme = url.scheme?.lowercased(), let host = url.host?.lowercased(), !host.isEmpty, !host.contains(" ") else { return .failure(.notAnAddress) } if url.user != nil || url.password != nil { return .failure(.credentials) } if !url.path.isEmpty || url.query != nil || url.fragment != nil { return .failure(.notJustTheServer) } guard scheme == "https" || (scheme == "http" && isLoopback(host)) else { return .failure(scheme == "http" ? .insecure : .notAnAddress) } let written = host.contains(":") ? "[\(host)]" : host return .success("\(scheme)://\(written)" + (url.port.map { ":\($0)" } ?? "")) } static func isLoopback(_ host: String) -> Bool { ["localhost", "127.0.0.1", "::1"].contains(host) } /// The saved server, or the default when nothing valid is saved. public static func load(from defaults: UserDefaults) -> String { guard let saved = defaults.string(forKey: key), case .success(let origin) = validate(saved) else { return defaultOrigin } return origin } /// Saves a validated origin. The default is saved as no setting at all, /// so a future change of default reaches everyone who never chose. static func save(_ origin: String, to defaults: UserDefaults) { if origin == defaultOrigin { defaults.removeObject(forKey: key) } else { defaults.set(origin, forKey: key) } } /// The host alone, for a line that names the server. public static func host(of origin: String) -> String { URLComponents(string: origin)?.host ?? origin } } /// A meeting or invitation link — `/m/{sqid}` or `/j/{token}` on some /// Calls server — checked before anything is done with it. public enum MeetingLink { public enum Problem: Error, Equatable { case empty, notALink, insecure, credentials, notACall public var message: String { switch self { case .empty: return "Paste the invite link you were sent." case .notALink: return "That isn’t a link. Paste the whole invite link, starting with https://." case .insecure: return "Calls invite links start with https://." case .credentials: return "That link has a name or password in it, which a Calls invite never does." case .notACall: return "That isn’t a Calls invite link. Invite links end in /j/… or /m/… ." } } } /// The link, or what is wrong with it. `allowLoopback` admits plain /// http to this device (a development server), as callscore does; a /// link arriving from outside the app through `oficina-calls://` never /// gets that allowance. public static func validate(_ text: String, allowLoopback: Bool = true) -> Result { var text = text.trimmingCharacters(in: .whitespacesAndNewlines) if text.isEmpty { return .failure(.empty) } // A link copied without its scheme — "calls.example.com/j/…" — is // taken to be https, as a browser would. if !text.contains("://"), !text.contains(" ") { text = "https://" + text } guard let url = URL(string: text), let scheme = url.scheme?.lowercased(), let host = url.host, !host.isEmpty else { return .failure(.notALink) } guard scheme == "https" || (scheme == "http" && allowLoopback && CallsServer.isLoopback(host.lowercased())) else { return .failure(scheme == "http" ? .insecure : .notALink) } if url.user != nil || url.password != nil { return .failure(.credentials) } let path = url.path.split(separator: "/") guard url.query == nil, url.fragment == nil, path.count == 2, path[0] == "m" || path[0] == "j" else { return .failure(.notACall) } return .success(url) } }