mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-27 20:40:12 +00:00
ae3476293e
- fix(media): use temp config file for yt-dlp credentials - fix(rpc): exclude rpcSecret and rpcPort from serialization - fix(ssrf): validate private hosts for all fetches - fix(uri): add rate limiting to firelink scheme - fix(auth): implement HMAC-SHA256 request signing for LocalExtensionServer - fix(fs): prevent path traversal during file deletion - fix(fs): apply strict 0o600 permissions to aria2.conf - fix(crypto): remove insecure UUID fallback for pairing token - fix(http): strip CRLF characters from custom headers - fix(network): use POSIX socket to eliminate port-finding race window - fix(ui): limit text input lengths in Add Downloads view - fix(concurrency): implement thread-safe temp directory cleanup - feat(ui): modernize sidebar and download table components
289 lines
8.6 KiB
Swift
289 lines
8.6 KiB
Swift
import Foundation
|
|
|
|
enum DownloadStatus: String, Codable, CaseIterable, Sendable {
|
|
case queued = "Queued"
|
|
case downloading = "Downloading"
|
|
case paused = "Paused"
|
|
case completed = "Completed"
|
|
case failed = "Failed"
|
|
case canceled = "Canceled"
|
|
}
|
|
|
|
enum DownloadCategory: String, Codable, CaseIterable, Sendable {
|
|
case musics = "Musics"
|
|
case movies = "Movies"
|
|
case compressed = "Compressed"
|
|
case pictures = "Pictures"
|
|
case documents = "Documents"
|
|
case other = "Other"
|
|
|
|
var symbolName: String {
|
|
switch self {
|
|
case .musics: "music.note"
|
|
case .movies: "film"
|
|
case .compressed: "archivebox"
|
|
case .pictures: "photo"
|
|
case .documents: "doc.text"
|
|
case .other: "folder"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct DownloadQueue: Identifiable, Codable, Equatable, Sendable {
|
|
static let mainQueueID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")!
|
|
|
|
var id = UUID()
|
|
var name: String
|
|
|
|
var isMain: Bool {
|
|
id == Self.mainQueueID
|
|
}
|
|
|
|
static var main: DownloadQueue {
|
|
DownloadQueue(id: mainQueueID, name: "Main queue")
|
|
}
|
|
}
|
|
|
|
struct DownloadCredentials: Codable, Equatable, Sendable {
|
|
var username: String
|
|
var password: String
|
|
|
|
var isEmpty: Bool {
|
|
username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
|
password.isEmpty
|
|
}
|
|
}
|
|
|
|
enum ChecksumAlgorithm: String, Codable, CaseIterable, Identifiable, Sendable {
|
|
case md5
|
|
case sha1 = "sha-1"
|
|
case sha256 = "sha-256"
|
|
case sha512 = "sha-512"
|
|
|
|
var id: String { rawValue }
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .md5: "MD5"
|
|
case .sha1: "SHA-1"
|
|
case .sha256: "SHA-256"
|
|
case .sha512: "SHA-512"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct DownloadChecksum: Codable, Equatable, Sendable {
|
|
var algorithm: ChecksumAlgorithm
|
|
var value: String
|
|
|
|
var normalized: DownloadChecksum {
|
|
DownloadChecksum(
|
|
algorithm: algorithm,
|
|
value: value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
)
|
|
}
|
|
|
|
var isEmpty: Bool {
|
|
value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
}
|
|
}
|
|
|
|
struct DownloadRequestHeader: Codable, Equatable, Sendable {
|
|
var name: String
|
|
var value: String
|
|
|
|
var normalized: DownloadRequestHeader {
|
|
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: "")
|
|
let cleanValue = value.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: "")
|
|
return DownloadRequestHeader(name: cleanName, value: cleanValue)
|
|
}
|
|
|
|
var isEmpty: Bool {
|
|
let clean = normalized
|
|
return clean.name.isEmpty && clean.value.isEmpty
|
|
}
|
|
|
|
var headerLine: String {
|
|
let clean = normalized
|
|
return "\(clean.name): \(clean.value)"
|
|
}
|
|
}
|
|
|
|
struct DownloadTransferOptions: Equatable, Sendable {
|
|
var checksum: DownloadChecksum?
|
|
var requestHeaders: [DownloadRequestHeader] = []
|
|
var cookieHeader: String?
|
|
var mirrorURLs: [URL] = []
|
|
}
|
|
|
|
enum DownloadTransferOptionParser {
|
|
static func parseHeaders(_ text: String) -> [DownloadRequestHeader] {
|
|
headerLines(text).compactMap { line in
|
|
guard let colonIndex = line.firstIndex(of: ":") else { return nil }
|
|
let name = String(line[..<colonIndex]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let value = String(line[line.index(after: colonIndex)...]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let header = DownloadRequestHeader(name: name, value: value).normalized
|
|
return header.isEmpty || header.name.isEmpty ? nil : header
|
|
}
|
|
}
|
|
|
|
static func invalidHeaderLines(_ text: String) -> [String] {
|
|
headerLines(text).filter { line in
|
|
guard let colonIndex = line.firstIndex(of: ":") else { return true }
|
|
let name = String(line[..<colonIndex]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return name.isEmpty
|
|
}
|
|
}
|
|
|
|
static func cleanCookieHeader(_ text: String) -> String? {
|
|
var value = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if value.lowercased().hasPrefix("cookie:") {
|
|
value = String(value.dropFirst("cookie:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
return value.isEmpty ? nil : value
|
|
}
|
|
|
|
static func parseMirrorURLs(_ text: String) -> [URL] {
|
|
DownloadURLParser.parse(text)
|
|
}
|
|
|
|
static func invalidMirrorLines(_ text: String) -> [String] {
|
|
text.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
|
|
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
.filter { !$0.isEmpty && DownloadURLParser.parse($0).isEmpty }
|
|
}
|
|
|
|
private static func headerLines(_ text: String) -> [String] {
|
|
text.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
|
|
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
.filter { !$0.isEmpty }
|
|
}
|
|
}
|
|
|
|
struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
|
|
var id = UUID()
|
|
var url: URL
|
|
var fileName: String
|
|
var category: DownloadCategory
|
|
var destinationDirectory: URL
|
|
var connectionsPerServer: Int
|
|
var credentials: DownloadCredentials?
|
|
var checksum: DownloadChecksum?
|
|
var requestHeaders: [DownloadRequestHeader]?
|
|
var cookieHeader: String?
|
|
var mirrorURLs: [URL]?
|
|
var speedLimitKiBPerSecond: Int?
|
|
var status: DownloadStatus = .queued
|
|
var progress: Double = 0
|
|
var speedText: String = "-"
|
|
var etaText: String = "-"
|
|
var connectionCount: Int = 0
|
|
var sizeBytes: Int64?
|
|
var bytesText: String = "-"
|
|
var message: String = ""
|
|
var createdAt = Date()
|
|
var lastTryAt: Date?
|
|
var autoResumeOnLaunch: Bool?
|
|
var queueID: UUID?
|
|
var rpcPort: Int?
|
|
var rpcSecret: String?
|
|
var mediaFormatSelector: String?
|
|
var isAudioOnlyMedia: Bool?
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case id, url, fileName, category, destinationDirectory, connectionsPerServer
|
|
case credentials, checksum, requestHeaders, cookieHeader, mirrorURLs, speedLimitKiBPerSecond
|
|
case status, progress, speedText, etaText, connectionCount, sizeBytes, bytesText, message
|
|
case createdAt, lastTryAt, autoResumeOnLaunch, queueID
|
|
case mediaFormatSelector, isAudioOnlyMedia
|
|
}
|
|
|
|
var displaySpeedText: String {
|
|
status == .downloading ? speedText : "-"
|
|
}
|
|
|
|
var displayETAText: String {
|
|
status == .downloading ? etaText : "-"
|
|
}
|
|
|
|
var destinationPath: String {
|
|
destinationDirectory.appendingPathComponent(fileName).path
|
|
}
|
|
|
|
var sortableSize: Int64 {
|
|
sizeBytes ?? 0
|
|
}
|
|
|
|
var transferOptions: DownloadTransferOptions {
|
|
DownloadTransferOptions(
|
|
checksum: checksum,
|
|
requestHeaders: requestHeaders ?? [],
|
|
cookieHeader: cookieHeader,
|
|
mirrorURLs: mirrorURLs ?? []
|
|
)
|
|
}
|
|
|
|
var speedLimitText: String {
|
|
guard let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 else {
|
|
return "No limit"
|
|
}
|
|
return "\(speedLimitKiBPerSecond) KiB/s"
|
|
}
|
|
|
|
var redactedForPersistence: DownloadItem {
|
|
var item = self
|
|
if item.credentials != nil {
|
|
item.credentials?.password = ""
|
|
}
|
|
item.cookieHeader = nil
|
|
item.requestHeaders = item.requestHeaders?.filter { !$0.containsSensitiveValue }
|
|
return item
|
|
}
|
|
}
|
|
|
|
private extension DownloadRequestHeader {
|
|
var containsSensitiveValue: Bool {
|
|
switch normalized.name.lowercased() {
|
|
case "authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token":
|
|
true
|
|
default:
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
struct DownloadProgress: Equatable, Sendable {
|
|
var fraction: Double
|
|
var bytesText: String
|
|
var speedText: String
|
|
var etaText: String
|
|
var connectionCount: Int
|
|
}
|
|
|
|
struct PendingDownload: Identifiable, Equatable, Sendable {
|
|
enum MetadataState: Equatable, Sendable {
|
|
case pending
|
|
case loading
|
|
case loaded
|
|
case failed(String)
|
|
}
|
|
|
|
var id = UUID()
|
|
var url: URL
|
|
var fileName: String
|
|
var category: DownloadCategory
|
|
var defaultDirectory: URL
|
|
var sizeBytes: Int64?
|
|
var mimeType: String?
|
|
var state: MetadataState = .pending
|
|
|
|
var isMedia: Bool = false
|
|
var mediaOptions: [CleanFormatOption] = []
|
|
var selectedMediaOption: CleanFormatOption?
|
|
var mediaMetadata: MediaMetadata?
|
|
|
|
var destinationPath: String {
|
|
defaultDirectory.appendingPathComponent(fileName).path
|
|
}
|
|
}
|