Files
Firelink/Sources/Firelink/Models.swift
T
NimBold ae3476293e fix(security): address v0.7.0 security audit vulnerabilities
- 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
2026-06-11 03:13:47 +03:30

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
}
}