luminate/Sources/LuminateCore/Models/ServerAddress.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
}
}