mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-06 01:17:48 +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
231 lines
9.5 KiB
Swift
231 lines
9.5 KiB
Swift
import Foundation
|
|
|
|
enum DownloadURLParser {
|
|
private static let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
|
|
|
static func parse(_ text: String) -> [URL] {
|
|
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
|
let detected = detector?.matches(in: text, range: range).compactMap(\.url) ?? []
|
|
|
|
let tokenized = text
|
|
.components(separatedBy: CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: ",;")))
|
|
.compactMap { token -> URL? in
|
|
let trimmed = token.trimmingCharacters(in: CharacterSet(charactersIn: "\"'<>[]()"))
|
|
guard let url = URL(string: trimmed),
|
|
let scheme = url.scheme?.lowercased(),
|
|
["http", "https", "ftp", "sftp"].contains(scheme) else {
|
|
return nil
|
|
}
|
|
return url
|
|
}
|
|
|
|
var seen = Set<String>()
|
|
return (detected + tokenized).filter { url in
|
|
guard let scheme = url.scheme?.lowercased(),
|
|
["http", "https", "ftp", "sftp"].contains(scheme) else {
|
|
return false
|
|
}
|
|
return seen.insert(url.absoluteString).inserted
|
|
}
|
|
}
|
|
}
|
|
|
|
enum DownloadMetadataFetcher {
|
|
static func fetch(
|
|
for url: URL,
|
|
settings: AppSettings,
|
|
credentials: DownloadCredentials? = nil,
|
|
transferOptions: DownloadTransferOptions = DownloadTransferOptions(),
|
|
isAutoFetch: Bool = false
|
|
) async -> PendingDownload {
|
|
let initialName = FileClassifier.fileName(from: url)
|
|
let initialCategory = FileClassifier.category(forFileName: initialName)
|
|
let initialDirectory = await settings.destinationDirectory(for: initialCategory)
|
|
var pending = PendingDownload(
|
|
url: url,
|
|
fileName: initialName,
|
|
category: initialCategory,
|
|
defaultDirectory: initialDirectory,
|
|
state: .loading
|
|
)
|
|
|
|
guard url.scheme?.lowercased().hasPrefix("http") == true else {
|
|
pending.state = .loaded
|
|
return pending
|
|
}
|
|
|
|
if let host = url.host {
|
|
let isPrivate = await Task.detached {
|
|
isPrivateHost(host)
|
|
}.value
|
|
if isPrivate {
|
|
pending.state = .loaded
|
|
return pending
|
|
}
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "HEAD"
|
|
request.timeoutInterval = 12
|
|
request.setValue("Firelink/0.1", forHTTPHeaderField: "User-Agent")
|
|
|
|
let normalizedHeaders = transferOptions.requestHeaders.map(\.normalized).filter { !$0.isEmpty }
|
|
let hasAuthorizationHeader = normalizedHeaders.contains { $0.name.caseInsensitiveCompare("Authorization") == .orderedSame }
|
|
if let credentials, !credentials.isEmpty, !hasAuthorizationHeader {
|
|
let token = "\(credentials.username):\(credentials.password)"
|
|
.data(using: .utf8)?
|
|
.base64EncodedString()
|
|
if let token {
|
|
request.setValue("Basic \(token)", forHTTPHeaderField: "Authorization")
|
|
}
|
|
}
|
|
|
|
for header in normalizedHeaders {
|
|
request.setValue(header.value, forHTTPHeaderField: header.name)
|
|
}
|
|
if let cookieHeader = transferOptions.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines), !cookieHeader.isEmpty {
|
|
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
|
|
}
|
|
|
|
do {
|
|
let (_, response) = try await URLSession.shared.data(for: request)
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
pending.state = .loaded
|
|
return pending
|
|
}
|
|
|
|
if let disposition = httpResponse.value(forHTTPHeaderField: "Content-Disposition"),
|
|
let fileName = fileName(fromContentDisposition: disposition) {
|
|
pending.fileName = FileClassifier.sanitizedFileName(fileName)
|
|
pending.category = FileClassifier.category(forFileName: pending.fileName)
|
|
pending.defaultDirectory = await settings.destinationDirectory(for: pending.category)
|
|
}
|
|
|
|
if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"),
|
|
let bytes = Int64(contentLength) {
|
|
pending.sizeBytes = bytes
|
|
} else if response.expectedContentLength > 0 {
|
|
pending.sizeBytes = response.expectedContentLength
|
|
}
|
|
|
|
pending.mimeType = httpResponse.mimeType
|
|
pending.state = .loaded
|
|
} catch {
|
|
pending.state = .failed(error.localizedDescription)
|
|
}
|
|
|
|
return pending
|
|
}
|
|
|
|
private static func fileName(fromContentDisposition header: String) -> String? {
|
|
let parts = header.components(separatedBy: ";")
|
|
for part in parts {
|
|
let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if trimmed.lowercased().hasPrefix("filename*="),
|
|
let value = trimmed.components(separatedBy: "''").last?.removingPercentEncoding {
|
|
return value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
|
}
|
|
|
|
if trimmed.lowercased().hasPrefix("filename=") {
|
|
return String(trimmed.dropFirst("filename=".count))
|
|
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func isPrivateHost(_ host: String) -> Bool {
|
|
let h = host.lowercased()
|
|
if h == "localhost" || h.hasSuffix(".local") { return true }
|
|
if !h.contains(".") && !h.contains(":") { return true }
|
|
|
|
var hints = addrinfo(
|
|
ai_flags: 0,
|
|
ai_family: AF_UNSPEC,
|
|
ai_socktype: SOCK_STREAM,
|
|
ai_protocol: 0,
|
|
ai_addrlen: 0,
|
|
ai_canonname: nil,
|
|
ai_addr: nil,
|
|
ai_next: nil
|
|
)
|
|
|
|
var res: UnsafeMutablePointer<addrinfo>?
|
|
if getaddrinfo(host, nil, &hints, &res) == 0 {
|
|
var current = res
|
|
while let info = current {
|
|
let family = info.pointee.ai_family
|
|
if family == AF_INET {
|
|
let addr = info.pointee.ai_addr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee }
|
|
let ip = UInt32(bigEndian: addr.sin_addr.s_addr)
|
|
let first = (ip >> 24) & 0xFF
|
|
let second = (ip >> 16) & 0xFF
|
|
|
|
if first == 127 || first == 10 || (first == 192 && second == 168) {
|
|
freeaddrinfo(res)
|
|
return true
|
|
}
|
|
if first == 172 && (16...31).contains(second) {
|
|
freeaddrinfo(res)
|
|
return true
|
|
}
|
|
if first == 169 && second == 254 {
|
|
freeaddrinfo(res)
|
|
return true
|
|
}
|
|
} else if family == AF_INET6 {
|
|
let addr = info.pointee.ai_addr.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { $0.pointee }
|
|
let bytes = addr.sin6_addr.__u6_addr.__u6_addr8
|
|
|
|
let isLoopback = bytes.0 == 0 && bytes.1 == 0 && bytes.2 == 0 && bytes.3 == 0 &&
|
|
bytes.4 == 0 && bytes.5 == 0 && bytes.6 == 0 && bytes.7 == 0 &&
|
|
bytes.8 == 0 && bytes.9 == 0 && bytes.10 == 0 && bytes.11 == 0 &&
|
|
bytes.12 == 0 && bytes.13 == 0 && bytes.14 == 0 && bytes.15 == 1
|
|
|
|
let isULA = (bytes.0 & 0xFE) == 0xFC
|
|
let isLinkLocal = bytes.0 == 0xFE && (bytes.1 & 0xC0) == 0x80
|
|
|
|
let isIPv4Mapped = bytes.0 == 0 && bytes.1 == 0 && bytes.2 == 0 && bytes.3 == 0 &&
|
|
bytes.4 == 0 && bytes.5 == 0 && bytes.6 == 0 && bytes.7 == 0 &&
|
|
bytes.8 == 0 && bytes.9 == 0 && bytes.10 == 0xFF && bytes.11 == 0xFF
|
|
|
|
if isLoopback || isULA || isLinkLocal {
|
|
freeaddrinfo(res)
|
|
return true
|
|
}
|
|
if isIPv4Mapped {
|
|
let first = bytes.12
|
|
let second = bytes.13
|
|
if first == 127 || first == 10 || (first == 192 && second == 168) {
|
|
freeaddrinfo(res)
|
|
return true
|
|
}
|
|
if first == 172 && (16...31).contains(second) {
|
|
freeaddrinfo(res)
|
|
return true
|
|
}
|
|
if first == 169 && second == 254 {
|
|
freeaddrinfo(res)
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
current = info.pointee.ai_next
|
|
}
|
|
freeaddrinfo(res)
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
enum ByteFormatter {
|
|
static func string(_ bytes: Int64?) -> String {
|
|
guard let bytes else { return "Unknown" }
|
|
let formatter = ByteCountFormatter()
|
|
formatter.countStyle = .file
|
|
formatter.includesUnit = true
|
|
formatter.includesCount = true
|
|
return formatter.string(fromByteCount: bytes)
|
|
}
|
|
}
|