gitbay/Networking/GitbayInstance.swift
71 lines · 2784 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 guard let url = components.url else { throw InvalidURL.notAURL(trimmed) }
44 self.baseURL = url
45 }
46
47 private static func isLoopback(_ host: String) -> Bool {
48 host == "localhost" || host == "127.0.0.1" || host == "::1"
49 }
50
51 /// `GET /api/v1/read` — argv arrives as repeated query parameters.
52 func readURL(argv: [String]) -> URL {
53 var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
54 components.path = "/api/v1/read"
55 components.queryItems = argv.map { URLQueryItem(name: "argv", value: $0) }
56 return components.url!
57 }
58
59 /// `POST /api/v1/cmd`
60 var cmdURL: URL {
61 baseURL.appending(path: "/api/v1/cmd")
62 }
63
64 /// Whether a URL is on this instance. Checked before the Authorization
65 /// header goes on a request, and again on every redirect.
66 func isOwn(_ url: URL) -> Bool {
67 url.scheme?.lowercased() == baseURL.scheme
68 && url.host()?.lowercased() == baseURL.host()?.lowercased()
69 && url.port == baseURL.port
70 }
71}