From 0cbb982f5b212bea456859660240a5d2dd040aeb Mon Sep 17 00:00:00 2001 From: nimbold <11913706+nimbold@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:41:38 +0330 Subject: [PATCH] fix: harden download workflows --- Extensions/Firefox | 2 +- Makefile | 5 +- Package.swift | 5 +- Scripts/verify.sh | 8 ++ Sources/Firelink/AddDownloadsView.swift | 21 ++++- Sources/Firelink/AppSettings.swift | 2 +- Sources/Firelink/Aria2DownloadEngine.swift | 24 +++++ Sources/Firelink/ContentView.swift | 13 ++- Sources/Firelink/DownloadController.swift | 50 +++++++++- .../Firelink/DownloadMetadataFetcher.swift | 7 +- Sources/Firelink/DownloadPropertiesView.swift | 2 +- Sources/Firelink/DownloadTable.swift | 92 ++++++++++++------- Sources/Firelink/FileClassifier.swift | 24 ++++- Sources/Firelink/FirelinkApp.swift | 18 +++- Sources/Firelink/LocalExtensionServer.swift | 17 +++- Sources/Firelink/SchedulerController.swift | 15 ++- Sources/Firelink/SchedulerView.swift | 8 +- Sources/Firelink/SettingsView.swift | 32 ++++++- 18 files changed, 283 insertions(+), 62 deletions(-) create mode 100755 Scripts/verify.sh diff --git a/Extensions/Firefox b/Extensions/Firefox index 2f99dda..f443e09 160000 --- a/Extensions/Firefox +++ b/Extensions/Firefox @@ -1 +1 @@ -Subproject commit 2f99dda36676a6c74ec1eb908c4499530af683f5 +Subproject commit f443e096c2fac58cb3d06f97cd792a602582ef14 diff --git a/Makefile b/Makefile index 5f513cf..52c80ac 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build app dmg run clean +.PHONY: build app dmg run verify clean build: swift build -c release @@ -12,6 +12,9 @@ dmg: app run: swift run Firelink +verify: + Scripts/verify.sh + clean: swift package clean rm -rf build dist diff --git a/Package.swift b/Package.swift index 7aa2042..6dc54a6 100644 --- a/Package.swift +++ b/Package.swift @@ -13,7 +13,10 @@ let package = Package( targets: [ .executableTarget( name: "Firelink", - path: "Sources/Firelink" + path: "Sources/Firelink", + resources: [ + .process("Assets.xcassets") + ] ) ] ) diff --git a/Scripts/verify.sh b/Scripts/verify.sh new file mode 100755 index 0000000..ccec882 --- /dev/null +++ b/Scripts/verify.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +swift build +python3 -m json.tool Extensions/Firefox/manifest.json >/dev/null diff --git a/Sources/Firelink/AddDownloadsView.swift b/Sources/Firelink/AddDownloadsView.swift index 53b9dc3..ff092d4 100644 --- a/Sources/Firelink/AddDownloadsView.swift +++ b/Sources/Firelink/AddDownloadsView.swift @@ -52,6 +52,7 @@ struct AddDownloadsView: View { targetQueueID = controller.pendingAddQueueID ?? DownloadQueue.mainQueueID controller.pendingAddQueueID = nil if let text = controller.pendingPasteboardText { + applyPendingReferer() linkText = text controller.pendingPasteboardText = nil } @@ -388,6 +389,24 @@ struct AddDownloadsView: View { } } + private func applyPendingReferer() { + guard let referer = controller.pendingReferer?.trimmingCharacters(in: .whitespacesAndNewlines), + !referer.isEmpty, + URL(string: referer) != nil else { + controller.pendingReferer = nil + return + } + + let refererHeader = "Referer: \(referer)" + let existingHeaders = DownloadTransferOptionParser.parseHeaders(headerText) + if !existingHeaders.contains(where: { $0.normalized.name.lowercased() == "referer" }) { + headerText = headerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? refererHeader + : "\(headerText.trimmingCharacters(in: .whitespacesAndNewlines))\n\(refererHeader)" + } + controller.pendingReferer = nil + } + private func refreshMetadata(for text: String) { let urls = DownloadURLParser.parse(text) metadataTask?.cancel() @@ -458,7 +477,7 @@ struct AddDownloadsView: View { var savedHosts = Set() for item in pendingDownloads { if let host = item.url.host, !savedHosts.contains(host) { - settings.addSiteLogin(urlPattern: "*.\(host)", username: cleanUsername, password: authPassword) + settings.addSiteLogin(urlPattern: host, username: cleanUsername, password: authPassword) savedHosts.insert(host) } } diff --git a/Sources/Firelink/AppSettings.swift b/Sources/Firelink/AppSettings.swift index 4b95ada..0e9530a 100644 --- a/Sources/Firelink/AppSettings.swift +++ b/Sources/Firelink/AppSettings.swift @@ -285,7 +285,7 @@ final class AppSettings: ObservableObject { return absolute.contains(normalizedPattern) } - return host == normalizedPattern || host.hasSuffix(".\(normalizedPattern)") + return host == normalizedPattern } private static func defaultDirectories() -> [DownloadCategory: String] { diff --git a/Sources/Firelink/Aria2DownloadEngine.swift b/Sources/Firelink/Aria2DownloadEngine.swift index 4adc549..1cd5bc1 100644 --- a/Sources/Firelink/Aria2DownloadEngine.swift +++ b/Sources/Firelink/Aria2DownloadEngine.swift @@ -202,6 +202,30 @@ final class Aria2DownloadEngine { } } + static func updateSpeedLimit(handle: Handle, speedLimitKiBPerSecond: Int?) async { + guard let url = URL(string: "http://127.0.0.1:\(handle.rpcPort)/jsonrpc") else { return } + + let limitValue = speedLimitKiBPerSecond.map { "\($0)K" } ?? "0" + let payload: [String: Any] = [ + "jsonrpc": "2.0", + "method": "aria2.changeGlobalOption", + "id": UUID().uuidString, + "params": [ + "token:\(handle.rpcSecret)", + ["max-download-limit": limitValue] + ] + ] + + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = data + request.timeoutInterval = 3 + + _ = try? await URLSession.shared.data(for: request) + } + private func arguments( for item: DownloadItem, proxyConfiguration: DownloadProxyConfiguration, diff --git a/Sources/Firelink/ContentView.swift b/Sources/Firelink/ContentView.swift index 25fe883..7fc863d 100644 --- a/Sources/Firelink/ContentView.swift +++ b/Sources/Firelink/ContentView.swift @@ -28,6 +28,7 @@ struct ContentView: View { if let url = url { DispatchQueue.main.async { controller.pendingPasteboardText = url.absoluteString + controller.pendingReferer = nil openWindow(id: "add-downloads") } } @@ -37,6 +38,7 @@ struct ContentView: View { if let text = text { DispatchQueue.main.async { controller.pendingPasteboardText = text + controller.pendingReferer = nil openWindow(id: "add-downloads") } } @@ -117,7 +119,7 @@ struct ContentView: View { } .disabled(!canStop) - let canStart = selectedItems.isEmpty ? true : selectedItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) + let canStart = selectedItems.isEmpty ? hasQueuedDownloads(in: queueID) : selectedItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) Button { if selectedItems.isEmpty { controller.startQueue(queueID: queueID) @@ -183,6 +185,7 @@ struct ContentView: View { private func handlePaste(queueID: UUID?) { guard let text = NSPasteboard.general.string(forType: .string), !text.isEmpty else { return } controller.pendingPasteboardText = text + controller.pendingReferer = nil controller.pendingAddQueueID = queueID openWindow(id: "add-downloads") } @@ -199,6 +202,14 @@ struct ContentView: View { return controller.activeCount > 0 } + private func hasQueuedDownloads(in queueID: UUID?) -> Bool { + if let queueID { + return controller.queueItems(for: queueID).contains { $0.status == .queued } + } + + return controller.queuedCount > 0 + } + private func filteredDownloads(for filter: DownloadSidebarFilter) -> [DownloadItem] { switch filter { case .all: diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift index f04904c..a106deb 100644 --- a/Sources/Firelink/DownloadController.swift +++ b/Sources/Firelink/DownloadController.swift @@ -9,6 +9,7 @@ final class DownloadController: ObservableObject { @Published var queues: [DownloadQueue] = [.main] @Published var engineMessage = "" @Published var pendingPasteboardText: String? + @Published var pendingReferer: String? var pendingAddQueueID: UUID? private let settings: AppSettings @@ -38,6 +39,25 @@ final class DownloadController: ObservableObject { } } .store(in: &cancellables) + + settings.$globalSpeedLimitKiBPerSecond + .dropFirst() + .sink { [weak self] _ in + Task { @MainActor in + self?.applySpeedLimitsToActiveDownloads() + } + } + .store(in: &cancellables) + + settings.$maxConcurrentDownloads + .dropFirst() + .sink { [weak self] _ in + Task { @MainActor in + self?.applySpeedLimitsToActiveDownloads() + self?.pumpQueue() + } + } + .store(in: &cancellables) $downloads .dropFirst() @@ -131,10 +151,11 @@ final class DownloadController: ObservableObject { let speedLimitKiBPerSecond = normalizedSpeedLimit(speedLimitKiBPerSecond) let items = pendingDownloads.map { pending in - DownloadItem( + let fileName = FileClassifier.sanitizedFileName(pending.fileName) + return DownloadItem( url: pending.url, - fileName: pending.fileName, - category: pending.category, + fileName: fileName, + category: FileClassifier.category(forFileName: fileName), destinationDirectory: overrideDirectory ?? pending.defaultDirectory, connectionsPerServer: clampedConnections, credentials: credentials ?? settings.credentials(for: pending.url), @@ -526,8 +547,8 @@ final class DownloadController: ObservableObject { ) { update(id) { $0.url = url - $0.fileName = fileName - $0.category = FileClassifier.category(forFileName: fileName) + $0.fileName = FileClassifier.sanitizedFileName(fileName) + $0.category = FileClassifier.category(forFileName: $0.fileName) $0.destinationDirectory = destinationDirectory $0.connectionsPerServer = min(max(connectionsPerServer, 1), 16) $0.credentials = credentials @@ -543,6 +564,7 @@ final class DownloadController: ObservableObject { } else if credentials == nil { KeychainCredentialStore.deletePassword(for: id) } + applySpeedLimitToActiveDownload(id: id) saveDownloads() } @@ -568,6 +590,24 @@ final class DownloadController: ObservableObject { } } + private func applySpeedLimitsToActiveDownloads() { + for item in downloads where item.status == .downloading { + applySpeedLimitToActiveDownload(id: item.id) + } + } + + private func applySpeedLimitToActiveDownload(id: UUID) { + guard let handle = activeHandles[id], + let item = downloads.first(where: { $0.id == id }) else { + return + } + + let limit = effectiveSpeedLimitKiBPerSecond(for: item) + Task { + await Aria2DownloadEngine.updateSpeedLimit(handle: handle, speedLimitKiBPerSecond: limit) + } + } + private func markQueuedDownloadsForAutoResume(queueID: UUID?) { if let queueID { let normalizedID = normalizedQueueID(queueID) diff --git a/Sources/Firelink/DownloadMetadataFetcher.swift b/Sources/Firelink/DownloadMetadataFetcher.swift index 24a71b3..56ba4c1 100644 --- a/Sources/Firelink/DownloadMetadataFetcher.swift +++ b/Sources/Firelink/DownloadMetadataFetcher.swift @@ -1,8 +1,9 @@ import Foundation enum DownloadURLParser { + private static let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + static func parse(_ text: String) -> [URL] { - let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) let range = NSRange(text.startIndex..( + for item: DownloadItem, + @ViewBuilder content: () -> Content + ) -> some View { + content() + .contentShape(Rectangle()) + .onTapGesture(count: 2) { + performPrimaryAction(for: item) + } + } + + private func performPrimaryAction(for item: DownloadItem) { + if item.status == .completed { + openFile(item) + } else { + openWindow(value: item.id) + } + } + @ViewBuilder private func rowContextMenu(for itemIDs: Set) -> some View { let targetItems = controller.downloads.filter { itemIDs.contains($0.id) } diff --git a/Sources/Firelink/FileClassifier.swift b/Sources/Firelink/FileClassifier.swift index 674bc2e..339a6bf 100644 --- a/Sources/Firelink/FileClassifier.swift +++ b/Sources/Firelink/FileClassifier.swift @@ -57,10 +57,30 @@ enum FileClassifier { static func fileName(from url: URL) -> String { let pathName = url.lastPathComponent.removingPercentEncoding ?? url.lastPathComponent if !pathName.isEmpty, pathName != "/" { - return pathName + return sanitizedFileName(pathName) } let host = url.host(percentEncoded: false) ?? "download" - return "\(host)-download" + return sanitizedFileName("\(host)-download") + } + + static func sanitizedFileName(_ rawValue: String, fallback: String = "download") -> String { + let separators = CharacterSet(charactersIn: "/\\") + let invalidCharacters = separators + .union(.newlines) + .union(.controlCharacters) + + let components = rawValue + .trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: invalidCharacters) + .filter { !$0.isEmpty } + let clean = components.joined(separator: "_") + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard !clean.isEmpty, clean != ".", clean != ".." else { + return fallback + } + + return String(clean.prefix(255)) } } diff --git a/Sources/Firelink/FirelinkApp.swift b/Sources/Firelink/FirelinkApp.swift index e2ab8df..af5f06c 100644 --- a/Sources/Firelink/FirelinkApp.swift +++ b/Sources/Firelink/FirelinkApp.swift @@ -31,6 +31,7 @@ struct FirelinkApp: App { .modifier(AppFontSizeModifier(fontSize: settings.appFontSize)) .onOpenURL { url in controller.pendingPasteboardText = url.absoluteString + controller.pendingReferer = nil NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil) } .frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760) @@ -87,7 +88,7 @@ struct FirelinkApp: App { .environmentObject(controller) } label: { if let nsImage = { () -> NSImage? in - guard let url = Bundle.main.url(forResource: "MenuBarIconTemplate", withExtension: "png"), + guard let url = menuBarIconURL(), let img = NSImage(contentsOf: url) else { return nil } img.size = NSSize(width: 21, height: 21) img.isTemplate = true @@ -99,4 +100,19 @@ struct FirelinkApp: App { } } } + + private func menuBarIconURL() -> URL? { + if let bundled = Bundle.main.url(forResource: "MenuBarIconTemplate", withExtension: "png") { + return bundled + } + + let sourceFile = URL(fileURLWithPath: #filePath) + let projectRoot = sourceFile + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let sourceTreeIcon = projectRoot + .appendingPathComponent("Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png") + return FileManager.default.fileExists(atPath: sourceTreeIcon.path) ? sourceTreeIcon : nil + } } diff --git a/Sources/Firelink/LocalExtensionServer.swift b/Sources/Firelink/LocalExtensionServer.swift index 8100d46..d9c0934 100644 --- a/Sources/Firelink/LocalExtensionServer.swift +++ b/Sources/Firelink/LocalExtensionServer.swift @@ -4,25 +4,30 @@ import AppKit final class LocalExtensionServer: @unchecked Sendable { private enum Constants { - static let port = NWEndpoint.Port(rawValue: 6412)! + static let portRange = 6412...6422 static let maxRequestBytes = 128 * 1024 static let maxURLCount = 200 + static let extensionRequestHeader = "x-firelink-extension" + static let extensionRequestToken = "firelink-extension-v1" static let allowedSchemes = Set(["http", "https", "ftp", "sftp"]) } private let listener: NWListener private let downloadController: DownloadController private let queue = DispatchQueue(label: "local.firelink.server") + let port: UInt16 init?(downloadController: DownloadController) { self.downloadController = downloadController let parameters = NWParameters.tcp var createdListener: NWListener? - for portValue in 6412...6422 { + var selectedPort: UInt16? + for portValue in Constants.portRange { parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: UInt16(portValue))!) do { createdListener = try NWListener(using: parameters) + selectedPort = UInt16(portValue) break } catch { continue @@ -35,6 +40,7 @@ final class LocalExtensionServer: @unchecked Sendable { } self.listener = createdListener + self.port = selectedPort ?? 6412 } func start() { @@ -95,7 +101,7 @@ final class LocalExtensionServer: @unchecked Sendable { headers.append("Access-Control-Allow-Origin: \(origin)") headers.append("Vary: Origin") headers.append("Access-Control-Allow-Methods: POST, OPTIONS") - headers.append("Access-Control-Allow-Headers: Content-Type") + headers.append("Access-Control-Allow-Headers: Content-Type, X-Firelink-Extension") } let response = headers.joined(separator: "\r\n") + "\r\n\r\n" @@ -132,6 +138,10 @@ final class LocalExtensionServer: @unchecked Sendable { return .methodNotAllowed } + guard request.header(named: Constants.extensionRequestHeader) == Constants.extensionRequestToken else { + return .forbidden + } + guard request.header(named: "content-type")?.lowercased().contains("application/json") == true else { return .unsupportedMediaType } @@ -161,6 +171,7 @@ final class LocalExtensionServer: @unchecked Sendable { Task { @MainActor in self.downloadController.pendingPasteboardText = validURLs.joined(separator: "\n") + self.downloadController.pendingReferer = payload.referer NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil) NSApp.activate(ignoringOtherApps: true) } diff --git a/Sources/Firelink/SchedulerController.swift b/Sources/Firelink/SchedulerController.swift index 311db8c..9bd9365 100644 --- a/Sources/Firelink/SchedulerController.swift +++ b/Sources/Firelink/SchedulerController.swift @@ -35,7 +35,7 @@ struct SchedulerSettings: Codable, Equatable { var isEveryday: Bool = true var selectedDays: Set = Set(SchedulerDay.allCases) var postQueueAction: PostQueueAction = .doNothing - var targetQueueIDs: Set = [] + var targetQueueIDs: Set = [DownloadQueue.mainQueueID] } @MainActor @@ -62,6 +62,9 @@ final class SchedulerController: ObservableObject { } else { self.settings = SchedulerSettings() } + if self.settings.targetQueueIDs.isEmpty { + self.settings.targetQueueIDs = [DownloadQueue.mainQueueID] + } checkAutomationPermission() startTimer() @@ -131,7 +134,8 @@ final class SchedulerController: ObservableObject { } private func triggerQueues() { - let runnableQueueIDs = settings.targetQueueIDs.filter { queueID in + let targetQueueIDs = effectiveTargetQueueIDs() + let runnableQueueIDs = targetQueueIDs.filter { queueID in downloadController.queues.contains(where: { $0.id == queueID }) && downloadController.queueItems(for: queueID).contains(where: { $0.status == .queued }) } @@ -150,7 +154,8 @@ final class SchedulerController: ObservableObject { private func checkIfRunningFinished() { guard isRunning else { return } - let hasActiveItems = settings.targetQueueIDs.contains { queueID in + let targetQueueIDs = effectiveTargetQueueIDs() + let hasActiveItems = targetQueueIDs.contains { queueID in downloadController.queueItems(for: queueID).contains { $0.status == .queued || $0.status == .downloading } @@ -162,6 +167,10 @@ final class SchedulerController: ObservableObject { } } + private func effectiveTargetQueueIDs() -> Set { + settings.targetQueueIDs.isEmpty ? [DownloadQueue.mainQueueID] : settings.targetQueueIDs + } + private func performPostAction() { guard settings.postQueueAction != .doNothing else { return } diff --git a/Sources/Firelink/SchedulerView.swift b/Sources/Firelink/SchedulerView.swift index 941b8ed..b49692f 100644 --- a/Sources/Firelink/SchedulerView.swift +++ b/Sources/Firelink/SchedulerView.swift @@ -199,7 +199,9 @@ struct SchedulerView: View { isEveryday = schedulerController.settings.isEveryday selectedDays = schedulerController.settings.selectedDays postQueueAction = schedulerController.settings.postQueueAction - targetQueueIDs = schedulerController.settings.targetQueueIDs + targetQueueIDs = schedulerController.settings.targetQueueIDs.isEmpty + ? [DownloadQueue.mainQueueID] + : schedulerController.settings.targetQueueIDs } private func saveState() { @@ -208,7 +210,9 @@ struct SchedulerView: View { schedulerController.settings.isEveryday = isEveryday schedulerController.settings.selectedDays = selectedDays schedulerController.settings.postQueueAction = postQueueAction - schedulerController.settings.targetQueueIDs = targetQueueIDs + schedulerController.settings.targetQueueIDs = targetQueueIDs.isEmpty + ? [DownloadQueue.mainQueueID] + : targetQueueIDs schedulerController.saveSettings() } } diff --git a/Sources/Firelink/SettingsView.swift b/Sources/Firelink/SettingsView.swift index dccd0cc..727d4dc 100644 --- a/Sources/Firelink/SettingsView.swift +++ b/Sources/Firelink/SettingsView.swift @@ -666,7 +666,7 @@ private struct IntegrationSettingsPane: View { } private func copyExtensionToDownloads() { - guard let sourceURL = Bundle.main.url(forResource: "FirefoxExtension", withExtension: nil) else { + guard let sourceURL = bundledFirefoxExtensionURL() else { installMessage = "The bundled Firefox extension folder was not found." return } @@ -677,7 +677,7 @@ private struct IntegrationSettingsPane: View { try FileManager.default.removeItem(at: destinationURL) } - try FileManager.default.copyItem(at: sourceURL, to: destinationURL) + try copyFirefoxExtension(from: sourceURL, to: destinationURL) copiedExtensionURL = destinationURL installMessage = "Copied to \(destinationURL.path). Select manifest.json from this folder in Firefox." showCopiedExtensionInFinder() @@ -686,6 +686,34 @@ private struct IntegrationSettingsPane: View { } } + private func bundledFirefoxExtensionURL() -> URL? { + if let bundled = Bundle.main.url(forResource: "FirefoxExtension", withExtension: nil) { + return bundled + } + + let sourceFile = URL(fileURLWithPath: #filePath) + let projectRoot = sourceFile + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let sourceTreeExtension = projectRoot.appendingPathComponent("Extensions/Firefox", isDirectory: true) + return FileManager.default.fileExists(atPath: sourceTreeExtension.appendingPathComponent("manifest.json").path) + ? sourceTreeExtension + : nil + } + + private func copyFirefoxExtension(from sourceURL: URL, to destinationURL: URL) throws { + let fileManager = FileManager.default + try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true) + + for component in ["background.js", "content.js", "manifest.json", "icons", "popup"] { + try fileManager.copyItem( + at: sourceURL.appendingPathComponent(component), + to: destinationURL.appendingPathComponent(component) + ) + } + } + private func showCopiedExtensionInFinder() { let folderURL = copiedExtensionURL ?? downloadsExtensionURL let manifestURL = folderURL.appendingPathComponent("manifest.json")