diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 22bb4767..5e63730a 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -15083,6 +15083,17 @@ } } }, + "sources.error.pipedBasicAuthUnsupported" : { + "comment" : "Error when a Piped instance is detected behind an HTTP Basic Auth proxy. Piped uses the same Authorization header for the user session, so the two cannot coexist.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Piped sources can't be used behind an HTTP Basic Auth proxy. Piped reuses the Authorization header for the user session, so it conflicts with the proxy's credentials." + } + } + } + }, "sources.error.couldNotDetect" : { "comment" : "Error when source type could not be detected", "localizations" : { diff --git a/Yattee/Models/Instance.swift b/Yattee/Models/Instance.swift index 6d81a2de..55e2c4e0 100644 --- a/Yattee/Models/Instance.swift +++ b/Yattee/Models/Instance.swift @@ -182,6 +182,22 @@ extension Instance { var supportsVideoProxying: Bool { type == .invidious || type == .piped || type == .yatteeServer } + + /// Whether this instance can sit behind an HTTP Basic Auth reverse proxy. + /// Piped is excluded: its session token is sent in the same `Authorization` + /// header that the proxy would consume, so logged-in features can't coexist + /// with proxy credentials. + var supportsHTTPBasicAuthProxy: Bool { + type.supportsHTTPBasicAuthProxy + } +} + +extension InstanceType { + /// See `Instance.supportsHTTPBasicAuthProxy`. Type-only variant for flows + /// (e.g., AddRemoteServer detection) that don't yet have an `Instance`. + var supportsHTTPBasicAuthProxy: Bool { + self != .piped + } } // MARK: - Instance Validation diff --git a/Yattee/Views/Settings/AddSource/AddRemoteServerView.swift b/Yattee/Views/Settings/AddSource/AddRemoteServerView.swift index 187a22f6..ae59523b 100644 --- a/Yattee/Views/Settings/AddSource/AddRemoteServerView.swift +++ b/Yattee/Views/Settings/AddSource/AddRemoteServerView.swift @@ -439,11 +439,13 @@ struct AddRemoteServerView: View { } // HTTP Basic Auth credentials. - // Required for Yattee Server (always shown). Optional for other types — show only - // when credentials were already provided (e.g., via the basic-auth-required retry - // path), so we don't clutter the form for the normal "no proxy" case. - let showBasicAuthSection = detectedType == .yatteeServer - || (!basicAuthUsername.isEmpty || !basicAuthPassword.isEmpty) + // Required for Yattee Server (always shown). Optional for types that can sit + // behind a basic-auth proxy — show only when credentials were already provided + // (e.g., via the basic-auth-required retry path), so we don't clutter the form + // for the normal "no proxy" case. + let showBasicAuthSection = (detectedType?.supportsHTTPBasicAuthProxy ?? false) && + (detectedType == .yatteeServer + || (!basicAuthUsername.isEmpty || !basicAuthPassword.isEmpty)) if showBasicAuthSection { Section { @@ -574,6 +576,14 @@ struct AddRemoteServerView: View { switch result { case .success(let detectionResult): LoggingService.shared.debug("[AddRemoteServerView] Detection succeeded: \(detectionResult.type)", category: .api) + + if basicAuthHeader != nil, !detectionResult.type.supportsHTTPBasicAuthProxy { + withAnimation { + self.uiState = .error(String(localized: "sources.error.pipedBasicAuthUnsupported")) + } + return + } + withAnimation { self.detectedType = detectionResult.type self.detectionResult = detectionResult @@ -764,7 +774,7 @@ struct AddRemoteServerView: View { allowInvalidCertificates: allowInvalidCertificates ) - if !basicAuthUsername.isEmpty && !basicAuthPassword.isEmpty { + if !basicAuthUsername.isEmpty, !basicAuthPassword.isEmpty { appEnvironment.basicAuthCredentialsManager.setCredentials( username: basicAuthUsername, password: basicAuthPassword, diff --git a/Yattee/Views/Settings/EditSourceView.swift b/Yattee/Views/Settings/EditSourceView.swift index 3b2d2478..e73a6fbd 100644 --- a/Yattee/Views/Settings/EditSourceView.swift +++ b/Yattee/Views/Settings/EditSourceView.swift @@ -129,35 +129,37 @@ private struct EditRemoteServerContent: View { #endif } - Section { - #if os(tvOS) - TVSettingsTextField(title: String(localized: "sources.field.username"), text: $basicAuthUsername) - TVSettingsTextField(title: String(localized: "sources.field.password"), text: $basicAuthPassword, isSecure: true) - #elseif os(macOS) - LabeledContent(String(localized: "sources.field.username")) { - TextField("", text: $basicAuthUsername) + if instance.supportsHTTPBasicAuthProxy { + Section { + #if os(tvOS) + TVSettingsTextField(title: String(localized: "sources.field.username"), text: $basicAuthUsername) + TVSettingsTextField(title: String(localized: "sources.field.password"), text: $basicAuthPassword, isSecure: true) + #elseif os(macOS) + LabeledContent(String(localized: "sources.field.username")) { + TextField("", text: $basicAuthUsername) + .textContentType(.username) + .autocorrectionDisabled() + } + LabeledContent(String(localized: "sources.field.password")) { + SecureField("", text: $basicAuthPassword) + .textContentType(.password) + } + #else + TextField(String(localized: "sources.field.username"), text: $basicAuthUsername) .textContentType(.username) + #if os(iOS) + .textInputAutocapitalization(.never) + #endif .autocorrectionDisabled() - } - LabeledContent(String(localized: "sources.field.password")) { - SecureField("", text: $basicAuthPassword) - .textContentType(.password) - } - #else - TextField(String(localized: "sources.field.username"), text: $basicAuthUsername) - .textContentType(.username) - #if os(iOS) - .textInputAutocapitalization(.never) - #endif - .autocorrectionDisabled() - SecureField(String(localized: "sources.field.password"), text: $basicAuthPassword) - .textContentType(.password) - #endif - } header: { - Text(String(localized: instance.type == .yatteeServer ? "sources.header.auth" : "sources.header.basicAuth")) - } footer: { - Text(String(localized: instance.type == .yatteeServer ? "sources.footer.yatteeServerAuth" : "sources.footer.basicAuth")) + SecureField(String(localized: "sources.field.password"), text: $basicAuthPassword) + .textContentType(.password) + #endif + } header: { + Text(String(localized: instance.type == .yatteeServer ? "sources.header.auth" : "sources.header.basicAuth")) + } footer: { + Text(String(localized: instance.type == .yatteeServer ? "sources.footer.yatteeServerAuth" : "sources.footer.basicAuth")) + } } if instance.type == .yatteeServer { @@ -452,16 +454,20 @@ private struct EditRemoteServerContent: View { updated.allowInvalidCertificates = allowInvalidCertificates updated.proxiesVideos = proxiesVideos - // Save / clear HTTP Basic Auth credentials. - // Works for any instance type, but for Yattee Server we never auto-clear - // (credentials are required there; the user can re-enter to overwrite). - if !basicAuthUsername.isEmpty && !basicAuthPassword.isEmpty { + // Save / clear HTTP Basic Auth credentials. Yattee Server never auto-clears + // (credentials are required and the user re-enters to overwrite). Piped never + // persists them — its session token reuses the same Authorization header. + if !instance.supportsHTTPBasicAuthProxy { + if hadStoredBasicAuth { + appEnvironment?.basicAuthCredentialsManager.deleteCredentials(for: instance) + } + } else if !basicAuthUsername.isEmpty, !basicAuthPassword.isEmpty { appEnvironment?.basicAuthCredentialsManager.setCredentials( username: basicAuthUsername, password: basicAuthPassword, for: instance ) - } else if hadStoredBasicAuth && instance.type != .yatteeServer { + } else if hadStoredBasicAuth, instance.type != .yatteeServer { appEnvironment?.basicAuthCredentialsManager.deleteCredentials(for: instance) }