Inject basic auth via per-instance HTTPClient default headers

Replace the YatteeServerAPI setAuthHeader/buildHeaders pattern (which was
race-prone on the shared actor across multiple instances) with a generic
mechanism: HTTPClient now supports a defaultHeaders dictionary applied to
every request, and ContentService builds a per-instance HTTPClient with the
basic-auth Authorization header baked in whenever credentials are configured.

The same code path now works uniformly for Invidious, Piped, PeerTube, and
Yattee Server, so any instance sitting behind a reverse proxy that requires
HTTP Basic Auth can be authenticated regardless of backend type. Cached
default API actors are still reused when no basic-auth header is needed.
This commit is contained in:
Arkadiusz Fal
2026-04-06 19:53:47 +02:00
parent aed78c13fb
commit 63f1cb1f25
6 changed files with 103 additions and 101 deletions

View File

@@ -68,60 +68,49 @@ actor ContentService: ContentServiceProtocol {
// MARK: - Routing // MARK: - Routing
/// Builds a per-instance HTTPClient with the basic-auth `Authorization` header baked in,
/// or returns nil if no basic-auth credentials are configured for the instance.
/// Used to inject reverse-proxy basic auth uniformly across all backends.
private func httpClientWithBasicAuth(for instance: Instance) async -> HTTPClient? {
guard let authHeader = await basicAuthCredentialsManager?.basicAuthHeader(for: instance) else {
return nil
}
let client = httpClientFactory.createClient(for: instance)
await client.setDefaultHeaders(["Authorization": authHeader])
return client
}
/// Returns an API client configured for the instance's SSL and auth requirements. /// Returns an API client configured for the instance's SSL and auth requirements.
private func api(for instance: Instance) async -> any InstanceAPI { private func api(for instance: Instance) async -> any InstanceAPI {
// For Yattee Server, use the dedicated method that handles auth
if instance.type == .yatteeServer {
return await yatteeServerAPI(for: instance)
}
// For instances with standard SSL, use cached default API clients
if !instance.allowInvalidCertificates {
switch instance.type {
case .invidious:
return defaultInvidiousAPI
case .piped:
return defaultPipedAPI
case .peertube:
return defaultPeerTubeAPI
case .yatteeServer:
fatalError("Should be handled above")
}
}
// For instances with allowInvalidCertificates, create API with insecure HTTPClient
let insecureClient = httpClientFactory.createClient(for: instance)
switch instance.type { switch instance.type {
case .invidious: case .invidious:
return InvidiousAPI(httpClient: insecureClient) return await invidiousAPI(for: instance)
case .piped: case .piped:
return PipedAPI(httpClient: insecureClient) return await pipedAPI(for: instance)
case .peertube: case .peertube:
return PeerTubeAPI(httpClient: insecureClient) return await peerTubeAPI(for: instance)
case .yatteeServer: case .yatteeServer:
fatalError("Should be handled above") return await yatteeServerAPI(for: instance)
} }
} }
/// Returns a YatteeServerAPI configured for the instance's SSL and auth requirements. /// Returns a YatteeServerAPI configured for the instance's SSL and auth requirements.
private func yatteeServerAPI(for instance: Instance) async -> YatteeServerAPI { private func yatteeServerAPI(for instance: Instance) async -> YatteeServerAPI {
let api: YatteeServerAPI if let authClient = await httpClientWithBasicAuth(for: instance) {
if !instance.allowInvalidCertificates { return YatteeServerAPI(httpClient: authClient)
api = defaultYatteeServerAPI
} else {
let insecureClient = httpClientFactory.createClient(for: instance)
api = YatteeServerAPI(httpClient: insecureClient)
} }
if !instance.allowInvalidCertificates {
// Fetch auth header directly from credentials manager (avoids race condition on app startup) return defaultYatteeServerAPI
let authHeader = await basicAuthCredentialsManager?.basicAuthHeader(for: instance) }
await api.setAuthHeader(authHeader) let insecureClient = httpClientFactory.createClient(for: instance)
return YatteeServerAPI(httpClient: insecureClient)
return api
} }
/// Returns an InvidiousAPI configured for the instance's SSL requirements. /// Returns an InvidiousAPI configured for the instance's SSL and auth requirements.
private func invidiousAPI(for instance: Instance) -> InvidiousAPI { private func invidiousAPI(for instance: Instance) async -> InvidiousAPI {
if let authClient = await httpClientWithBasicAuth(for: instance) {
return InvidiousAPI(httpClient: authClient)
}
if !instance.allowInvalidCertificates { if !instance.allowInvalidCertificates {
return defaultInvidiousAPI return defaultInvidiousAPI
} }
@@ -129,8 +118,11 @@ actor ContentService: ContentServiceProtocol {
return InvidiousAPI(httpClient: insecureClient) return InvidiousAPI(httpClient: insecureClient)
} }
/// Returns a PipedAPI configured for the instance's SSL requirements. /// Returns a PipedAPI configured for the instance's SSL and auth requirements.
private func pipedAPI(for instance: Instance) -> PipedAPI { private func pipedAPI(for instance: Instance) async -> PipedAPI {
if let authClient = await httpClientWithBasicAuth(for: instance) {
return PipedAPI(httpClient: authClient)
}
if !instance.allowInvalidCertificates { if !instance.allowInvalidCertificates {
return defaultPipedAPI return defaultPipedAPI
} }
@@ -138,8 +130,11 @@ actor ContentService: ContentServiceProtocol {
return PipedAPI(httpClient: insecureClient) return PipedAPI(httpClient: insecureClient)
} }
/// Returns a PeerTubeAPI configured for the instance's SSL requirements. /// Returns a PeerTubeAPI configured for the instance's SSL and auth requirements.
private func peerTubeAPI(for instance: Instance) -> PeerTubeAPI { private func peerTubeAPI(for instance: Instance) async -> PeerTubeAPI {
if let authClient = await httpClientWithBasicAuth(for: instance) {
return PeerTubeAPI(httpClient: authClient)
}
if !instance.allowInvalidCertificates { if !instance.allowInvalidCertificates {
return defaultPeerTubeAPI return defaultPeerTubeAPI
} }
@@ -286,7 +281,7 @@ actor ContentService: ContentServiceProtocol {
return try await yatteeServerAPI(for: instance).videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance) return try await yatteeServerAPI(for: instance).videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance)
case .piped: case .piped:
// Piped fallback - make separate calls (no storyboard support) // Piped fallback - make separate calls (no storyboard support)
let pipedAPI = pipedAPI(for: instance) let pipedAPI = await pipedAPI(for: instance)
async let videoTask = pipedAPI.video(id: id, instance: instance) async let videoTask = pipedAPI.video(id: id, instance: instance)
async let streamsTask = pipedAPI.streams(videoID: id, instance: instance) async let streamsTask = pipedAPI.streams(videoID: id, instance: instance)
async let captionsTask = pipedAPI.captions(videoID: id, instance: instance) async let captionsTask = pipedAPI.captions(videoID: id, instance: instance)
@@ -298,7 +293,7 @@ actor ContentService: ContentServiceProtocol {
case .peertube: case .peertube:
// PeerTube fallback - make separate calls (no storyboard support) // PeerTube fallback - make separate calls (no storyboard support)
let peerTubeAPI = peerTubeAPI(for: instance) let peerTubeAPI = await peerTubeAPI(for: instance)
async let videoTask = peerTubeAPI.video(id: id, instance: instance) async let videoTask = peerTubeAPI.video(id: id, instance: instance)
async let streamsTask = peerTubeAPI.streams(videoID: id, instance: instance) async let streamsTask = peerTubeAPI.streams(videoID: id, instance: instance)
async let captionsTask = peerTubeAPI.captions(videoID: id, instance: instance) async let captionsTask = peerTubeAPI.captions(videoID: id, instance: instance)

View File

@@ -14,42 +14,23 @@
actor YatteeServerAPI: InstanceAPI { actor YatteeServerAPI: InstanceAPI {
private let httpClient: HTTPClient private let httpClient: HTTPClient
/// Optional Basic Auth header value for authenticated requests.
/// Set this when the server requires authentication.
private var authHeader: String?
init(httpClient: HTTPClient) { init(httpClient: HTTPClient) {
self.httpClient = httpClient self.httpClient = httpClient
} }
/// Sets the Basic Auth header to use for all subsequent requests.
/// - Parameter authHeader: The Authorization header value (e.g., "Basic dXNlcjpwYXNz")
func setAuthHeader(_ authHeader: String?) {
self.authHeader = authHeader
}
/// Builds custom headers including auth if available.
private func buildHeaders(additionalHeaders: [String: String]? = nil) -> [String: String]? {
var headers = additionalHeaders ?? [:]
if let authHeader {
headers["Authorization"] = authHeader
}
return headers.isEmpty ? nil : headers
}
// MARK: - InstanceAPI // MARK: - InstanceAPI
func trending(instance: Instance) async throws -> [Video] { func trending(instance: Instance) async throws -> [Video] {
// Yattee server proxies trending from Invidious if configured // Yattee server proxies trending from Invidious if configured
let endpoint = GenericEndpoint.get("/api/v1/trending") let endpoint = GenericEndpoint.get("/api/v1/trending")
let response: [YatteeVideo] = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders()) let response: [YatteeVideo] = try await httpClient.fetch(endpoint, baseURL: instance.url)
return response.map { $0.toVideo() } return response.map { $0.toVideo() }
} }
func popular(instance: Instance) async throws -> [Video] { func popular(instance: Instance) async throws -> [Video] {
// Yattee server proxies popular from Invidious if configured // Yattee server proxies popular from Invidious if configured
let endpoint = GenericEndpoint.get("/api/v1/popular") let endpoint = GenericEndpoint.get("/api/v1/popular")
let response: [YatteeVideo] = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders()) let response: [YatteeVideo] = try await httpClient.fetch(endpoint, baseURL: instance.url)
return response.map { $0.toVideo() } return response.map { $0.toVideo() }
} }
@@ -71,7 +52,7 @@ actor YatteeServerAPI: InstanceAPI {
} }
let endpoint = GenericEndpoint.get("/api/v1/search", query: queryParams) let endpoint = GenericEndpoint.get("/api/v1/search", query: queryParams)
let response: [YatteeSearchItem] = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders()) let response: [YatteeSearchItem] = try await httpClient.fetch(endpoint, baseURL: instance.url)
// Helper to detect playlist IDs that may be returned as "video" type // Helper to detect playlist IDs that may be returned as "video" type
// YouTube playlist IDs start with: PL (user playlist), RD (mix), OL (offline mix), UU (uploads) // YouTube playlist IDs start with: PL (user playlist), RD (mix), OL (offline mix), UU (uploads)
@@ -131,18 +112,18 @@ actor YatteeServerAPI: InstanceAPI {
let endpoint = GenericEndpoint.get("/api/v1/search/suggestions", query: [ let endpoint = GenericEndpoint.get("/api/v1/search/suggestions", query: [
"q": query "q": query
]) ])
return try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders()) return try await httpClient.fetch(endpoint, baseURL: instance.url)
} }
func video(id: String, instance: Instance) async throws -> Video { func video(id: String, instance: Instance) async throws -> Video {
let endpoint = GenericEndpoint.get("/api/v1/videos/\(id)") let endpoint = GenericEndpoint.get("/api/v1/videos/\(id)")
let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders()) let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url)
return response.toVideo() return response.toVideo()
} }
func channel(id: String, instance: Instance) async throws -> Channel { func channel(id: String, instance: Instance) async throws -> Channel {
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)") let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)")
let response: YatteeChannel = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders()) let response: YatteeChannel = try await httpClient.fetch(endpoint, baseURL: instance.url)
return response.toChannel() return response.toChannel()
} }
@@ -152,7 +133,7 @@ actor YatteeServerAPI: InstanceAPI {
query["continuation"] = continuation query["continuation"] = continuation
} }
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)/videos", query: query) let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)/videos", query: query)
let response: YatteeChannelVideosWithContinuation = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders()) let response: YatteeChannelVideosWithContinuation = try await httpClient.fetch(endpoint, baseURL: instance.url)
return ChannelVideosPage( return ChannelVideosPage(
videos: response.videos.map { $0.toVideo() }, videos: response.videos.map { $0.toVideo() },
continuation: response.continuation continuation: response.continuation
@@ -165,7 +146,7 @@ actor YatteeServerAPI: InstanceAPI {
query["continuation"] = continuation query["continuation"] = continuation
} }
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)/playlists", query: query) let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)/playlists", query: query)
let response: YatteeChannelPlaylists = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders()) let response: YatteeChannelPlaylists = try await httpClient.fetch(endpoint, baseURL: instance.url)
return ChannelPlaylistsPage( return ChannelPlaylistsPage(
playlists: response.playlists.map { $0.toPlaylist() }, playlists: response.playlists.map { $0.toPlaylist() },
continuation: response.continuation continuation: response.continuation
@@ -178,7 +159,7 @@ actor YatteeServerAPI: InstanceAPI {
query["continuation"] = continuation query["continuation"] = continuation
} }
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)/shorts", query: query) let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)/shorts", query: query)
let response: YatteeChannelVideosWithContinuation = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders()) let response: YatteeChannelVideosWithContinuation = try await httpClient.fetch(endpoint, baseURL: instance.url)
return ChannelVideosPage( return ChannelVideosPage(
videos: response.videos.map { $0.toVideo() }, videos: response.videos.map { $0.toVideo() },
continuation: response.continuation continuation: response.continuation
@@ -191,7 +172,7 @@ actor YatteeServerAPI: InstanceAPI {
query["continuation"] = continuation query["continuation"] = continuation
} }
let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)/streams", query: query) let endpoint = GenericEndpoint.get("/api/v1/channels/\(id)/streams", query: query)
let response: YatteeChannelVideosWithContinuation = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders()) let response: YatteeChannelVideosWithContinuation = try await httpClient.fetch(endpoint, baseURL: instance.url)
return ChannelVideosPage( return ChannelVideosPage(
videos: response.videos.map { $0.toVideo() }, videos: response.videos.map { $0.toVideo() },
continuation: response.continuation continuation: response.continuation
@@ -200,7 +181,7 @@ actor YatteeServerAPI: InstanceAPI {
func playlist(id: String, instance: Instance) async throws -> Playlist { func playlist(id: String, instance: Instance) async throws -> Playlist {
let endpoint = GenericEndpoint.get("/api/v1/playlists/\(id)") let endpoint = GenericEndpoint.get("/api/v1/playlists/\(id)")
let response: YatteePlaylist = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders()) let response: YatteePlaylist = try await httpClient.fetch(endpoint, baseURL: instance.url)
return response.toPlaylist() return response.toPlaylist()
} }
@@ -212,7 +193,7 @@ actor YatteeServerAPI: InstanceAPI {
} }
let endpoint = GenericEndpoint.get("/api/v1/comments/\(videoID)", query: query) let endpoint = GenericEndpoint.get("/api/v1/comments/\(videoID)", query: query)
do { do {
let response: YatteeComments = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders()) let response: YatteeComments = try await httpClient.fetch(endpoint, baseURL: instance.url)
return CommentsPage( return CommentsPage(
comments: response.comments.map { $0.toComment() }, comments: response.comments.map { $0.toComment() },
continuation: response.continuation continuation: response.continuation
@@ -227,13 +208,13 @@ actor YatteeServerAPI: InstanceAPI {
func streams(videoID: String, instance: Instance) async throws -> [Stream] { func streams(videoID: String, instance: Instance) async throws -> [Stream] {
let endpoint = GenericEndpoint.get("/api/v1/videos/\(videoID)") let endpoint = GenericEndpoint.get("/api/v1/videos/\(videoID)")
let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders()) let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url)
return response.toStreams() return response.toStreams()
} }
func captions(videoID: String, instance: Instance) async throws -> [Caption] { func captions(videoID: String, instance: Instance) async throws -> [Caption] {
let endpoint = GenericEndpoint.get("/api/v1/videos/\(videoID)") let endpoint = GenericEndpoint.get("/api/v1/videos/\(videoID)")
let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders()) let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url)
return response.toCaptions(baseURL: instance.url) return response.toCaptions(baseURL: instance.url)
} }
@@ -243,7 +224,7 @@ actor YatteeServerAPI: InstanceAPI {
"page": String(page) "page": String(page)
]) ])
// Yattee Server returns {"videos": [...]} wrapper, not a plain array // Yattee Server returns {"videos": [...]} wrapper, not a plain array
let response: YatteeChannelSearchResponse = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders()) let response: YatteeChannelSearchResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
var items: [ChannelSearchItem] = [] var items: [ChannelSearchItem] = []
@@ -264,7 +245,7 @@ actor YatteeServerAPI: InstanceAPI {
func videoWithStreamsAndCaptionsAndStoryboards(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption], storyboards: [Storyboard]) { func videoWithStreamsAndCaptionsAndStoryboards(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption], storyboards: [Storyboard]) {
let endpoint = GenericEndpoint.get("/api/v1/videos/\(id)") let endpoint = GenericEndpoint.get("/api/v1/videos/\(id)")
let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders()) let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url)
return ( return (
video: response.toVideo(), video: response.toVideo(),
streams: response.toStreams(), streams: response.toStreams(),
@@ -280,7 +261,7 @@ actor YatteeServerAPI: InstanceAPI {
/// directly to YouTube CDN, allowing the server to download at full speed and serve locally. /// directly to YouTube CDN, allowing the server to download at full speed and serve locally.
func proxyStreams(videoID: String, instance: Instance) async throws -> [Stream] { func proxyStreams(videoID: String, instance: Instance) async throws -> [Stream] {
let endpoint = GenericEndpoint.get("/api/v1/videos/\(videoID)", query: ["proxy": "true"]) let endpoint = GenericEndpoint.get("/api/v1/videos/\(videoID)", query: ["proxy": "true"])
let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders()) let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url)
return response.toStreams() return response.toStreams()
} }
@@ -288,7 +269,7 @@ actor YatteeServerAPI: InstanceAPI {
/// Use this for downloads to get streams that route through the server. /// Use this for downloads to get streams that route through the server.
func videoWithProxyStreamsAndCaptionsAndStoryboards(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption], storyboards: [Storyboard]) { func videoWithProxyStreamsAndCaptionsAndStoryboards(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption], storyboards: [Storyboard]) {
let endpoint = GenericEndpoint.get("/api/v1/videos/\(id)", query: ["proxy": "true"]) let endpoint = GenericEndpoint.get("/api/v1/videos/\(id)", query: ["proxy": "true"])
let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders()) let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url)
return ( return (
video: response.toVideo(), video: response.toVideo(),
streams: response.toStreams(), streams: response.toStreams(),
@@ -315,7 +296,7 @@ actor YatteeServerAPI: InstanceAPI {
queryItems: [URLQueryItem(name: "url", value: url.absoluteString)], queryItems: [URLQueryItem(name: "url", value: url.absoluteString)],
timeout: 180 // 3 minutes for slow site extraction timeout: 180 // 3 minutes for slow site extraction
) )
let response: YatteeExternalVideo = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders()) let response: YatteeExternalVideo = try await httpClient.fetch(endpoint, baseURL: instance.url)
return ( return (
video: response.toVideo(originalURL: url), video: response.toVideo(originalURL: url),
streams: response.toStreams(), streams: response.toStreams(),
@@ -343,7 +324,7 @@ actor YatteeServerAPI: InstanceAPI {
], ],
timeout: 180 // 3 minutes for slow site extraction timeout: 180 // 3 minutes for slow site extraction
) )
let response: YatteeExternalChannel = try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders()) let response: YatteeExternalChannel = try await httpClient.fetch(endpoint, baseURL: instance.url)
return ( return (
channel: response.toChannel(originalURL: url), channel: response.toChannel(originalURL: url),
videos: response.toVideos(channelURL: url), videos: response.toVideos(channelURL: url),
@@ -357,14 +338,14 @@ actor YatteeServerAPI: InstanceAPI {
func postFeed(channels: [StatelessChannelRequest], limit: Int, offset: Int, instance: Instance) async throws -> StatelessFeedResponse { func postFeed(channels: [StatelessChannelRequest], limit: Int, offset: Int, instance: Instance) async throws -> StatelessFeedResponse {
let body = StatelessFeedRequest(channels: channels, limit: limit, offset: offset) let body = StatelessFeedRequest(channels: channels, limit: limit, offset: offset)
let endpoint = GenericEndpoint.post("/api/v1/feed", body: body) let endpoint = GenericEndpoint.post("/api/v1/feed", body: body)
return try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders()) return try await httpClient.fetch(endpoint, baseURL: instance.url)
} }
/// Checks feed status for given channels (lightweight polling). /// Checks feed status for given channels (lightweight polling).
func postFeedStatus(channels: [StatelessChannelStatusRequest], instance: Instance) async throws -> StatelessFeedStatusResponse { func postFeedStatus(channels: [StatelessChannelStatusRequest], instance: Instance) async throws -> StatelessFeedStatusResponse {
let body = StatelessFeedStatusRequest(channels: channels) let body = StatelessFeedStatusRequest(channels: channels)
let endpoint = GenericEndpoint.post("/api/v1/feed/status", body: body) let endpoint = GenericEndpoint.post("/api/v1/feed/status", body: body)
return try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders()) return try await httpClient.fetch(endpoint, baseURL: instance.url)
} }
// MARK: - Channel Metadata // MARK: - Channel Metadata
@@ -374,7 +355,7 @@ actor YatteeServerAPI: InstanceAPI {
func channelsMetadata(channelIDs: [String], instance: Instance) async throws -> ChannelsMetadataResponse { func channelsMetadata(channelIDs: [String], instance: Instance) async throws -> ChannelsMetadataResponse {
let body = ChannelMetadataRequest(channelIds: channelIDs) let body = ChannelMetadataRequest(channelIds: channelIDs)
let endpoint = GenericEndpoint.post("/api/v1/channels/metadata", body: body) let endpoint = GenericEndpoint.post("/api/v1/channels/metadata", body: body)
return try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders()) return try await httpClient.fetch(endpoint, baseURL: instance.url)
} }
// MARK: - Server Info // MARK: - Server Info
@@ -382,7 +363,7 @@ actor YatteeServerAPI: InstanceAPI {
/// Fetches server info including version, dependencies, and enabled sites. /// Fetches server info including version, dependencies, and enabled sites.
func fetchServerInfo(for instance: Instance) async throws -> InstanceDetectorModels.YatteeServerFullInfo { func fetchServerInfo(for instance: Instance) async throws -> InstanceDetectorModels.YatteeServerFullInfo {
let endpoint = GenericEndpoint.get("/info") let endpoint = GenericEndpoint.get("/info")
return try await httpClient.fetch(endpoint, baseURL: instance.url, customHeaders: buildHeaders()) return try await httpClient.fetch(endpoint, baseURL: instance.url)
} }
} }

