gitbay/Networking/GitbayInstance.swift
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}