oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1import 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.
11public 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.
89public 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