| 1 | import Foundation |
| 2 | |
| 3 | /// The Calls server this app signs in to: calls.oficina.works unless the |
| 4 | /// person has chosen another in Settings. Kept in UserDefaults, because |
| 5 | /// it is a preference and not a secret; the session for it stays in the |
| 6 | /// Keychain, keyed to its own origin. |
| 7 | /// |
| 8 | /// `-CallsServer <origin>` on the command line overrides it for one run |
| 9 | /// (UserDefaults' argument domain), which is how the UI tests point the |
| 10 | /// app at their own server without touching the saved setting. |
| 11 | public enum CallsServer { |
| 12 | public static let defaultOrigin = "https://calls.oficina.works" |
| 13 | public static let key = "CallsServer" |
| 14 | |
| 15 | /// What is wrong with an address, in words a person can act on. |
| 16 | public enum Problem: Error, Equatable { |
| 17 | case empty, notAnAddress, insecure, notJustTheServer, credentials |
| 18 | |
| 19 | public var message: String { |
| 20 | switch self { |
| 21 | case .empty: return "Enter your Calls server’s address." |
| 22 | case .notAnAddress: |
| 23 | return "That isn’t a web address. It should look like https://calls.example.com." |
| 24 | case .insecure: |
| 25 | return "The address must start with https://. Plain http:// works only for this device, for development." |
| 26 | case .notJustTheServer: |
| 27 | return "Enter just the server’s address, without a page or a meeting after it." |
| 28 | case .credentials: |
| 29 | return "Leave out any name or password. You sign in in your browser." |
| 30 | } |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | /// The server address as the app stores and compares it — scheme, host |
| 35 | /// and any port, the way the server's own origin is written — or what |
| 36 | /// is wrong with it. Whitespace and trailing slashes are forgiven, and |
| 37 | /// an address with no scheme is taken to be https. |
| 38 | /// |
| 39 | /// https only, except http on loopback: the same rule callscore's |
| 40 | /// transport enforces, so nothing is accepted here that it would refuse. |
| 41 | public static func validate(_ text: String) -> Result<String, Problem> { |
| 42 | var text = text.trimmingCharacters(in: .whitespacesAndNewlines) |
| 43 | while text.hasSuffix("/"), !text.hasSuffix("://") { text.removeLast() } |
| 44 | if text.isEmpty { return .failure(.empty) } |
| 45 | if !text.contains("://") { text = "https://" + text } |
| 46 | guard let url = URLComponents(string: text), let scheme = url.scheme?.lowercased(), |
| 47 | let host = url.host?.lowercased(), !host.isEmpty, !host.contains(" ") |
| 48 | else { return .failure(.notAnAddress) } |
| 49 | if url.user != nil || url.password != nil { return .failure(.credentials) } |
| 50 | if !url.path.isEmpty || url.query != nil || url.fragment != nil { |
| 51 | return .failure(.notJustTheServer) |
| 52 | } |
| 53 | guard scheme == "https" || (scheme == "http" && isLoopback(host)) else { |
| 54 | return .failure(scheme == "http" ? .insecure : .notAnAddress) |
| 55 | } |
| 56 | let written = host.contains(":") ? "[\(host)]" : host |
| 57 | return .success("\(scheme)://\(written)" + (url.port.map { ":\($0)" } ?? "")) |
| 58 | } |
| 59 | |
| 60 | static func isLoopback(_ host: String) -> Bool { |
| 61 | ["localhost", "127.0.0.1", "::1"].contains(host) |
| 62 | } |
| 63 | |
| 64 | /// The saved server, or the default when nothing valid is saved. |
| 65 | public static func load(from defaults: UserDefaults) -> String { |
| 66 | guard let saved = defaults.string(forKey: key), case .success(let origin) = validate(saved) |
| 67 | else { return defaultOrigin } |
| 68 | return origin |
| 69 | } |
| 70 | |
| 71 | /// Saves a validated origin. The default is saved as no setting at all, |
| 72 | /// so a future change of default reaches everyone who never chose. |
| 73 | static func save(_ origin: String, to defaults: UserDefaults) { |
| 74 | if origin == defaultOrigin { |
| 75 | defaults.removeObject(forKey: key) |
| 76 | } else { |
| 77 | defaults.set(origin, forKey: key) |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | /// The host alone, for a line that names the server. |
| 82 | public static func host(of origin: String) -> String { |
| 83 | URLComponents(string: origin)?.host ?? origin |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | /// A meeting or invitation link — `/m/{sqid}` or `/j/{token}` on some |
| 88 | /// Calls server — checked before anything is done with it. |
| 89 | public enum MeetingLink { |
| 90 | public enum Problem: Error, Equatable { |
| 91 | case empty, notALink, insecure, credentials, notACall |
| 92 | |
| 93 | public var message: String { |
| 94 | switch self { |
| 95 | case .empty: return "Paste the invite link you were sent." |
| 96 | case .notALink: |
| 97 | return "That isn’t a link. Paste the whole invite link, starting with https://." |
| 98 | case .insecure: return "Calls invite links start with https://." |
| 99 | case .credentials: |
| 100 | return "That link has a name or password in it, which a Calls invite never does." |
| 101 | case .notACall: |
| 102 | return "That isn’t a Calls invite link. Invite links end in /j/… or /m/… ." |
| 103 | } |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | /// The link, or what is wrong with it. `allowLoopback` admits plain |
| 108 | /// http to this device (a development server), as callscore does; a |
| 109 | /// link arriving from outside the app through `oficina-calls://` never |
| 110 | /// gets that allowance. |
| 111 | public static func validate(_ text: String, allowLoopback: Bool = true) -> Result<URL, Problem> { |
| 112 | var text = text.trimmingCharacters(in: .whitespacesAndNewlines) |
| 113 | if text.isEmpty { return .failure(.empty) } |
| 114 | // A link copied without its scheme — "calls.example.com/j/…" — is |
| 115 | // taken to be https, as a browser would. |
| 116 | if !text.contains("://"), !text.contains(" ") { text = "https://" + text } |
| 117 | guard let url = URL(string: text), let scheme = url.scheme?.lowercased(), |
| 118 | let host = url.host, !host.isEmpty |
| 119 | else { return .failure(.notALink) } |
| 120 | guard scheme == "https" |
| 121 | || (scheme == "http" && allowLoopback && CallsServer.isLoopback(host.lowercased())) |
| 122 | else { return .failure(scheme == "http" ? .insecure : .notALink) } |
| 123 | if url.user != nil || url.password != nil { return .failure(.credentials) } |
| 124 | let path = url.path.split(separator: "/") |
| 125 | guard url.query == nil, url.fragment == nil, path.count == 2, path[0] == "m" || path[0] == "j" |
| 126 | else { return .failure(.notACall) } |
| 127 | return .success(url) |
| 128 | } |
| 129 | } |
| 130 | |