Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions Harbor/Models/AddDownloadRequest+Batch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -21,7 +22,8 @@ extension AddDownloadRequest {
sourceURL: url,
customFilename: nil,
destinationFolder: destinationFolder,
shouldStartImmediately: shouldStartImmediately
shouldStartImmediately: shouldStartImmediately,
requestHeaders: requestHeaders
)
}
}
Expand Down
3 changes: 3 additions & 0 deletions Harbor/Models/AddDownloadRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -18,6 +19,7 @@ struct AddDownloadRequest: Sendable {
customFilename: String?,
destinationFolder: URL,
shouldStartImmediately: Bool,
requestHeaders: [RequestHeader] = [],
mediaMetadata: MediaDownloadMetadata? = nil,
mediaFormatPreference: MediaDownloadFormatPreference? = nil,
torrentFileSelection: TorrentFileSelection? = nil,
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions Harbor/Models/DownloadItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -173,6 +174,7 @@ struct DownloadRecord: Codable, Sendable {
case lastError
case resumeData
case browserResumeData
case requestHeaders
case backendIdentifier
case metadataName
case mediaMetadata
Expand Down Expand Up @@ -212,6 +214,7 @@ struct DownloadRecord: Codable, Sendable {
lastError: String?,
resumeData: Data?,
browserResumeData: Data? = nil,
requestHeaders: [RequestHeader] = [],
backendIdentifier: String?,
metadataName: String?,
mediaMetadata: MediaDownloadMetadata? = nil,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -791,6 +801,7 @@ final class DownloadItem: Identifiable {
lastError: lastError,
resumeData: resumeData,
browserResumeData: browserResumeData,
requestHeaders: requestHeaders,
backendIdentifier: backendIdentifier,
metadataName: metadataName,
mediaMetadata: mediaMetadata?.persistenceSnapshot,
Expand Down
4 changes: 4 additions & 0 deletions Harbor/Models/DownloadSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions Harbor/Models/RequestHeader.swift
Original file line number Diff line number Diff line change
@@ -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<String>()

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)
}
}
27 changes: 23 additions & 4 deletions Harbor/Services/Aria2TorrentService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand All @@ -457,6 +458,7 @@ actor Aria2TorrentService {

var options = downloadOptions(
destinationFolderPath: destinationFolderPath,
requestHeaders: requestHeaders,
transferOptions: transferOptions
)
options["gid"] = gid
Expand Down Expand Up @@ -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 {
Expand All @@ -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",
Expand All @@ -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: [
Expand Down Expand Up @@ -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(
Expand Down
10 changes: 9 additions & 1 deletion Harbor/Services/DownloadCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand All @@ -377,6 +380,7 @@ final class DownloadCoordinator: NSObject, @unchecked Sendable {
downloadID: id,
attemptIdentifier: attemptIdentifier,
sourceURL: sourceURL,
requestHeaders: requestHeaders,
session: session,
task: task,
state: OwnedPartialState(
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion Harbor/Services/DownloadHTTPResponsePolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
Loading