Files
yattee/Yattee/Services/API/InstanceDetector.swift
Arkadiusz Fal eefd49f743 Fix three basic-auth regressions surfaced by end-to-end testing
- InstanceDetector: a single 401 from one probe was over-eagerly concluded
  as "credentials invalid" / "credentials required". On instances behind a
  reverse proxy where one probe path (e.g. Yattee Server's /info) hits a
  same-origin redirect, iOS URLSession strips the Authorization header on
  the redirect and the request 401s even with valid credentials. Track 401s
  across all probes and only conclude basicAuthRequired/basicAuthInvalid
  when no probe matched and at least one returned 401.

- InstanceLoginView: the Invidious/Piped login flow constructed an API
  client backed by the shared appEnvironment.httpClient, which has no
  per-instance basic-auth headers. For instances behind a reverse proxy,
  the login POST 401d before reaching the upstream login endpoint. Build a
  per-instance HTTPClient with the basic-auth Authorization header baked in
  via setDefaultHeaders, mirroring ContentService.httpClientWithBasicAuth.

- InvidiousAPI.login: the login function constructs its own URLSession (to
  capture Set-Cookie via a redirect-blocking delegate), so it never
  inherits headers from the injected httpClient. Add an optional
  extraHeaders parameter and have InstanceLoginView pass the basic-auth
  header through when present. PipedAPI.login uses httpClient.fetch and
  inherits defaultHeaders correctly, so no change is needed there.
2026-04-18 20:38:00 +02:00

444 lines
17 KiB
Swift

//
// InstanceDetector.swift
// Yattee
//
// Automatically detects backend instance type by probing API endpoints.
//
import Foundation
/// Errors that can occur during instance detection.
enum DetectionError: Error, Sendable {
case sslCertificateError
case networkError(String)
case unknownType
case invalidURL
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 {
switch self {
case .sslCertificateError:
return String(localized: "sources.error.sslCertificate")
case .networkError(let message):
return message
case .unknownType:
return String(localized: "sources.error.couldNotDetect")
case .invalidURL:
return String(localized: "sources.validation.invalidURL")
case .timeout:
return String(localized: "sources.error.timeout")
case .basicAuthRequired:
return String(localized: "sources.error.basicAuthRequired")
case .basicAuthInvalid:
return String(localized: "sources.error.basicAuthInvalid")
}
}
}
/// Result of instance detection including type and authentication requirements.
struct InstanceDetectionResult: Sendable {
let type: InstanceType
/// Whether this instance requires authentication (Basic Auth for Yattee Server).
let requiresAuth: Bool
init(type: InstanceType, requiresAuth: Bool = false) {
self.type = type
self.requiresAuth = requiresAuth
}
}
/// Detects the type of a backend instance by probing known API endpoints.
actor InstanceDetector {
private let httpClient: HTTPClient
init(httpClient: HTTPClient) {
self.httpClient = httpClient
}
/// Detects the instance type for a given URL.
/// - Parameter url: The base URL of the instance.
/// - Returns: The detected instance type, or nil if detection failed.
func detect(url: URL) async -> InstanceType? {
let result = await detectWithAuth(url: url)
return result?.type
}
/// Detects the instance type and authentication requirements for a given URL.
/// - Parameter url: The base URL of the instance.
/// - Returns: The detection result including type and auth requirements, or nil if detection failed.
func detectWithAuth(url: URL) async -> InstanceDetectionResult? {
let result = await detectWithResult(url: url)
switch result {
case .success(let detectionResult):
return detectionResult
case .failure:
return nil
}
}
/// Detects the instance type with detailed error reporting.
/// - 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.
func detectWithResult(
url: URL,
basicAuthHeader: String? = nil
) async -> Result<InstanceDetectionResult, DetectionError> {
let extraHeaders: [String: String]? = basicAuthHeader.map { ["Authorization": $0] }
// A 401 from a single probe is *not* enough to conclude that credentials are
// invalid. Some probe paths (e.g. Yattee Server's `/info`) trigger an HTTP
// redirect on Invidious, and iOS URLSession strips the Authorization header
// when following redirects, so the redirected request 401s even when the
// credentials are valid. We instead consider credentials bad only if EVERY
// probe failed with 401 and none matched.
var sawUnauthorized = false
// Try each detection method in order of specificity.
// Check Yattee Server first as it's most specific.
do {
if let result = try await detectYatteeServerWithError(url: url, extraHeaders: extraHeaders) {
return .success(result)
}
} catch let error as DetectionError {
return .failure(error)
} catch APIError.unauthorized {
sawUnauthorized = true
} catch {
// Continue to next detection method
}
do {
if try await isPeerTube(url: url, extraHeaders: extraHeaders) {
return .success(InstanceDetectionResult(type: .peertube))
}
} catch APIError.unauthorized {
sawUnauthorized = true
} catch {
// Continue to next detection method
}
do {
if try await isInvidious(url: url, extraHeaders: extraHeaders) {
return .success(InstanceDetectionResult(type: .invidious))
}
} catch APIError.unauthorized {
sawUnauthorized = true
} catch {
// Continue to next detection method
}
do {
if try await isPiped(url: url, extraHeaders: extraHeaders) {
return .success(InstanceDetectionResult(type: .piped))
}
} catch APIError.unauthorized {
sawUnauthorized = true
} catch {
// Fall through
}
// No probe matched. If at least one probe returned 401, the instance is
// (or appears to be) behind HTTP Basic Auth. Distinguish "needs creds" from
// "creds rejected" by whether the caller already supplied a header.
if sawUnauthorized {
return .failure(basicAuthHeader == nil ? .basicAuthRequired : .basicAuthInvalid)
}
return .failure(.unknownType)
}
// MARK: - Detection Methods
/// Detects if the instance is a Yattee Server with detailed error reporting.
/// 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")
do {
// First, get raw data to debug the response
let rawData = try await httpClient.fetchData(endpoint, baseURL: url, customHeaders: extraHeaders)
if let rawString = String(data: rawData, encoding: .utf8) {
LoggingService.shared.debug("[InstanceDetector] Raw /info response: \(rawString)", category: .api)
}
let response = try JSONDecoder().decode(InstanceDetectorModels.YatteeServerInfo.self, from: rawData)
LoggingService.shared.debug("[InstanceDetector] Parsed YatteeServerInfo: name=\(response.name ?? "nil")", category: .api)
// Yattee Server returns name containing "yattee"
if response.name?.lowercased().contains("yattee") == true {
// Auth is always required for Yattee Server
let result = InstanceDetectionResult(
type: .yatteeServer,
requiresAuth: true
)
LoggingService.shared.debug("[InstanceDetector] Returning result: type=yatteeServer, requiresAuth=true", category: .api)
return result
}
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 {
LoggingService.shared.error("[InstanceDetector] detectYatteeServer URLError", category: .api, details: urlError.localizedDescription)
// Check for SSL certificate errors
if urlError.code == .serverCertificateUntrusted ||
urlError.code == .serverCertificateHasBadDate ||
urlError.code == .serverCertificateHasUnknownRoot ||
urlError.code == .serverCertificateNotYetValid ||
urlError.code == .clientCertificateRejected {
throw DetectionError.sslCertificateError
}
if urlError.code == .timedOut {
throw DetectionError.timeout
}
throw DetectionError.networkError(urlError.localizedDescription)
} catch {
LoggingService.shared.error("[InstanceDetector] detectYatteeServer error", category: .api, details: error.localizedDescription)
return nil
}
}
/// Checks if the instance is PeerTube by calling /api/v1/config.
/// 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")
do {
let response: InstanceDetectorModels.PeerTubeConfig = try await httpClient.fetch(endpoint, baseURL: url, customHeaders: extraHeaders)
// PeerTube config has specific fields
return response.instance != nil || response.serverVersion != nil
} catch APIError.unauthorized {
throw APIError.unauthorized
} catch {
return false
}
}
/// Checks if the instance is Invidious by calling /api/v1/stats.
/// 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")
do {
let response: InstanceDetectorModels.InvidiousStats = try await httpClient.fetch(endpoint, baseURL: url, customHeaders: extraHeaders)
// Invidious stats has software.name = "invidious"
return response.software?.name?.lowercased() == "invidious"
} catch APIError.unauthorized {
throw APIError.unauthorized
} catch {
return false
}
}
/// Checks if the instance is Piped by probing Piped-specific endpoints.
/// 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"
let healthEndpoint = GenericEndpoint.get("/healthcheck")
do {
let data = try await httpClient.fetchData(healthEndpoint, baseURL: url, customHeaders: extraHeaders)
if let text = String(data: data, encoding: .utf8), text.contains("OK") {
return true
}
} catch APIError.unauthorized {
throw APIError.unauthorized
} catch {
// Continue to next check
}
// Also try /config endpoint which Piped uses
let configEndpoint = GenericEndpoint.get("/config")
do {
let response: InstanceDetectorModels.PipedConfig = try await httpClient.fetch(configEndpoint, baseURL: url, customHeaders: extraHeaders)
// Piped config has specific fields
return response.donationUrl != nil || response.statusPageUrl != nil
} catch APIError.unauthorized {
throw APIError.unauthorized
} catch {
return false
}
}
}
// MARK: - Detection Response Models
/// Namespace for instance detection response models.
/// Using an enum as a namespace to avoid MainActor isolation issues.
enum InstanceDetectorModels {
struct YatteeServerInfo: Sendable {
let name: String?
let version: String?
let description: String?
}
/// Full server info response from /info endpoint for display in UI.
struct YatteeServerFullInfo: Sendable {
let name: String?
let version: String?
let dependencies: Dependencies?
let config: Config?
let sites: [Site]?
struct Dependencies: Sendable {
let ytDlp: String?
let ffmpeg: String?
}
struct Config: Sendable {
let invidiousInstance: String?
let allowAllSitesForExtraction: Bool?
}
struct Site: Sendable {
let name: String
let extractorPattern: String?
}
}
struct PeerTubeConfig: Sendable {
let instance: PeerTubeInstanceInfo?
let serverVersion: String?
struct PeerTubeInstanceInfo: Sendable {
let name: String?
let shortDescription: String?
}
}
struct InvidiousStats: Sendable {
let software: InvidiousSoftware?
struct InvidiousSoftware: Sendable {
let name: String?
let version: String?
}
}
struct PipedConfig: Sendable {
let donationUrl: String?
let statusPageUrl: String?
let s3Enabled: Bool?
}
}
// MARK: - Decodable Conformance (nonisolated)
extension InstanceDetectorModels.YatteeServerInfo: Decodable {
private enum CodingKeys: String, CodingKey {
case name, version, description
}
nonisolated init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decodeIfPresent(String.self, forKey: .name)
version = try container.decodeIfPresent(String.self, forKey: .version)
description = try container.decodeIfPresent(String.self, forKey: .description)
}
}
extension InstanceDetectorModels.YatteeServerFullInfo: Decodable {
private enum CodingKeys: String, CodingKey {
case name, version, dependencies, config, sites
}
nonisolated init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decodeIfPresent(String.self, forKey: .name)
version = try container.decodeIfPresent(String.self, forKey: .version)
dependencies = try container.decodeIfPresent(Dependencies.self, forKey: .dependencies)
config = try container.decodeIfPresent(Config.self, forKey: .config)
sites = try container.decodeIfPresent([Site].self, forKey: .sites)
}
}
extension InstanceDetectorModels.YatteeServerFullInfo.Dependencies: Decodable {
private enum CodingKeys: String, CodingKey {
case ytDlp = "yt-dlp"
case ffmpeg
}
nonisolated init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
ytDlp = try container.decodeIfPresent(String.self, forKey: .ytDlp)
ffmpeg = try container.decodeIfPresent(String.self, forKey: .ffmpeg)
}
}
// Config and Site use automatic Decodable synthesis since HTTPClient uses .convertFromSnakeCase
extension InstanceDetectorModels.YatteeServerFullInfo.Config: Decodable {}
extension InstanceDetectorModels.YatteeServerFullInfo.Site: Decodable {}
extension InstanceDetectorModels.PeerTubeConfig: Decodable {
private enum CodingKeys: String, CodingKey {
case instance, serverVersion
}
nonisolated init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
instance = try container.decodeIfPresent(PeerTubeInstanceInfo.self, forKey: .instance)
serverVersion = try container.decodeIfPresent(String.self, forKey: .serverVersion)
}
}
extension InstanceDetectorModels.PeerTubeConfig.PeerTubeInstanceInfo: Decodable {
private enum CodingKeys: String, CodingKey {
case name, shortDescription
}
nonisolated init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decodeIfPresent(String.self, forKey: .name)
shortDescription = try container.decodeIfPresent(String.self, forKey: .shortDescription)
}
}
extension InstanceDetectorModels.InvidiousStats: Decodable {
private enum CodingKeys: String, CodingKey {
case software
}
nonisolated init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
software = try container.decodeIfPresent(InvidiousSoftware.self, forKey: .software)
}
}
extension InstanceDetectorModels.InvidiousStats.InvidiousSoftware: Decodable {
private enum CodingKeys: String, CodingKey {
case name, version
}
nonisolated init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decodeIfPresent(String.self, forKey: .name)
version = try container.decodeIfPresent(String.self, forKey: .version)
}
}
extension InstanceDetectorModels.PipedConfig: Decodable {
private enum CodingKeys: String, CodingKey {
case donationUrl, statusPageUrl, s3Enabled
}
nonisolated init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
donationUrl = try container.decodeIfPresent(String.self, forKey: .donationUrl)
statusPageUrl = try container.decodeIfPresent(String.self, forKey: .statusPageUrl)
s3Enabled = try container.decodeIfPresent(Bool.self, forKey: .s3Enabled)
}
}