139 lines
5.1 KiB
Swift
139 lines
5.1 KiB
Swift
//
|
|
// ServerAddress.swift
|
|
//
|
|
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
//
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// Controls whether server discovery may try plaintext HTTP after HTTPS fails.
|
|
package enum ServerSchemePolicy: Sendable, Hashable {
|
|
/// Try HTTPS first and permit HTTP fallback only for an unprefixed address.
|
|
case automatic
|
|
/// Use HTTPS only, preserving any port supplied by the user.
|
|
case httpsOnly
|
|
}
|
|
|
|
/// A validated Jellyfin server base URL and its scheme provenance.
|
|
package struct ServerAddress: Sendable, Hashable {
|
|
/// The normalized base URL, including a trailing slash.
|
|
package let baseURL: URL
|
|
/// Whether the user supplied a scheme in the original address.
|
|
package let schemeWasExplicit: Bool
|
|
|
|
private let allowsInsecureFallback: Bool
|
|
|
|
/// The candidates to probe, ordered from preferred to fallback URL.
|
|
package var probeCandidates: [URL] {
|
|
allowsInsecureFallback ? [baseURL, baseURL.withScheme("http")] : [baseURL]
|
|
}
|
|
|
|
/// Normalizes free-text onboarding fields into a validated server address.
|
|
///
|
|
/// - Parameters:
|
|
/// - address: A host name, IP address, or HTTP(S) URL, optionally with a path and port.
|
|
/// - port: An optional separate TCP port field.
|
|
/// - policy: The scheme policy selected by the user.
|
|
/// - Returns: A validated server address whose base URL is ready for probing.
|
|
/// - Throws: `ServerAddressError` when the input is empty, malformed, unsupported, or conflicting.
|
|
package static func normalize(
|
|
address rawAddress: String,
|
|
port rawPort: String,
|
|
policy: ServerSchemePolicy
|
|
) throws(ServerAddressError) -> ServerAddress {
|
|
let address = rawAddress.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let portText = rawPort.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !address.isEmpty else {
|
|
throw .emptyAddress
|
|
}
|
|
|
|
let schemeWasExplicit =
|
|
address.range(
|
|
of: #"^[A-Za-z][A-Za-z0-9+.-]*://"#,
|
|
options: .regularExpression
|
|
) != nil
|
|
let parseable = schemeWasExplicit ? address : "https://" + address
|
|
guard var components = URLComponents(string: parseable) else {
|
|
throw .malformedAddress
|
|
}
|
|
guard let parsedScheme = components.scheme?.lowercased() else {
|
|
throw .malformedAddress
|
|
}
|
|
guard parsedScheme == "http" || parsedScheme == "https" else {
|
|
throw .unsupportedScheme(parsedScheme)
|
|
}
|
|
guard let host = components.host, !host.isEmpty else {
|
|
throw .missingHost
|
|
}
|
|
guard components.user == nil, components.password == nil else {
|
|
throw .credentialsNotAllowed
|
|
}
|
|
guard components.query == nil, components.fragment == nil else {
|
|
throw .queryOrFragmentNotAllowed
|
|
}
|
|
|
|
let addressPort = components.port
|
|
let enteredPort: Int?
|
|
if portText.isEmpty {
|
|
enteredPort = nil
|
|
} else {
|
|
guard let value = Int(portText), (1...65_535).contains(value) else {
|
|
throw .invalidPort(portText)
|
|
}
|
|
enteredPort = value
|
|
}
|
|
if addressPort != nil && enteredPort != nil {
|
|
throw .conflictingPorts
|
|
}
|
|
|
|
var chosenPort = enteredPort ?? addressPort
|
|
if enteredPort == nil,
|
|
(parsedScheme == "http" && chosenPort == 80) || (parsedScheme == "https" && chosenPort == 443)
|
|
{
|
|
chosenPort = nil
|
|
}
|
|
components.scheme = policy == .httpsOnly ? "https" : parsedScheme
|
|
components.port = chosenPort
|
|
var path = components.path
|
|
if path.isEmpty {
|
|
path = "/"
|
|
} else if !path.hasSuffix("/") {
|
|
path += "/"
|
|
}
|
|
components.path = path
|
|
guard let baseURL = components.url else {
|
|
throw .malformedAddress
|
|
}
|
|
|
|
return ServerAddress(
|
|
baseURL: baseURL,
|
|
schemeWasExplicit: schemeWasExplicit,
|
|
allowsInsecureFallback: !schemeWasExplicit && policy == .automatic
|
|
)
|
|
}
|
|
}
|
|
|
|
private extension URL {
|
|
func withScheme(_ scheme: String) -> URL {
|
|
guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else {
|
|
return self
|
|
}
|
|
components.scheme = scheme
|
|
return components.url ?? self
|
|
}
|
|
}
|