diff --git a/Harbor/Models/AddDownloadRequest+Batch.swift b/Harbor/Models/AddDownloadRequest+Batch.swift index 09c2119..c34fd39 100644 --- a/Harbor/Models/AddDownloadRequest+Batch.swift +++ b/Harbor/Models/AddDownloadRequest+Batch.swift @@ -9,7 +9,8 @@ extension AddDownloadRequest { static func batch( from urls: [URL], destinationFolder: URL, - shouldStartImmediately: Bool + shouldStartImmediately: Bool, + requestHeaders: [RequestHeader] = [] ) -> [AddDownloadRequest] { urls.compactMap { url in guard let sourceKind = DownloadSourceKind.detect(from: url) else { @@ -21,7 +22,8 @@ extension AddDownloadRequest { sourceURL: url, customFilename: nil, destinationFolder: destinationFolder, - shouldStartImmediately: shouldStartImmediately + shouldStartImmediately: shouldStartImmediately, + requestHeaders: requestHeaders ) } } diff --git a/Harbor/Models/AddDownloadRequest.swift b/Harbor/Models/AddDownloadRequest.swift index f2b1f59..b8fbd90 100644 --- a/Harbor/Models/AddDownloadRequest.swift +++ b/Harbor/Models/AddDownloadRequest.swift @@ -6,6 +6,7 @@ struct AddDownloadRequest: Sendable { let customFilename: String? let destinationFolder: URL let shouldStartImmediately: Bool + let requestHeaders: [RequestHeader] let mediaMetadata: MediaDownloadMetadata? let mediaFormatPreference: MediaDownloadFormatPreference? let torrentFileSelection: TorrentFileSelection? @@ -18,6 +19,7 @@ struct AddDownloadRequest: Sendable { customFilename: String?, destinationFolder: URL, shouldStartImmediately: Bool, + requestHeaders: [RequestHeader] = [], mediaMetadata: MediaDownloadMetadata? = nil, mediaFormatPreference: MediaDownloadFormatPreference? = nil, torrentFileSelection: TorrentFileSelection? = nil, @@ -29,6 +31,7 @@ struct AddDownloadRequest: Sendable { self.customFilename = customFilename self.destinationFolder = destinationFolder self.shouldStartImmediately = shouldStartImmediately + self.requestHeaders = requestHeaders self.mediaMetadata = mediaMetadata self.mediaFormatPreference = mediaFormatPreference self.torrentFileSelection = torrentFileSelection diff --git a/Harbor/Models/DownloadItem.swift b/Harbor/Models/DownloadItem.swift index f58e145..f1fadd6 100644 --- a/Harbor/Models/DownloadItem.swift +++ b/Harbor/Models/DownloadItem.swift @@ -135,6 +135,7 @@ struct DownloadRecord: Codable, Sendable { /// never populate it; WebKit continuation data uses `browserResumeData`. let resumeData: Data? let browserResumeData: Data? + let requestHeaders: [RequestHeader] let backendIdentifier: String? let metadataName: String? let mediaMetadata: MediaDownloadMetadata? @@ -173,6 +174,7 @@ struct DownloadRecord: Codable, Sendable { case lastError case resumeData case browserResumeData + case requestHeaders case backendIdentifier case metadataName case mediaMetadata @@ -212,6 +214,7 @@ struct DownloadRecord: Codable, Sendable { lastError: String?, resumeData: Data?, browserResumeData: Data? = nil, + requestHeaders: [RequestHeader] = [], backendIdentifier: String?, metadataName: String?, mediaMetadata: MediaDownloadMetadata? = nil, @@ -249,6 +252,7 @@ struct DownloadRecord: Codable, Sendable { self.lastError = lastError self.resumeData = resumeData self.browserResumeData = browserResumeData + self.requestHeaders = requestHeaders self.backendIdentifier = backendIdentifier self.metadataName = metadataName self.mediaMetadata = mediaMetadata @@ -290,6 +294,7 @@ struct DownloadRecord: Codable, Sendable { self.lastError = try container.decodeIfPresent(String.self, forKey: .lastError) self.resumeData = try container.decodeIfPresent(Data.self, forKey: .resumeData) self.browserResumeData = try container.decodeIfPresent(Data.self, forKey: .browserResumeData) + self.requestHeaders = try container.decodeIfPresent([RequestHeader].self, forKey: .requestHeaders) ?? [] self.backendIdentifier = try container.decodeIfPresent(String.self, forKey: .backendIdentifier) self.metadataName = try container.decodeIfPresent(String.self, forKey: .metadataName) self.mediaMetadata = try container.decodeIfPresent(MediaDownloadMetadata.self, forKey: .mediaMetadata) @@ -375,6 +380,7 @@ final class DownloadItem: Identifiable { var metadataName: String? var mediaMetadata: MediaDownloadMetadata? var mediaFormatPreference: MediaDownloadFormatPreference? + var requestHeaders: [RequestHeader] var requiresMediaRecoveryReset: Bool var mediaOutputConflictIdentifier: UUID? var downloadLimitOverride: TransferLimitOverride @@ -416,6 +422,7 @@ final class DownloadItem: Identifiable { metadataName: String? = nil, mediaMetadata: MediaDownloadMetadata? = nil, mediaFormatPreference: MediaDownloadFormatPreference? = nil, + requestHeaders: [RequestHeader] = [], requiresMediaRecoveryReset: Bool = false, mediaOutputConflictIdentifier: UUID? = nil, downloadLimitOverride: TransferLimitOverride = .inherit, @@ -456,6 +463,7 @@ final class DownloadItem: Identifiable { self.metadataName = metadataName self.mediaMetadata = mediaMetadata self.mediaFormatPreference = mediaFormatPreference + self.requestHeaders = requestHeaders self.requiresMediaRecoveryReset = requiresMediaRecoveryReset self.mediaOutputConflictIdentifier = mediaOutputConflictIdentifier self.downloadLimitOverride = downloadLimitOverride @@ -507,6 +515,7 @@ final class DownloadItem: Identifiable { metadataName: record.metadataName, mediaMetadata: record.mediaMetadata, mediaFormatPreference: record.mediaFormatPreference, + requestHeaders: record.requestHeaders, requiresMediaRecoveryReset: record.requiresMediaRecoveryReset, mediaOutputConflictIdentifier: record.mediaOutputConflictIdentifier, downloadLimitOverride: record.downloadLimitOverride, @@ -548,6 +557,7 @@ final class DownloadItem: Identifiable { metadataName = record.metadataName mediaMetadata = record.mediaMetadata mediaFormatPreference = record.mediaFormatPreference + requestHeaders = record.requestHeaders requiresMediaRecoveryReset = record.requiresMediaRecoveryReset mediaOutputConflictIdentifier = record.mediaOutputConflictIdentifier downloadLimitOverride = record.downloadLimitOverride @@ -791,6 +801,7 @@ final class DownloadItem: Identifiable { lastError: lastError, resumeData: resumeData, browserResumeData: browserResumeData, + requestHeaders: requestHeaders, backendIdentifier: backendIdentifier, metadataName: metadataName, mediaMetadata: mediaMetadata?.persistenceSnapshot, diff --git a/Harbor/Models/DownloadSource.swift b/Harbor/Models/DownloadSource.swift index 87c4db1..7bd1109 100644 --- a/Harbor/Models/DownloadSource.swift +++ b/Harbor/Models/DownloadSource.swift @@ -38,6 +38,10 @@ enum DownloadSourceKind: String, Codable, CaseIterable, Identifiable, Sendable { self == .directURL } + var usesAria2: Bool { + self == .magnetLink || self == .torrentFile + } + static func detect(from url: URL) -> DownloadSourceKind? { if url.isFileURL { return url.pathExtension.lowercased() == "torrent" ? .torrentFile : nil diff --git a/Harbor/Models/RequestHeader.swift b/Harbor/Models/RequestHeader.swift new file mode 100644 index 0000000..ebe626f --- /dev/null +++ b/Harbor/Models/RequestHeader.swift @@ -0,0 +1,99 @@ +import Foundation + +nonisolated struct RequestHeader: Codable, Hashable, Sendable { + var name: String + var value: String + + var validationIssue: RequestHeaderValidationIssue? { + guard name.isEmpty == false else { + return .missingName + } + + guard name.unicodeScalars.allSatisfy(Self.allowedNameCharacters.contains) else { + return .invalidName + } + + guard value.unicodeScalars.contains(where: Self.isDisallowedValueCharacter) == false else { + return .invalidValue + } + + return nil + } + + /// Identifies Cookie and Authorization headers that require torrent disclosure. + var triggersSensitiveTorrentWarning: Bool { + let fieldName = name.trimmingCharacters(in: .whitespacesAndNewlines) + return fieldName.caseInsensitiveCompare("Cookie") == .orderedSame + || fieldName.caseInsensitiveCompare("Authorization") == .orderedSame + } + + var aria2HeaderValue: String { + "\(name): \(value)" + } + + private static let allowedNameCharacters = CharacterSet( + charactersIn: "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + ) + + private static func isDisallowedValueCharacter(_ character: Unicode.Scalar) -> Bool { + (character.value < 0x20 && character.value != 0x09) // 0x09: horizontal tab + || character.value == 0x7F // 0x7F: delete + } +} + +nonisolated enum RequestHeaderValidationIssue: Equatable, Sendable { + case missingName + case invalidName + case invalidValue +} + +nonisolated extension Collection where Element == RequestHeader { + var triggersSensitiveTorrentWarning: Bool { + contains(where: \.triggersSensitiveTorrentWarning) + } + + func apply(to request: inout URLRequest) { + var appliedFieldNames = Set() + + for header in self { + let normalizedName = header.name.lowercased() + if appliedFieldNames.insert(normalizedName).inserted { + request.setValue(header.value, forHTTPHeaderField: header.name) + } else { + request.addValue(header.value, forHTTPHeaderField: header.name) + } + } + } + + func apply( + toSameOriginRedirect request: inout URLRequest, + originatingAt sourceURL: URL + ) { + guard let redirectedURL = request.url, + let sourceOrigin = HTTPOrigin(sourceURL), + let redirectedOrigin = HTTPOrigin(redirectedURL), + sourceOrigin == redirectedOrigin else { + return + } + + apply(to: &request) + } +} + +private nonisolated struct HTTPOrigin: Equatable { + let scheme: String + let host: String + let port: Int + + init?(_ url: URL) { + guard let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https", + let host = url.host?.lowercased() else { + return nil + } + + self.scheme = scheme + self.host = host + self.port = url.port ?? (scheme == "http" ? 80 : 443) + } +} diff --git a/Harbor/Services/Aria2TorrentService.swift b/Harbor/Services/Aria2TorrentService.swift index 1dd42d3..ad4de8e 100644 --- a/Harbor/Services/Aria2TorrentService.swift +++ b/Harbor/Services/Aria2TorrentService.swift @@ -440,6 +440,7 @@ actor Aria2TorrentService { sourceKind: DownloadSourceKind, sourceURL: URL, destinationFolderPath: String, + requestHeaders: [RequestHeader], transferOptions: TorrentTransferOptions? = nil ) async throws -> String { logger.info("Starting torrent add request for source kind \(String(describing: sourceKind), privacy: .public)") @@ -457,6 +458,7 @@ actor Aria2TorrentService { var options = downloadOptions( destinationFolderPath: destinationFolderPath, + requestHeaders: requestHeaders, transferOptions: transferOptions ) options["gid"] = gid @@ -514,7 +516,10 @@ actor Aria2TorrentService { return returnedGID } - func previewMagnetMetainfo(at sourceURL: URL) async throws -> Data { + func previewMagnetMetainfo( + at sourceURL: URL, + requestHeaders: [RequestHeader] + ) async throws -> Data { guard let expectedInfoHash = ManagedTorrentSourceStore.normalizedInfoHash( MagnetLinkMetadata(url: sourceURL).infoHash ) else { @@ -532,7 +537,7 @@ actor Aria2TorrentService { let gid = Self.makeSubmissionGID() do { try await ensureDaemonRunning() - let options = [ + var options: [String: Any] = [ "gid": gid, "dir": previewDirectory.path, "pause": "false", @@ -541,6 +546,9 @@ actor Aria2TorrentService { "bt-save-metadata": "true", "seed-time": "0" ] + if requestHeaders.isEmpty == false { + options["header"] = requestHeaders.map(\.aria2HeaderValue) + } let returnedGID = try await submitDownload( method: "aria2.addUri", params: [ @@ -1154,13 +1162,24 @@ actor Aria2TorrentService { private func downloadOptions( destinationFolderPath: String, + requestHeaders: [RequestHeader], transferOptions: TorrentTransferOptions? - ) -> [String: String] { - Self.downloadOptions( + ) -> [String: Any] { + let options = Self.downloadOptions( destinationFolderPath: destinationFolderPath, transferSettings: transferSettings, transferOptions: transferOptions ) + + var rpcOptions = options.reduce(into: [String: Any]()) { result, option in + result[option.key] = option.value + } + + if requestHeaders.isEmpty == false { + rpcOptions["header"] = requestHeaders.map(\.aria2HeaderValue) + } + + return rpcOptions } nonisolated static func downloadOptions( diff --git a/Harbor/Services/DownloadCoordinator.swift b/Harbor/Services/DownloadCoordinator.swift index e18e613..003c9cf 100644 --- a/Harbor/Services/DownloadCoordinator.swift +++ b/Harbor/Services/DownloadCoordinator.swift @@ -227,6 +227,7 @@ final class DownloadCoordinator: NSObject, @unchecked Sendable { let downloadID: UUID let attemptIdentifier: UUID let sourceURL: URL + let requestHeaders: [RequestHeader] let session: URLSession let task: URLSessionTask var state: OwnedPartialState @@ -335,6 +336,7 @@ final class DownloadCoordinator: NSObject, @unchecked Sendable { id: UUID, attemptIdentifier: UUID = UUID(), sourceURL: URL, + requestHeaders: [RequestHeader] = [], speedLimitOverride: TransferLimitOverride = .inherit ) throws -> Int { guard stateLock.withLock({ @@ -366,7 +368,8 @@ final class DownloadCoordinator: NSObject, @unchecked Sendable { let task = session.dataTask( with: DirectDownloadResponsePolicy.request( sourceURL: sourceURL, - recovery: preparation.snapshot + recovery: preparation.snapshot, + requestHeaders: requestHeaders ) ) let ownedRecovery = preparation.snapshot @@ -377,6 +380,7 @@ final class DownloadCoordinator: NSObject, @unchecked Sendable { downloadID: id, attemptIdentifier: attemptIdentifier, sourceURL: sourceURL, + requestHeaders: requestHeaders, session: session, task: task, state: OwnedPartialState( @@ -1450,6 +1454,10 @@ extension DownloadCoordinator: URLSessionDataDelegate { let state = context.state var redirectedRequest = request + context.requestHeaders.apply( + toSameOriginRedirect: &redirectedRequest, + originatingAt: context.sourceURL + ) redirectedRequest.setValue("identity", forHTTPHeaderField: "Accept-Encoding") if state.resumeOffset > 0 { redirectedRequest.setValue( diff --git a/Harbor/Services/DownloadHTTPResponsePolicy.swift b/Harbor/Services/DownloadHTTPResponsePolicy.swift index 5517e2f..20df08c 100644 --- a/Harbor/Services/DownloadHTTPResponsePolicy.swift +++ b/Harbor/Services/DownloadHTTPResponsePolicy.swift @@ -234,9 +234,11 @@ enum DirectDownloadResponsePolicy { nonisolated static func request( sourceURL: URL, - recovery: DirectDownloadRecoverySnapshot? + recovery: DirectDownloadRecoverySnapshot?, + requestHeaders: [RequestHeader] = [] ) -> URLRequest { var request = URLRequest(url: sourceURL) + requestHeaders.apply(to: &request) request.cachePolicy = .reloadIgnoringLocalCacheData request.setValue("identity", forHTTPHeaderField: "Accept-Encoding") diff --git a/Harbor/Services/ManagedTorrentSourceStore.swift b/Harbor/Services/ManagedTorrentSourceStore.swift index b273517..3406269 100644 --- a/Harbor/Services/ManagedTorrentSourceStore.swift +++ b/Harbor/Services/ManagedTorrentSourceStore.swift @@ -11,8 +11,6 @@ struct ManagedTorrentSource: Equatable, Sendable { enum ManagedTorrentSourceStoreError: LocalizedError { case emptyTorrent case torrentTooLarge - case invalidServerResponse - case unsuccessfulStatusCode(Int) var errorDescription: String? { switch self { @@ -28,21 +26,6 @@ enum ManagedTorrentSourceStoreError: LocalizedError { defaultValue: "The torrent file is too large.", comment: "Error shown when a torrent file exceeds Harbor's safe metadata size limit." ) - case .invalidServerResponse: - String( - localized: "torrent.import.invalidResponse", - defaultValue: "The torrent server returned an invalid response.", - comment: "Error shown when a remote torrent URL does not return an HTTP response." - ) - case let .unsuccessfulStatusCode(statusCode): - String( - format: String( - localized: "torrent.import.httpStatus", - defaultValue: "The torrent server returned HTTP status %lld.", - comment: "Error shown when a remote torrent URL returns a failing HTTP status." - ), - Int64(statusCode) - ) } } } @@ -76,16 +59,12 @@ actor ManagedTorrentSourceStore { func fetchRemoteTorrent( from remoteURL: URL, - using session: URLSession = .shared + requestHeaders: [RequestHeader] ) async throws -> ManagedTorrentSource { - let (temporaryURL, response) = try await session.download(from: remoteURL) - guard let httpResponse = response as? HTTPURLResponse else { - throw ManagedTorrentSourceStoreError.invalidServerResponse - } - guard 200 ..< 300 ~= httpResponse.statusCode else { - throw ManagedTorrentSourceStoreError.unsuccessfulStatusCode(httpResponse.statusCode) - } - let data = try Self.loadTorrentData(at: temporaryURL, fileManager: fileManager) + let data = try await TorrentSourceLoader.fetch( + from: remoteURL, + requestHeaders: requestHeaders + ) return try persist(data: data, originalURL: remoteURL) } diff --git a/Harbor/Services/TorrentContentsPreviewService.swift b/Harbor/Services/TorrentContentsPreviewService.swift index 5a3daa7..54971cf 100644 --- a/Harbor/Services/TorrentContentsPreviewService.swift +++ b/Harbor/Services/TorrentContentsPreviewService.swift @@ -4,17 +4,24 @@ struct TorrentContentsPreviewService: Sendable { func preview( sourceKind: DownloadSourceKind, sourceURL: URL, + requestHeaders: [RequestHeader], torrentService: Aria2TorrentService ) async throws -> TorrentContentsPreview { let data: Data switch sourceKind { case .magnetLink: - data = try await torrentService.previewMagnetMetainfo(at: sourceURL) + data = try await torrentService.previewMagnetMetainfo( + at: sourceURL, + requestHeaders: requestHeaders + ) case .torrentFile: if sourceURL.isFileURL { data = try readLocalTorrent(at: sourceURL) } else { - data = try await fetchRemoteTorrent(from: sourceURL) + data = try await TorrentSourceLoader.fetch( + from: sourceURL, + requestHeaders: requestHeaders + ) } case .directURL, .mediaURL: throw TorrentEngineError.invalidSource @@ -32,15 +39,4 @@ struct TorrentContentsPreviewService: Sendable { } return try ManagedTorrentSourceStore.loadTorrentData(at: sourceURL) } - - private func fetchRemoteTorrent(from sourceURL: URL) async throws -> Data { - let (temporaryURL, response) = try await URLSession.shared.download(from: sourceURL) - guard let response = response as? HTTPURLResponse else { - throw ManagedTorrentSourceStoreError.invalidServerResponse - } - guard 200 ..< 300 ~= response.statusCode else { - throw ManagedTorrentSourceStoreError.unsuccessfulStatusCode(response.statusCode) - } - return try ManagedTorrentSourceStore.loadTorrentData(at: temporaryURL) - } } diff --git a/Harbor/Services/TorrentSourceLoader.swift b/Harbor/Services/TorrentSourceLoader.swift new file mode 100644 index 0000000..7ab21cf --- /dev/null +++ b/Harbor/Services/TorrentSourceLoader.swift @@ -0,0 +1,105 @@ +import Foundation + +nonisolated enum TorrentSourceLoader { + static func fetch( + from remoteURL: URL, + requestHeaders: [RequestHeader] + ) async throws -> Data { + var request = URLRequest(url: remoteURL) + requestHeaders.apply(to: &request) + + let configuration = URLSessionConfiguration.ephemeral + configuration.waitsForConnectivity = true + configuration.timeoutIntervalForRequest = 30 + configuration.timeoutIntervalForResource = 120 + + let redirectDelegate = TorrentSourceRedirectDelegate( + sourceURL: remoteURL, + requestHeaders: requestHeaders + ) + let session = URLSession( + configuration: configuration, + delegate: redirectDelegate, + delegateQueue: nil + ) + defer { + session.finishTasksAndInvalidate() + } + + let (temporaryURL, response) = try await session.download(for: request) + if let response = response as? HTTPURLResponse, + (200 ... 299).contains(response.statusCode) == false { + throw TorrentSourceLoadingError.httpStatus(response.statusCode) + } + + let data = try ManagedTorrentSourceStore.loadTorrentData(at: temporaryURL) + + guard data.isEmpty == false else { + throw TorrentSourceLoadingError.emptyResponse + } + + return data + } +} + +/// Handles redirects while Harbor fetches a remote `.torrent` file before passing its data to aria2. +/// +/// `TorrentSourceLoader` uses a separate ephemeral URLSession, so it cannot share the redirect handling +/// in `DownloadCoordinator`. This delegate is scoped to that one source-loading session; it does not +/// control the later tracker or web-seed requests made by aria2. +private nonisolated final class TorrentSourceRedirectDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + private let sourceURL: URL + private let requestHeaders: [RequestHeader] + + init( + sourceURL: URL, + requestHeaders: [RequestHeader] + ) { + self.sourceURL = sourceURL + self.requestHeaders = requestHeaders + } + + /// Reapplies the supplied headers when the torrent source redirects within its original HTTP origin. + /// URLSession may omit headers such as `Authorization` from its proposed redirect request. Passing + /// the adjusted request to the completion handler allows the source fetch to continue without + /// deliberately reapplying those headers after an origin change. + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + var redirectedRequest = request + requestHeaders.apply( + toSameOriginRedirect: &redirectedRequest, + originatingAt: sourceURL + ) + completionHandler(redirectedRequest) + } +} + +nonisolated enum TorrentSourceLoadingError: LocalizedError, Sendable { + case httpStatus(Int) + case emptyResponse + + var errorDescription: String? { + switch self { + case let .httpStatus(statusCode): + return String( + format: String( + localized: "error.torrent.sourceHTTPStatus", + defaultValue: "The torrent file server returned HTTP %d.", + comment: "Error shown when a remote torrent file request returns a non-success HTTP status. Parameter is the HTTP status code." + ), + statusCode + ) + case .emptyResponse: + return String( + localized: "error.torrent.sourceEmpty", + defaultValue: "The torrent file server returned an empty response.", + comment: "Error shown when a remote torrent file request succeeds without returning torrent data." + ) + } + } +} diff --git a/Harbor/ViewModels/DownloadCenter.swift b/Harbor/ViewModels/DownloadCenter.swift index 600c934..1c20cb4 100644 --- a/Harbor/ViewModels/DownloadCenter.swift +++ b/Harbor/ViewModels/DownloadCenter.swift @@ -70,6 +70,7 @@ final class DownloadCenter { DownloadSourceKind, URL, String, + [RequestHeader], TorrentTransferOptions ) async throws -> String typealias TorrentRemoveOperation = @Sendable ( @@ -221,11 +222,12 @@ final class DownloadCenter { try await service.pause(gid: gid) }, torrentStartOperation: @escaping TorrentStartOperation = { - service, sourceKind, sourceURL, destinationFolderPath, transferOptions in + service, sourceKind, sourceURL, destinationFolderPath, requestHeaders, transferOptions in try await service.addDownload( sourceKind: sourceKind, sourceURL: sourceURL, destinationFolderPath: destinationFolderPath, + requestHeaders: requestHeaders, transferOptions: transferOptions ) }, @@ -2378,11 +2380,13 @@ final class DownloadCenter { func previewTorrentContents( sourceKind: DownloadSourceKind, - sourceURL: URL + sourceURL: URL, + requestHeaders: [RequestHeader] ) async throws -> TorrentContentsPreview { try await TorrentContentsPreviewService().preview( sourceKind: sourceKind, sourceURL: sourceURL, + requestHeaders: requestHeaders, torrentService: torrentService ) } @@ -2463,6 +2467,7 @@ final class DownloadCenter { metadataName: request.torrentMetadataName ?? request.mediaMetadata?.title, mediaMetadata: request.mediaMetadata, mediaFormatPreference: request.mediaFormatPreference, + requestHeaders: request.requestHeaders, torrentFingerprint: managedTorrentSource?.fingerprint ?? Self.normalizedMagnetInfoHash(for: request), torrentSourceFingerprint: managedTorrentSource?.sourceFingerprint, @@ -2519,7 +2524,8 @@ final class DownloadCenter { ) } else { managedSource = try await managedTorrentSourceStore.fetchRemoteTorrent( - from: request.sourceURL + from: request.sourceURL, + requestHeaders: request.requestHeaders ) } @@ -4150,7 +4156,10 @@ final class DownloadCenter { originalURL: sourceURL ) } else { - managedSource = try await managedTorrentSourceStore.fetchRemoteTorrent(from: sourceURL) + managedSource = try await managedTorrentSourceStore.fetchRemoteTorrent( + from: sourceURL, + requestHeaders: item.requestHeaders + ) } item.sourceURL = managedSource.originalURL @@ -4486,6 +4495,7 @@ final class DownloadCenter { id: item.id, attemptIdentifier: attemptIdentifier, sourceURL: item.sourceURL, + requestHeaders: item.requestHeaders, speedLimitOverride: item.downloadLimitOverride ) } catch { @@ -5076,6 +5086,7 @@ final class DownloadCenter { torrentEngineSourceKind(for: refreshedItem), torrentEngineSourceURL(for: refreshedItem), refreshedItem.destinationFolderPath, + refreshedItem.requestHeaders, torrentTransferOptions(for: refreshedItem) ) @@ -5096,6 +5107,7 @@ final class DownloadCenter { torrentEngineSourceKind(for: currentItem), torrentEngineSourceURL(for: currentItem), currentItem.destinationFolderPath, + currentItem.requestHeaders, torrentTransferOptions(for: currentItem) ) guard item(for: id) != nil else { diff --git a/Harbor/Views/AddDownloadSheet.swift b/Harbor/Views/AddDownloadSheet.swift index 163dc09..271293f 100644 --- a/Harbor/Views/AddDownloadSheet.swift +++ b/Harbor/Views/AddDownloadSheet.swift @@ -9,6 +9,11 @@ struct AddDownloadSheet: View { var id: String { "\(sourceKind.rawValue):\(sourceURL.absoluteString)" } } + private enum PendingSensitiveTorrentAction { + case preview(TorrentPreviewSource) + case submit([AddDownloadRequest]) + } + private enum Layout { static let groupedFormHorizontalExpansion: CGFloat = 20 } @@ -19,7 +24,7 @@ struct AddDownloadSheet: View { let settings: AppSettingsStore let mediaPreviewProvider: @MainActor (URL) async throws -> MediaDownloadMetadata? - let torrentPreviewProvider: @MainActor (DownloadSourceKind, URL) async throws -> TorrentContentsPreview + let torrentPreviewProvider: @MainActor (DownloadSourceKind, URL, [RequestHeader]) async throws -> TorrentContentsPreview let onSubmit: @MainActor ([AddDownloadRequest]) -> Void @Environment(\.dismiss) private var dismiss @@ -31,6 +36,8 @@ struct AddDownloadSheet: View { @State private var destinationPath: String @State private var hasCustomizedDestination = false @State private var shouldStartImmediately: Bool + @State private var requestHeaders: [RequestHeader] = [] + @State private var isRequestHeadersEditorPresented = false @State private var validationMessage: String? @State private var mediaPreview: MediaDownloadMetadata? @State private var mediaPreviewError: String? @@ -41,12 +48,14 @@ struct AddDownloadSheet: View { @State private var mediaPreviewTask: Task? @State private var mediaPreviewGeneration = 0 @State private var torrentPreviewSource: TorrentPreviewSource? + @State private var hasApprovedSensitiveTorrentHeaders = false + @State private var pendingSensitiveTorrentAction: PendingSensitiveTorrentAction? init( settings: AppSettingsStore, draft: AddDownloadSheetDraft, mediaPreviewProvider: @escaping @MainActor (URL) async throws -> MediaDownloadMetadata? = { _ in nil }, - torrentPreviewProvider: @escaping @MainActor (DownloadSourceKind, URL) async throws -> TorrentContentsPreview = { _, _ in + torrentPreviewProvider: @escaping @MainActor (DownloadSourceKind, URL, [RequestHeader]) async throws -> TorrentContentsPreview = { _, _, _ in throw TorrentEngineError.invalidSource }, onSubmit: @escaping @MainActor ([AddDownloadRequest]) -> Void @@ -124,6 +133,8 @@ struct AddDownloadSheet: View { Toggle("Start immediately", isOn: $shouldStartImmediately) .accessibilityIdentifier(HarborAccessibility.addStartImmediately) + + advancedSettingsSection } .formStyle(.grouped) .padding(.horizontal, -Layout.groupedFormHorizontalExpansion) @@ -152,7 +163,7 @@ struct AddDownloadSheet: View { if let torrentPreviewCandidate { Button("Preview") { - torrentPreviewSource = torrentPreviewCandidate + presentTorrentPreview(torrentPreviewCandidate) } .accessibilityIdentifier(HarborAccessibility.addPreview) } @@ -194,7 +205,11 @@ struct AddDownloadSheet: View { .sheet(item: $torrentPreviewSource) { source in TorrentContentsSelectionSheet( loadPreview: { - try await torrentPreviewProvider(source.sourceKind, source.sourceURL) + try await torrentPreviewProvider( + source.sourceKind, + source.sourceURL, + requestHeaders + ) }, onAdd: { preview, selection in submitTorrentPreview( @@ -205,6 +220,38 @@ struct AddDownloadSheet: View { } ) } + .sheet(isPresented: $isRequestHeadersEditorPresented) { + RequestHeadersEditor( + requestHeaders: requestHeaders + ) { updatedHeaders in + requestHeaders = updatedHeaders + hasApprovedSensitiveTorrentHeaders = false + validationMessage = nil + } + } + .alert( + "Sensitive headers may be shared", + isPresented: Binding( + get: { pendingSensitiveTorrentAction != nil }, + set: { isPresented in + if isPresented == false { + pendingSensitiveTorrentAction = nil + } + } + ) + ) { + Button("Cancel", role: .cancel) { + pendingSensitiveTorrentAction = nil + } + + Button("Continue") { + continuePendingSensitiveTorrentAction() + } + } message: { + Text( + "The supplied headers contain Cookie or Authorization information. aria2 may send these headers to every HTTP/HTTPS tracker and web seed used by this torrent. Proceed?" + ) + } } private var torrentPreviewCandidate: TorrentPreviewSource? { @@ -226,6 +273,16 @@ struct AddDownloadSheet: View { } } + private func presentTorrentPreview(_ source: TorrentPreviewSource) { + if requestHeaders.triggersSensitiveTorrentWarning, + hasApprovedSensitiveTorrentHeaders == false { + pendingSensitiveTorrentAction = .preview(source) + return + } + + torrentPreviewSource = source + } + @MainActor private func submitTorrentPreview( source: TorrentPreviewSource, @@ -238,12 +295,12 @@ struct AddDownloadSheet: View { customFilename: nil, destinationFolder: URL(fileURLWithPath: destinationPath, isDirectory: true), shouldStartImmediately: shouldStartImmediately, + requestHeaders: requestHeaders, torrentFileSelection: selection, preparedTorrentMetainfo: preview.metainfoData, torrentMetadataName: preview.name ) - onSubmit([request]) - dismiss() + submitRequests([request]) } @ViewBuilder @@ -452,6 +509,18 @@ struct AddDownloadSheet: View { } } + private var advancedSettingsSection: some View { + DisclosureGroup("Advanced Settings") { + LabeledContent("Request Headers") { + Button("Configure…") { + isRequestHeadersEditorPresented = true + } + } + .padding(.top, 10) + .padding(.leading, 24) + } + } + private var sourceAwareDefaultDestinationPath: String { switch entryMode { case .torrentFile: @@ -685,15 +754,15 @@ struct AddDownloadSheet: View { let requests = AddDownloadRequest.batch( from: parsedBatchURLs, destinationFolder: folderURL, - shouldStartImmediately: shouldStartImmediately + shouldStartImmediately: shouldStartImmediately, + requestHeaders: requestHeaders ) guard requests.isEmpty == false else { return } - onSubmit(requests) - dismiss() + submitRequests(requests) return } @@ -782,22 +851,69 @@ struct AddDownloadSheet: View { let folderURL = URL(fileURLWithPath: destinationPath, isDirectory: true) - onSubmit( - [ - AddDownloadRequest( - sourceKind: sourceKind, - sourceURL: sourceURL, - customFilename: nil, - destinationFolder: folderURL, - shouldStartImmediately: shouldStartImmediately, - mediaMetadata: requestMediaMetadata, - mediaFormatPreference: requestMediaFormatPreference - ) - ] + guard sourceKind != .mediaURL || requestHeaders.isEmpty else { + validationMessage = String( + localized: "add.validation.mediaHeadersUnsupported", + defaultValue: "Request headers aren’t supported for media downloads.", + comment: "Validation message shown when request headers are supplied for a yt-dlp media download." + ) + return + } + + let request = AddDownloadRequest( + sourceKind: sourceKind, + sourceURL: sourceURL, + customFilename: nil, + destinationFolder: folderURL, + shouldStartImmediately: shouldStartImmediately, + requestHeaders: requestHeaders, + mediaMetadata: requestMediaMetadata, + mediaFormatPreference: requestMediaFormatPreference ) + + submitRequests([request]) + } + + @MainActor + private func submitRequests(_ requests: [AddDownloadRequest]) { + if requests.contains(where: { + $0.sourceKind.usesAria2 && $0.requestHeaders.triggersSensitiveTorrentWarning + }), + hasApprovedSensitiveTorrentHeaders == false { + pendingSensitiveTorrentAction = .submit(requests) + return + } + + performSubmission(requests) + } + + @MainActor + private func performSubmission(_ requests: [AddDownloadRequest]) { + onSubmit(requests) dismiss() } + private func continuePendingSensitiveTorrentAction() { + guard let action = pendingSensitiveTorrentAction else { + return + } + + pendingSensitiveTorrentAction = nil + hasApprovedSensitiveTorrentHeaders = true + + switch action { + case let .preview(source): + torrentPreviewSource = source + case let .submit(requests): + guard isSubmitting == false else { + return + } + isSubmitting = true + defer { isSubmitting = false } + performSubmission(requests) + } + } + @ViewBuilder private func mediaThumbnail(for metadata: MediaDownloadMetadata) -> some View { if let thumbnailURL = metadata.thumbnailURL { diff --git a/Harbor/Views/RequestHeadersEditor.swift b/Harbor/Views/RequestHeadersEditor.swift new file mode 100644 index 0000000..0f49f9d --- /dev/null +++ b/Harbor/Views/RequestHeadersEditor.swift @@ -0,0 +1,268 @@ +import AppKit +import SwiftUI + +struct RequestHeadersEditor: View { + private static let placeholder = """ + User-Agent: Mozilla/5.0 + Referer: https://example.com/ + """ + + let onSave: ([RequestHeader]) -> Void + + @Environment(\.dismiss) private var dismiss + + @State private var requestHeadersText: String + @State private var isRequestHeadersEditorFocused = false + @State private var validationMessage: String? + + init( + requestHeaders: [RequestHeader], + onSave: @escaping ([RequestHeader]) -> Void + ) { + self.onSave = onSave + _requestHeadersText = State( + initialValue: requestHeaders + .map { "\($0.name): \($0.value)" } + .joined(separator: "\n") + ) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text("Request Headers") + .font(.title2.weight(.semibold)) + + Text("Enter one header per line using Name: value format.") + .foregroundStyle(.secondary) + } + + ZStack(alignment: .topLeading) { + PaddedTextEditor( + text: $requestHeadersText, + isFocused: $isRequestHeadersEditorFocused + ) + + if requestHeadersText.isEmpty { + Text(Self.placeholder) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary.opacity(0.6)) + .padding(.horizontal, 5) + .padding(.vertical, 8) + .allowsHitTesting(false) + } + } + .frame(minHeight: 140, idealHeight: 170) + .overlay { + RoundedRectangle(cornerRadius: 5) + .stroke(.secondary.opacity(0.25), lineWidth: 1) + } + + if let validationMessage { + Text(validationMessage) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Spacer() + + Button("Cancel") { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Button("Save") { + save() + } + .keyboardShortcut(.defaultAction) + } + } + .padding(20) + .frame(minWidth: 460, idealWidth: 500, maxWidth: 560, minHeight: 260, idealHeight: 300) + .onAppear { + isRequestHeadersEditorFocused = true + } + .onChange(of: isRequestHeadersEditorFocused) { oldValue, newValue in + if oldValue, newValue == false { + _ = parseRequestHeaders() + } + } + .onChange(of: requestHeadersText) { + validationMessage = nil + } + } + + private func save() { + guard let requestHeaders = parseRequestHeaders() else { + isRequestHeadersEditorFocused = true + return + } + + onSave(requestHeaders) + dismiss() + } + + private func parseRequestHeaders() -> [RequestHeader]? { + var requestHeaders: [RequestHeader] = [] + validationMessage = nil + + for (index, line) in requestHeadersText.components(separatedBy: "\n").enumerated() { + guard line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + continue + } + + let lineNumber = index + 1 + guard let separator = line.firstIndex(of: ":") else { + validationMessage = String( + format: String( + localized: "add.validation.headerFormat", + defaultValue: "Header line %d must use Name: value format.", + comment: "Validation message shown when a request header line has no colon separator. Parameter is the one-based line number." + ), + lineNumber + ) + return nil + } + + let valueStart = line.index(after: separator) + let header = RequestHeader( + name: line[.. Coordinator { + Coordinator(self) + } + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSTextView.scrollableTextView() + guard let textView = scrollView.documentView as? NSTextView else { + return scrollView + } + + scrollView.borderType = .noBorder + scrollView.hasHorizontalScroller = false + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + + textView.delegate = context.coordinator + textView.string = text + textView.font = .monospacedSystemFont( + ofSize: NSFont.systemFontSize, + weight: .regular + ) + textView.textContainerInset = NSSize(width: 0, height: 8) + textView.textContainer?.lineFragmentPadding = 5 + textView.isRichText = false + textView.allowsUndo = true + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticSpellingCorrectionEnabled = false + textView.isAutomaticTextReplacementEnabled = false + textView.isContinuousSpellCheckingEnabled = false + textView.setAccessibilityLabel("Request Headers") + + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = scrollView.documentView as? NSTextView else { + return + } + + context.coordinator.parent = self + + if textView.string != text { + textView.string = text + } + + guard isFocused, + textView.window?.firstResponder !== textView else { + return + } + + DispatchQueue.main.async { + guard isFocused else { + return + } + + textView.window?.makeFirstResponder(textView) + } + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var parent: PaddedTextEditor + + init(_ parent: PaddedTextEditor) { + self.parent = parent + } + + func textDidBeginEditing(_ notification: Notification) { + if parent.isFocused == false { + parent.isFocused = true + } + } + + func textDidEndEditing(_ notification: Notification) { + if parent.isFocused { + parent.isFocused = false + } + } + + func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? NSTextView, + parent.text != textView.string else { + return + } + + parent.text = textView.string + } + } +} diff --git a/Harbor/Views/RootView.swift b/Harbor/Views/RootView.swift index 1cdea63..cc3184d 100644 --- a/Harbor/Views/RootView.swift +++ b/Harbor/Views/RootView.swift @@ -64,10 +64,11 @@ struct RootView: View { mediaPreviewProvider: { url in try await center.previewMediaDownload(for: url) }, - torrentPreviewProvider: { sourceKind, url in + torrentPreviewProvider: { sourceKind, url, requestHeaders in try await center.previewTorrentContents( sourceKind: sourceKind, - sourceURL: url + sourceURL: url, + requestHeaders: requestHeaders ) } ) { requests in diff --git a/HarborTests/BatchAddDownloadTests.swift b/HarborTests/BatchAddDownloadTests.swift index d594866..d1d1fa7 100644 --- a/HarborTests/BatchAddDownloadTests.swift +++ b/HarborTests/BatchAddDownloadTests.swift @@ -74,17 +74,20 @@ final class BatchAddDownloadTests: XCTestCase { URL(string: "magnet:?xt=urn:btih:abcdef")!, URL(string: "https://example.com/linux.torrent")! ] + let requestHeaders = [RequestHeader(name: "User-Agent", value: "Harbor")] let requests = AddDownloadRequest.batch( from: urls, destinationFolder: destination, - shouldStartImmediately: false + shouldStartImmediately: false, + requestHeaders: requestHeaders ) XCTAssertEqual(requests.count, 3) XCTAssertTrue(requests.allSatisfy { $0.destinationFolder == destination }) XCTAssertTrue(requests.allSatisfy { $0.shouldStartImmediately == false }) XCTAssertTrue(requests.allSatisfy { $0.customFilename == nil }) + XCTAssertTrue(requests.allSatisfy { $0.requestHeaders == requestHeaders }) XCTAssertEqual(requests[0].sourceKind, .directURL) XCTAssertEqual(requests[1].sourceKind, .magnetLink) diff --git a/HarborTests/HarborModelAndSafetyTests.swift b/HarborTests/HarborModelAndSafetyTests.swift index b54005c..b20a862 100644 --- a/HarborTests/HarborModelAndSafetyTests.swift +++ b/HarborTests/HarborModelAndSafetyTests.swift @@ -487,6 +487,109 @@ final class HarborModelAndSafetyTests: XCTestCase { ) } + func testRequestHeaderValidationAndSensitiveDetection() { + XCTAssertNil(RequestHeader(name: "User-Agent", value: "Harbor\t1.0").validationIssue) + XCTAssertEqual(RequestHeader(name: "Bad Header", value: "value").validationIssue, .invalidName) + + for value in ["line\nbreak", "line\rbreak", "nul\u{0}byte", "delete\u{7F}byte"] { + XCTAssertEqual( + RequestHeader(name: "X-Test", value: value).validationIssue, + .invalidValue + ) + } + + XCTAssertTrue(RequestHeader(name: "cookie", value: "session=secret").triggersSensitiveTorrentWarning) + XCTAssertTrue(RequestHeader(name: "AUTHORIZATION", value: "Bearer secret").triggersSensitiveTorrentWarning) + XCTAssertFalse(RequestHeader(name: "Referer", value: "https://example.com").triggersSensitiveTorrentWarning) + } + + func testRequestHeadersApplyOnlyToSameOriginRedirects() throws { + let sourceURL = try XCTUnwrap(URL(string: "https://example.com/source")) + let headers = [ + RequestHeader(name: "Authorization", value: "Bearer secret"), + RequestHeader(name: "X-Client", value: "Harbor") + ] + + var sameOriginRequest = URLRequest( + url: try XCTUnwrap(URL(string: "https://example.com/redirected")) + ) + headers.apply(toSameOriginRedirect: &sameOriginRequest, originatingAt: sourceURL) + XCTAssertEqual( + sameOriginRequest.value(forHTTPHeaderField: "Authorization"), + "Bearer secret" + ) + XCTAssertEqual(sameOriginRequest.value(forHTTPHeaderField: "X-Client"), "Harbor") + + var crossOriginRequest = URLRequest( + url: try XCTUnwrap(URL(string: "https://downloads.example.net/file")) + ) + headers.apply(toSameOriginRedirect: &crossOriginRequest, originatingAt: sourceURL) + XCTAssertNil(crossOriginRequest.value(forHTTPHeaderField: "Authorization")) + XCTAssertNil(crossOriginRequest.value(forHTTPHeaderField: "X-Client")) + } + + func testDirectDownloadRequestPreservesHeadersAndRecoveryInvariants() throws { + let sourceURL = try XCTUnwrap(URL(string: "https://example.com/archive.bin")) + let recovery = DirectDownloadRecoverySnapshot( + bytesWritten: 128, + metadata: DirectDownloadRecoveryMetadata( + sourceURL: sourceURL, + entityTag: "\"current-etag\"", + lastModified: nil, + expectedBytes: 1_024, + suggestedFilename: nil, + mimeType: nil + ) + ) + + let request = DirectDownloadResponsePolicy.request( + sourceURL: sourceURL, + recovery: recovery, + requestHeaders: [ + RequestHeader(name: "X-Client", value: "Harbor"), + RequestHeader(name: "Accept-Encoding", value: "gzip"), + RequestHeader(name: "Range", value: "bytes=0-1"), + RequestHeader(name: "If-Range", value: "\"stale-etag\"") + ] + ) + + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Client"), "Harbor") + XCTAssertEqual(request.value(forHTTPHeaderField: "Accept-Encoding"), "identity") + XCTAssertEqual(request.value(forHTTPHeaderField: "Range"), "bytes=128-") + XCTAssertEqual(request.value(forHTTPHeaderField: "If-Range"), "\"current-etag\"") + } + + func testDownloadRecordRoundTripPreservesHeadersAndTorrentOptions() throws { + let headers = [ + RequestHeader(name: "User-Agent", value: "Harbor"), + RequestHeader(name: "Cookie", value: "session=secret") + ] + let item = DownloadItem( + sourceURL: URL(fileURLWithPath: "/tmp/source.torrent"), + sourceKind: .torrentFile, + backend: .aria2, + preferredFilename: nil, + destinationFolderPath: "/tmp", + status: .paused, + requestHeaders: headers, + downloadLimitOverride: .limited(kilobytesPerSecond: 512), + uploadLimitOverride: .unlimited, + torrentFingerprint: "fingerprint", + managedTorrentSourcePath: "/tmp/managed.torrent", + shouldSeedAfterDownload: true + ) + + let data = try JSONEncoder().encode(item.makeRecord()) + let restored = try JSONDecoder().decode(DownloadRecord.self, from: data) + + XCTAssertEqual(restored.requestHeaders, headers) + XCTAssertEqual(restored.downloadLimitOverride, .limited(kilobytesPerSecond: 512)) + XCTAssertEqual(restored.uploadLimitOverride, .unlimited) + XCTAssertEqual(restored.torrentFingerprint, "fingerprint") + XCTAssertEqual(restored.managedTorrentSourcePath, "/tmp/managed.torrent") + XCTAssertTrue(restored.shouldSeedAfterDownload) + } + func testQuickLookRequiresCompletedExistingLocalFiles() throws { let suiteName = "HarborTests.QuickLook.\(UUID().uuidString)" let userDefaults = UserDefaults(suiteName: suiteName)! @@ -532,6 +635,7 @@ final class HarborModelAndSafetyTests: XCTestCase { func testLegacyCompletedTorrentDoesNotSeed() throws { let record = try legacyTorrentRecord(status: .completed) + XCTAssertTrue(record.requestHeaders.isEmpty) XCTAssertFalse(record.shouldSeedAfterDownload) XCTAssertFalse(record.removeOriginalTorrentAfterImport) XCTAssertTrue(record.completionNotificationDelivered) @@ -741,6 +845,7 @@ final class HarborModelAndSafetyTests: XCTestCase { JSONSerialization.jsonObject(with: JSONEncoder().encode(record)) as? [String: Any] ) [ + "requestHeaders", "downloadLimitOverride", "uploadLimitOverride", "requiresMediaRecoveryReset",