mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 02:17:46 +00:00
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:
@@ -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" : {
|
||||||
|
|||||||
@@ -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,31 +83,62 @@ 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 {
|
||||||
return .success(InstanceDetectionResult(type: .peertube))
|
if try await isPeerTube(url: url, extraHeaders: extraHeaders) {
|
||||||
|
return .success(InstanceDetectionResult(type: .peertube))
|
||||||
|
}
|
||||||
|
} catch APIError.unauthorized {
|
||||||
|
return .failure(unauthorizedError)
|
||||||
|
} catch {
|
||||||
|
// Continue to next detection method
|
||||||
}
|
}
|
||||||
|
|
||||||
if await isInvidious(url: url) {
|
do {
|
||||||
return .success(InstanceDetectionResult(type: .invidious))
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
if await isPiped(url: url) {
|
do {
|
||||||
return .success(InstanceDetectionResult(type: .piped))
|
if try await isPiped(url: url, extraHeaders: extraHeaders) {
|
||||||
|
return .success(InstanceDetectionResult(type: .piped))
|
||||||
|
}
|
||||||
|
} catch APIError.unauthorized {
|
||||||
|
return .failure(unauthorizedError)
|
||||||
|
} catch {
|
||||||
|
// Fall through
|
||||||
}
|
}
|
||||||
|
|
||||||
return .failure(.unknownType)
|
return .failure(.unknownType)
|
||||||
@@ -105,21 +146,17 @@ actor InstanceDetector {
|
|||||||
|
|
||||||
// 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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user