gitbay/Networking/GitbayInstance.swift

v1.13.0
gitbay-ios/gitbay/Networking/GitbayInstance.swift history · blame · raw

85 lines · 3437 bytes

 1import Foundation
 2
 3/// A gitbay deployment. gitbay is self-hosted, so an account is only
 4/// meaningful paired with the instance it lives on.
 5nonisolated struct GitbayInstance: Sendable, Hashable, Codable {
 6
 7    /// Origin of the instance, e.g. `https://gitbay.org`. Path, query and
 8    /// fragment are stripped on init so endpoint construction cannot be
 9    /// steered by whatever the user pasted.
10    let baseURL: URL
11
12    enum InvalidURL: LocalizedError, Sendable {
13        case notAURL(String)
14        case insecureScheme(String)
15
16        var errorDescription: String? {
17            switch self {
18            case .notAURL(let text):
19                "\(text) is not a server address."
20            case .insecureScheme(let scheme):
21                "\(scheme):// would send your token in the clear. Use https."
22            }
23        }
24    }
25
26    init(url text: String) throws {
27        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
28        let withScheme = trimmed.contains("://") ? trimmed : "https://\(trimmed)"
29        guard var components = URLComponents(string: withScheme),
30              let host = components.host, !host.isEmpty else {
31            throw InvalidURL.notAURL(trimmed)
32        }
33        let scheme = (components.scheme ?? "https").lowercased()
34        // A bearer token over plaintext is a token given away. localhost is
35        // exempt so a self-hoster can point the app at their own machine.
36        guard scheme == "https" || (scheme == "http" && Self.isLoopback(host)) else {
37            throw InvalidURL.insecureScheme(scheme)
38        }
39        components.scheme = scheme
40        components.path = ""
41        components.query = nil
42        components.fragment = nil
43        // An explicit default port is the same origin as no port at all —
44        // URLComponents does not fold this on its own, and two instances
45        // typed with and without it must normalise to one baseURL.
46        if let port = components.port, (scheme == "https" && port == 443) || (scheme == "http" && port == 80) {
47            components.port = nil
48        }
49        guard let url = components.url else { throw InvalidURL.notAURL(trimmed) }
50        self.baseURL = url
51    }
52
53    private static func isLoopback(_ host: String) -> Bool {
54        host == "localhost" || host == "127.0.0.1" || host == "::1"
55    }
56
57    /// `GET /api/v1/read` — argv arrives as repeated query parameters.
58    func readURL(argv: [String]) -> URL {
59        var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
60        components.path = "/api/v1/read"
61        components.queryItems = argv.map { URLQueryItem(name: "argv", value: $0) }
62        return components.url!
63    }
64
65    /// `POST /api/v1/cmd`
66    var cmdURL: URL {
67        baseURL.appending(path: "/api/v1/cmd")
68    }
69
70    /// The web Settings → API tokens section.
71    var tokenPageURL: URL {
72        var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
73        components.path = "/settings"
74        components.fragment = "tokens"
75        return components.url!
76    }
77
78    /// Whether a URL is on this instance. Checked before the Authorization
79    /// header goes on a request, and again on every redirect.
80    func isOwn(_ url: URL) -> Bool {
81        url.scheme?.lowercased() == baseURL.scheme
82            && url.host()?.lowercased() == baseURL.host()?.lowercased()
83            && url.port == baseURL.port
84    }
85}