View File

@@ -101,9 +101,11 @@ final class BackgroundFeedRefresher {
} }
do { do {
let yatteeServerAPI = YatteeServerAPI(httpClient: HTTPClient()) let httpClient = HTTPClient()
let authHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: yatteeServer) if let authHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: yatteeServer) {
await yatteeServerAPI.setAuthHeader(authHeader) await httpClient.setDefaultHeaders(["Authorization": authHeader])
}
let yatteeServerAPI = YatteeServerAPI(httpClient: httpClient)
let response = try await yatteeServerAPI.postFeed( let response = try await yatteeServerAPI.postFeed(
channels: channelRequests, channels: channelRequests,
limit: 5 * notifiableSubscriptions.count, limit: 5 * notifiableSubscriptions.count,

View File

@@ -16,6 +16,12 @@ actor HTTPClient {
private var userAgent: String? private var userAgent: String?
private var randomizeUserAgentPerRequest: Bool = false private var randomizeUserAgentPerRequest: Bool = false
/// Headers automatically applied to every request from this client.
/// Per-call `customHeaders` override these on key conflict.
/// Used to bake an HTTP Basic Auth `Authorization` header into a per-instance client
/// when the instance sits behind a reverse proxy that requires basic auth.
private var defaultHeaders: [String: String] = [:]
// MARK: - Initialization // MARK: - Initialization
init(session: URLSession = .shared, decoder: JSONDecoder = .init()) { init(session: URLSession = .shared, decoder: JSONDecoder = .init()) {
@@ -41,6 +47,12 @@ actor HTTPClient {
self.randomizeUserAgentPerRequest = enabled self.randomizeUserAgentPerRequest = enabled
} }
/// Sets headers to be applied to every request from this client.
/// Per-call `customHeaders` override these on key conflict.
func setDefaultHeaders(_ headers: [String: String]) {
self.defaultHeaders = headers
}
// MARK: - Public Methods // MARK: - Public Methods
/// Fetches and decodes a response from the given endpoint. /// Fetches and decodes a response from the given endpoint.
@@ -104,7 +116,13 @@ actor HTTPClient {
mutableRequest.setValue(userAgent, forHTTPHeaderField: "User-Agent") mutableRequest.setValue(userAgent, forHTTPHeaderField: "User-Agent")
} }
// Apply custom headers (e.g., X-API-Key for authenticated requests) // Apply default headers first (e.g., basic auth bound to a per-instance client)
for (key, value) in defaultHeaders {
mutableRequest.setValue(value, forHTTPHeaderField: key)
}
// Apply custom headers (e.g., X-API-Key for authenticated requests).
// These override default headers on key conflict.
if let customHeaders { if let customHeaders {
for (key, value) in customHeaders { for (key, value) in customHeaders {
mutableRequest.setValue(value, forHTTPHeaderField: key) mutableRequest.setValue(value, forHTTPHeaderField: key)

View File

@@ -491,9 +491,11 @@ final class SubscriptionFeedCache {
} }
do { do {
let yatteeServerAPI = YatteeServerAPI(httpClient: HTTPClient()) let httpClient = HTTPClient()
let authHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: instance) if let authHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: instance) {
await yatteeServerAPI.setAuthHeader(authHeader) await httpClient.setDefaultHeaders(["Authorization": authHeader])
}
let yatteeServerAPI = YatteeServerAPI(httpClient: httpClient)
LoggingService.shared.debug("refreshFromStatelessServer: Calling postFeed for \(channelRequests.count) channels", category: .general) LoggingService.shared.debug("refreshFromStatelessServer: Calling postFeed for \(channelRequests.count) channels", category: .general)
let response = try await yatteeServerAPI.postFeed( let response = try await yatteeServerAPI.postFeed(
channels: channelRequests, channels: channelRequests,
@@ -555,9 +557,11 @@ final class SubscriptionFeedCache {
let statusChannels = channels.map { let statusChannels = channels.map {
StatelessChannelStatusRequest(channelId: $0.channelId, site: $0.site) StatelessChannelStatusRequest(channelId: $0.channelId, site: $0.site)
} }
let yatteeServerAPI = YatteeServerAPI(httpClient: HTTPClient()) let httpClient = HTTPClient()
let authHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: instance) if let authHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: instance) {
await yatteeServerAPI.setAuthHeader(authHeader) await httpClient.setDefaultHeaders(["Authorization": authHeader])
}
let yatteeServerAPI = YatteeServerAPI(httpClient: httpClient)
let maxRetries = 5 let maxRetries = 5
var retryCount = 0 var retryCount = 0

View File

@@ -438,9 +438,11 @@ struct ManageChannelsView: View {
guard !channelIDs.isEmpty else { return } guard !channelIDs.isEmpty else { return }
do { do {
let api = YatteeServerAPI(httpClient: HTTPClient()) let httpClient = HTTPClient()
let authHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: yatteeServer) if let authHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: yatteeServer) {
await api.setAuthHeader(authHeader) await httpClient.setDefaultHeaders(["Authorization": authHeader])
}
let api = YatteeServerAPI(httpClient: httpClient)
let response = try await api.channelsMetadata(channelIDs: channelIDs, instance: yatteeServer) let response = try await api.channelsMetadata(channelIDs: channelIDs, instance: yatteeServer)
// Update subscriptions in SwiftData // Update subscriptions in SwiftData