Surface 401 from instance detection so the user can supply credentials

When an instance sits behind a reverse proxy that requires HTTP Basic Auth,
every detection probe (/info, /api/v1/config, /api/v1/stats, /healthcheck,
/config) returns 401 before reaching the real backend, so the type cannot be
identified. Re-throw APIError.unauthorized from each probe instead of
swallowing it, and have detectWithResult convert the first 401 it sees into
DetectionError.basicAuthRequired. Add a basicAuthHeader parameter so the
caller can retry detection after the user provides credentials; if a retry
also returns 401, surface basicAuthInvalid instead.
This commit is contained in:
Arkadiusz Fal
2026-04-06 19:56:34 +02:00
parent 63f1cb1f25
commit 222b53d520
2 changed files with 102 additions and 29 deletions

View File

@@ -14593,6 +14593,28 @@
} }
} }
}, },
"sources.error.basicAuthInvalid" : {
"comment" : "Error when HTTP Basic Auth credentials are rejected by the server",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Invalid HTTP Basic Auth credentials. Check the username and password."
}
}
}
},
"sources.error.basicAuthRequired" : {
"comment" : "Error when an instance is fronted by HTTP Basic Auth and credentials are required to detect its type",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "This instance requires HTTP Basic Auth. Enter a username and password to continue."
}
}
}
},
"sources.error.couldNotDetect" : { "sources.error.couldNotDetect" : {
"comment" : "Error when source type could not be detected", "comment" : "Error when source type could not be detected",
"localizations" : { "localizations" : {

View File

@@ -14,6 +14,12 @@ enum DetectionError: Error, Sendable {
case unknownType case unknownType
case invalidURL case invalidURL
case timeout case timeout
/// The instance is fronted by an HTTP Basic Auth challenge (401). The user must
/// supply credentials before detection can identify the backend type.
case basicAuthRequired
/// Detection was retried with HTTP Basic Auth credentials but the server still
/// returned 401 the credentials are invalid.
case basicAuthInvalid
var localizedDescription: String { var localizedDescription: String {
switch self { switch self {
@@ -27,6 +33,10 @@ enum DetectionError: Error, Sendable {
return String(localized: "sources.validation.invalidURL") return String(localized: "sources.validation.invalidURL")
case .timeout: case .timeout:
return String(localized: "sources.error.timeout") return String(localized: "sources.error.timeout")
case .basicAuthRequired:
return String(localized: "sources.error.basicAuthRequired")
case .basicAuthInvalid:
return String(localized: "sources.error.basicAuthInvalid")
} }
} }
} }
@@ -73,53 +83,80 @@ actor InstanceDetector {
} }
/// Detects the instance type with detailed error reporting. /// Detects the instance type with detailed error reporting.
/// - Parameter url: The base URL of the instance. /// - Parameters:
/// - url: The base URL of the instance.
/// - basicAuthHeader: Optional HTTP Basic Auth header value (e.g., "Basic dXNlcjpwYXNz")
/// to inject into every probe. Used to retry detection after the user provides
/// credentials for an instance fronted by a reverse proxy.
/// - Returns: Result containing either the detection result or a detailed error. /// - Returns: Result containing either the detection result or a detailed error.
func detectWithResult(url: URL) async -> Result<InstanceDetectionResult, DetectionError> { func detectWithResult(
url: URL,
basicAuthHeader: String? = nil
) async -> Result<InstanceDetectionResult, DetectionError> {
let extraHeaders: [String: String]? = basicAuthHeader.map { ["Authorization": $0] }
// If we already supplied credentials and still get 401, those credentials are wrong.
let unauthorizedError: DetectionError = basicAuthHeader == nil ? .basicAuthRequired : .basicAuthInvalid
// Try each detection method in order of specificity // Try each detection method in order of specificity
// Check Yattee Server first as it's most specific // Check Yattee Server first as it's most specific
do { do {
if let result = try await detectYatteeServerWithError(url: url) { if let result = try await detectYatteeServerWithError(url: url, extraHeaders: extraHeaders) {
return .success(result) return .success(result)
} }
} catch let error as DetectionError { } catch let error as DetectionError {
return .failure(error) return .failure(error)
} catch APIError.unauthorized {
return .failure(unauthorizedError)
} catch { } catch {
// Continue to next detection method // Continue to next detection method
} }
if await isPeerTube(url: url) { do {
if try await isPeerTube(url: url, extraHeaders: extraHeaders) {
return .success(InstanceDetectionResult(type: .peertube)) return .success(InstanceDetectionResult(type: .peertube))
} }
} catch APIError.unauthorized {
if await isInvidious(url: url) { return .failure(unauthorizedError)
return .success(InstanceDetectionResult(type: .invidious)) } catch {
// Continue to next detection method
} }
if await isPiped(url: url) { do {
if try await isInvidious(url: url, extraHeaders: extraHeaders) {
return .success(InstanceDetectionResult(type: .invidious))
}
} catch APIError.unauthorized {
return .failure(unauthorizedError)
} catch {
// Continue to next detection method
}
do {
if try await isPiped(url: url, extraHeaders: extraHeaders) {
return .success(InstanceDetectionResult(type: .piped)) return .success(InstanceDetectionResult(type: .piped))
} }
} catch APIError.unauthorized {
return .failure(unauthorizedError)
} catch {
// Fall through
}
return .failure(.unknownType) return .failure(.unknownType)
} }
// MARK: - Detection Methods // MARK: - Detection Methods
/// Detects if the instance is a Yattee Server.
/// Auth is always required for Yattee Server (after initial setup).
/// - Parameter url: The base URL to check.
/// - Returns: Detection result with type (always requiresAuth=true), or nil if not a Yattee Server.
private func detectYatteeServer(url: URL) async -> InstanceDetectionResult? {
try? await detectYatteeServerWithError(url: url)
}
/// Detects if the instance is a Yattee Server with detailed error reporting. /// Detects if the instance is a Yattee Server with detailed error reporting.
private func detectYatteeServerWithError(url: URL) async throws -> InstanceDetectionResult? { /// Throws `APIError.unauthorized` if the probe receives a 401, so the caller can prompt for credentials.
private func detectYatteeServerWithError(
url: URL,
extraHeaders: [String: String]? = nil
) async throws -> InstanceDetectionResult? {
let endpoint = GenericEndpoint.get("/info") let endpoint = GenericEndpoint.get("/info")
do { do {
// First, get raw data to debug the response // First, get raw data to debug the response
let rawData = try await httpClient.fetchData(endpoint, baseURL: url) let rawData = try await httpClient.fetchData(endpoint, baseURL: url, customHeaders: extraHeaders)
if let rawString = String(data: rawData, encoding: .utf8) { if let rawString = String(data: rawData, encoding: .utf8) {
LoggingService.shared.debug("[InstanceDetector] Raw /info response: \(rawString)", category: .api) LoggingService.shared.debug("[InstanceDetector] Raw /info response: \(rawString)", category: .api)
} }
@@ -138,6 +175,9 @@ actor InstanceDetector {
return result return result
} }
return nil return nil
} catch APIError.unauthorized {
LoggingService.shared.debug("[InstanceDetector] /info returned 401 — basic auth required", category: .api)
throw APIError.unauthorized
} catch let urlError as URLError { } catch let urlError as URLError {
LoggingService.shared.error("[InstanceDetector] detectYatteeServer URLError", category: .api, details: urlError.localizedDescription) LoggingService.shared.error("[InstanceDetector] detectYatteeServer URLError", category: .api, details: urlError.localizedDescription)
// Check for SSL certificate errors // Check for SSL certificate errors
@@ -158,42 +198,51 @@ actor InstanceDetector {
} }
} }
/// Checks if the instance is PeerTube by calling /api/v1/config /// Checks if the instance is PeerTube by calling /api/v1/config.
private func isPeerTube(url: URL) async -> Bool { /// Re-throws `APIError.unauthorized` so the caller can prompt for basic-auth credentials.
private func isPeerTube(url: URL, extraHeaders: [String: String]? = nil) async throws -> Bool {
let endpoint = GenericEndpoint.get("/api/v1/config") let endpoint = GenericEndpoint.get("/api/v1/config")
do { do {
let response: InstanceDetectorModels.PeerTubeConfig = try await httpClient.fetch(endpoint, baseURL: url) let response: InstanceDetectorModels.PeerTubeConfig = try await httpClient.fetch(endpoint, baseURL: url, customHeaders: extraHeaders)
// PeerTube config has specific fields // PeerTube config has specific fields
return response.instance != nil || response.serverVersion != nil return response.instance != nil || response.serverVersion != nil
} catch APIError.unauthorized {
throw APIError.unauthorized
} catch { } catch {
return false return false
} }
} }
/// Checks if the instance is Invidious by calling /api/v1/stats /// Checks if the instance is Invidious by calling /api/v1/stats.
private func isInvidious(url: URL) async -> Bool { /// Re-throws `APIError.unauthorized` so the caller can prompt for basic-auth credentials.
private func isInvidious(url: URL, extraHeaders: [String: String]? = nil) async throws -> Bool {
let endpoint = GenericEndpoint.get("/api/v1/stats") let endpoint = GenericEndpoint.get("/api/v1/stats")
do { do {
let response: InstanceDetectorModels.InvidiousStats = try await httpClient.fetch(endpoint, baseURL: url) let response: InstanceDetectorModels.InvidiousStats = try await httpClient.fetch(endpoint, baseURL: url, customHeaders: extraHeaders)
// Invidious stats has software.name = "invidious" // Invidious stats has software.name = "invidious"
return response.software?.name?.lowercased() == "invidious" return response.software?.name?.lowercased() == "invidious"
} catch APIError.unauthorized {
throw APIError.unauthorized
} catch { } catch {
return false return false
} }
} }
/// Checks if the instance is Piped by probing Piped-specific endpoints /// Checks if the instance is Piped by probing Piped-specific endpoints.
private func isPiped(url: URL) async -> Bool { /// Re-throws `APIError.unauthorized` so the caller can prompt for basic-auth credentials.
private func isPiped(url: URL, extraHeaders: [String: String]? = nil) async throws -> Bool {
// Piped has a /healthcheck endpoint that returns "OK" // Piped has a /healthcheck endpoint that returns "OK"
let healthEndpoint = GenericEndpoint.get("/healthcheck") let healthEndpoint = GenericEndpoint.get("/healthcheck")
do { do {
let data = try await httpClient.fetchData(healthEndpoint, baseURL: url) let data = try await httpClient.fetchData(healthEndpoint, baseURL: url, customHeaders: extraHeaders)
if let text = String(data: data, encoding: .utf8), text.contains("OK") { if let text = String(data: data, encoding: .utf8), text.contains("OK") {
return true return true
} }
} catch APIError.unauthorized {
throw APIError.unauthorized
} catch { } catch {
// Continue to next check // Continue to next check
} }
@@ -202,9 +251,11 @@ actor InstanceDetector {
let configEndpoint = GenericEndpoint.get("/config") let configEndpoint = GenericEndpoint.get("/config")
do { do {
let response: InstanceDetectorModels.PipedConfig = try await httpClient.fetch(configEndpoint, baseURL: url) let response: InstanceDetectorModels.PipedConfig = try await httpClient.fetch(configEndpoint, baseURL: url, customHeaders: extraHeaders)
// Piped config has specific fields // Piped config has specific fields
return response.donationUrl != nil || response.statusPageUrl != nil return response.donationUrl != nil || response.statusPageUrl != nil
} catch APIError.unauthorized {
throw APIError.unauthorized
} catch { } catch {
return false return false
} }