From 375de6f46ead8a90d7635f263370cc0092d82871 Mon Sep 17 00:00:00 2001 From: kingbri Date: Sun, 30 Apr 2023 17:40:38 -0400 Subject: [PATCH] Debrid: Various updates to API and settings Debrid services can change their APIs at any time which negatively impacts user experiences on Ferrite. Add the following: - Ability for a user to add a manually generated API key only showing the last 4 characters for security purposes. - Make ephemeral auth sessions toggle-able. ASWebAuthenticationView does not automatically clear on toggle change. - Add the savedLinks endpoint for AllDebrid so users can access their downloads and magnets. - Add a links section to AD's cloud view. Signed-off-by: kingbri --- Ferrite.xcodeproj/project.pbxproj | 4 + Ferrite/API/AllDebridWrapper.swift | 52 +++++- Ferrite/API/PremiumizeWrapper.swift | 21 ++- Ferrite/API/RealDebridWrapper.swift | 37 ++-- Ferrite/Extensions/View.swift | 9 + Ferrite/Models/AllDebridModels.swift | 13 ++ Ferrite/Utils/FerriteKeychain.swift | 13 ++ Ferrite/ViewModels/DebridManager.swift | 172 +++++++++++++----- .../Views/CommonViews/HybridSecureField.swift | 22 ++- .../Library/Cloud/AllDebridCloudView.swift | 41 ++++- .../Library/Cloud/PremiumizeCloudView.swift | 2 +- .../Library/Cloud/RealDebridCloudView.swift | 1 + .../Library/DebridCloudView.swift | 2 +- .../Settings/SettingsDebridInfoView.swift | 33 +++- .../Views/RepresentableViews/WebView.swift | 10 +- Ferrite/Views/SettingsView.swift | 24 ++- 16 files changed, 374 insertions(+), 82 deletions(-) create mode 100644 Ferrite/Utils/FerriteKeychain.swift diff --git a/Ferrite.xcodeproj/project.pbxproj b/Ferrite.xcodeproj/project.pbxproj index d314abc..de3bb7d 100644 --- a/Ferrite.xcodeproj/project.pbxproj +++ b/Ferrite.xcodeproj/project.pbxproj @@ -142,6 +142,7 @@ 0CC2CA7429E24F63000A8585 /* ExpandedSearchable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC2CA7329E24F63000A8585 /* ExpandedSearchable.swift */; }; 0CC389532970AD900066D06F /* Action+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC389512970AD900066D06F /* Action+CoreDataClass.swift */; }; 0CC389542970AD900066D06F /* Action+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC389522970AD900066D06F /* Action+CoreDataProperties.swift */; }; + 0CD0265729FEFBF900A83D25 /* FerriteKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD0265629FEFBF900A83D25 /* FerriteKeychain.swift */; }; 0CD4030A29DA01B6008D9F03 /* PluginInfoMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4030929DA01B6008D9F03 /* PluginInfoMetaView.swift */; }; 0CD4030C29DA0222008D9F03 /* PluginInfoAboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4030B29DA0222008D9F03 /* PluginInfoAboutView.swift */; }; 0CD4CAC628C980EB0046E1DC /* HistoryActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */; }; @@ -288,6 +289,7 @@ 0CC389512970AD900066D06F /* Action+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Action+CoreDataClass.swift"; sourceTree = ""; }; 0CC389522970AD900066D06F /* Action+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Action+CoreDataProperties.swift"; sourceTree = ""; }; 0CC6E4D428A45BA000AF2BCC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 0CD0265629FEFBF900A83D25 /* FerriteKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FerriteKeychain.swift; sourceTree = ""; }; 0CD4030929DA01B6008D9F03 /* PluginInfoMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginInfoMetaView.swift; sourceTree = ""; }; 0CD4030B29DA0222008D9F03 /* PluginInfoAboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginInfoAboutView.swift; sourceTree = ""; }; 0CD4CAC528C980EB0046E1DC /* HistoryActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryActionsView.swift; sourceTree = ""; }; @@ -449,6 +451,7 @@ children = ( 0C44E2A728D4DDDC007711AE /* Application.swift */, 0C1A3E5529C9488C00DA9730 /* CodableWrapper.swift */, + 0CD0265629FEFBF900A83D25 /* FerriteKeychain.swift */, ); path = Utils; sourceTree = ""; @@ -852,6 +855,7 @@ 0C3E00D2296F4FD200ECECB2 /* PluginsView.swift in Sources */, 0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */, 0C7D11FE28AA03FE00ED92DB /* View.swift in Sources */, + 0CD0265729FEFBF900A83D25 /* FerriteKeychain.swift in Sources */, 0CA3B23728C2660700616D3A /* HistoryView.swift in Sources */, 0C70E40228C3CE9C00A5C72D /* ConditionalContextMenu.swift in Sources */, 0C50B7D0299DF63C00A9FA3C /* UIDevice.swift in Sources */, diff --git a/Ferrite/API/AllDebridWrapper.swift b/Ferrite/API/AllDebridWrapper.swift index fbf0163..2b850ff 100644 --- a/Ferrite/API/AllDebridWrapper.swift +++ b/Ferrite/API/AllDebridWrapper.swift @@ -6,12 +6,10 @@ // import Foundation -import KeychainSwift // TODO: Fix errors public class AllDebrid { let jsonDecoder = JSONDecoder() - let keychain = KeychainSwift() let baseApiUrl = "https://api.alldebrid.com/v4" let appName = "Ferrite" @@ -60,7 +58,7 @@ public class AllDebrid { // If there's an API key from the response, end the task successfully if let apiKeyResponse = rawResponse { - keychain.set(apiKeyResponse.apikey, forKey: "AllDebrid.ApiKey") + FerriteKeychain.shared.set(apiKeyResponse.apikey, forKey: "AllDebrid.ApiKey") return } else { @@ -77,14 +75,27 @@ public class AllDebrid { } } + // Adds a manual API key instead of web auth + public func setApiKey(_ key: String) -> Bool { + FerriteKeychain.shared.set(key, forKey: "AllDebrid.ApiKey") + UserDefaults.standard.set(true, forKey: "AllDebrid.UseManualKey") + + return FerriteKeychain.shared.get("AllDebrid.ApiKey") == key + } + + public func getToken() -> String? { + return FerriteKeychain.shared.get("AllDebrid.ApiKey") + } + // Clears tokens. No endpoint to deregister a device public func deleteTokens() { - keychain.delete("AllDebrid.ApiKey") + FerriteKeychain.shared.delete("AllDebrid.ApiKey") + UserDefaults.standard.removeObject(forKey: "AllDebrid.UseManualKey") } // Wrapper request function which matches the responses and returns data @discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data { - guard let token = keychain.get("AllDebrid.ApiKey") else { + guard let token = getToken() else { throw ADError.InvalidToken } @@ -201,6 +212,37 @@ public class AllDebrid { return rawResponse.link } + public func saveLink(link: String) async throws { + let queryItems = [ + URLQueryItem(name: "links[]", value: link) + ] + var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/user/links/save", queryItems: queryItems)) + + try await performRequest(request: &request, requestName: #function) + } + + public func savedLinks() async throws -> [SavedLink] { + var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/user/links")) + + let data = try await performRequest(request: &request, requestName: #function) + let rawResponse = try jsonDecoder.decode(ADResponse.self, from: data).data + + if rawResponse.links.isEmpty { + throw ADError.EmptyData + } else { + return rawResponse.links + } + } + + public func deleteLink(link: String) async throws { + let queryItems = [ + URLQueryItem(name: "link", value: link) + ] + var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/user/links/delete", queryItems: queryItems)) + + try await performRequest(request: &request, requestName: #function) + } + public func instantAvailability(magnets: [Magnet]) async throws -> [IA] { let queryItems = magnets.map { URLQueryItem(name: "magnets[]", value: $0.hash) } var request = URLRequest(url: try buildRequestURL(urlString: "\(baseApiUrl)/magnet/instant", queryItems: queryItems)) diff --git a/Ferrite/API/PremiumizeWrapper.swift b/Ferrite/API/PremiumizeWrapper.swift index ccd4207..47bff26 100644 --- a/Ferrite/API/PremiumizeWrapper.swift +++ b/Ferrite/API/PremiumizeWrapper.swift @@ -6,11 +6,9 @@ // import Foundation -import KeychainSwift public class Premiumize { let jsonDecoder = JSONDecoder() - let keychain = KeychainSwift() let baseAuthUrl = "https://www.premiumize.me/authorize" let baseApiUrl = "https://www.premiumize.me/api" @@ -45,17 +43,30 @@ public class Premiumize { throw PMError.InvalidToken } - keychain.set(accessToken, forKey: "Premiumize.AccessToken") + FerriteKeychain.shared.set(accessToken, forKey: "Premiumize.AccessToken") + } + + // Adds a manual API key instead of web auth + public func setApiKey(_ key: String) -> Bool { + FerriteKeychain.shared.set(key, forKey: "Premiumize.AccessToken") + UserDefaults.standard.set(true, forKey: "Premiumize.UseManualKey") + + return FerriteKeychain.shared.get("Premiumize.AccessToken") == key + } + + public func getToken() -> String? { + return FerriteKeychain.shared.get("Premiumize.AccessToken") } // Clears tokens. No endpoint to deregister a device public func deleteTokens() { - keychain.delete("Premiumize.AccessToken") + FerriteKeychain.shared.delete("Premiumize.AccessToken") + UserDefaults.standard.removeObject(forKey: "Premiumize.UseManualKey") } // Wrapper request function which matches the responses and returns data @discardableResult private func performRequest(request: inout URLRequest, requestName: String) async throws -> Data { - guard let token = keychain.get("Premiumize.AccessToken") else { + guard let token = getToken() else { throw PMError.InvalidToken } diff --git a/Ferrite/API/RealDebridWrapper.swift b/Ferrite/API/RealDebridWrapper.swift index a81f69c..1291b2e 100644 --- a/Ferrite/API/RealDebridWrapper.swift +++ b/Ferrite/API/RealDebridWrapper.swift @@ -6,11 +6,9 @@ // import Foundation -import KeychainSwift public class RealDebrid { let jsonDecoder = JSONDecoder() - let keychain = KeychainSwift() let baseAuthUrl = "https://api.real-debrid.com/oauth/v2" let baseApiUrl = "https://api.real-debrid.com/rest/1.0" @@ -83,7 +81,7 @@ public class RealDebrid { // If there's a client ID from the response, end the task successfully if let clientId = rawResponse?.clientID, let clientSecret = rawResponse?.clientSecret { await setUserDefaultsValue(clientId, forKey: "RealDebrid.ClientId") - keychain.set(clientSecret, forKey: "RealDebrid.ClientSecret") + FerriteKeychain.shared.set(clientSecret, forKey: "RealDebrid.ClientSecret") try await getTokens(deviceCode: deviceCode) @@ -102,13 +100,13 @@ public class RealDebrid { } } - // Fetch all tokens for the user and store in keychain + // Fetch all tokens for the user and store in FerriteKeychain.shared public func getTokens(deviceCode: String) async throws { guard let clientId = UserDefaults.standard.string(forKey: "RealDebrid.ClientId") else { throw RDError.EmptyData } - guard let clientSecret = keychain.get("RealDebrid.ClientSecret") else { + guard let clientSecret = FerriteKeychain.shared.get("RealDebrid.ClientSecret") else { throw RDError.EmptyData } @@ -130,8 +128,8 @@ public class RealDebrid { let rawResponse = try jsonDecoder.decode(TokenResponse.self, from: data) - keychain.set(rawResponse.accessToken, forKey: "RealDebrid.AccessToken") - keychain.set(rawResponse.refreshToken, forKey: "RealDebrid.RefreshToken") + FerriteKeychain.shared.set(rawResponse.accessToken, forKey: "RealDebrid.AccessToken") + FerriteKeychain.shared.set(rawResponse.refreshToken, forKey: "RealDebrid.RefreshToken") let accessTimestamp = Date().timeIntervalSince1970 + Double(rawResponse.expiresIn) await setUserDefaultsValue(accessTimestamp, forKey: "RealDebrid.AccessTokenStamp") @@ -142,7 +140,7 @@ public class RealDebrid { if Date().timeIntervalSince1970 > accessTokenStamp { do { - if let refreshToken = keychain.get("RealDebrid.RefreshToken") { + if let refreshToken = FerriteKeychain.shared.get("RealDebrid.RefreshToken") { try await getTokens(deviceCode: refreshToken) } } catch { @@ -151,22 +149,35 @@ public class RealDebrid { } } - return keychain.get("RealDebrid.AccessToken") + return FerriteKeychain.shared.get("RealDebrid.AccessToken") + } + + // Adds a manual API key instead of web auth + // Clear out existing refresh tokens and timestamps + public func setApiKey(_ key: String) -> Bool { + FerriteKeychain.shared.set(key, forKey: "RealDebrid.AccessToken") + FerriteKeychain.shared.delete("RealDebrid.RefreshToken") + FerriteKeychain.shared.delete("RealDebrid.AccessTokenStamp") + + UserDefaults.standard.set(true, forKey: "RealDebrid.UseManualKey") + + return FerriteKeychain.shared.get("RealDebrid.AccessToken") == key } public func deleteTokens() async throws { - keychain.delete("RealDebrid.RefreshToken") - keychain.delete("RealDebrid.ClientSecret") + FerriteKeychain.shared.delete("RealDebrid.RefreshToken") + FerriteKeychain.shared.delete("RealDebrid.ClientSecret") await removeUserDefaultsValue(forKey: "RealDebrid.ClientId") await removeUserDefaultsValue(forKey: "RealDebrid.AccessTokenStamp") // Run the request, doesn't matter if it fails - if let token = keychain.get("RealDebrid.AccessToken") { + if let token = FerriteKeychain.shared.get("RealDebrid.AccessToken") { var request = URLRequest(url: URL(string: "\(baseApiUrl)/disable_access_token")!) request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") _ = try? await URLSession.shared.data(for: request) - keychain.delete("RealDebrid.AccessToken") + FerriteKeychain.shared.delete("RealDebrid.AccessToken") + await removeUserDefaultsValue(forKey: "RealDebrid.UseManualKey") } } diff --git a/Ferrite/Extensions/View.swift b/Ferrite/Extensions/View.swift index d5f4880..73bafca 100644 --- a/Ferrite/Extensions/View.swift +++ b/Ferrite/Extensions/View.swift @@ -9,6 +9,15 @@ import Introspect import SwiftUI extension View { + // Modifies properties of a view. Works the same way as a ViewModifier + // From: https://github.com/SwiftUIX/SwiftUIX/blob/master/Sources/Intermodular/Extensions/SwiftUI/View%2B%2B.swift#L10 + public func modifyViewProp(_ body: (inout Self) -> Void) -> Self { + var result = self + body(&result) + + return result + } + // MARK: Modifiers func conditionalContextMenu(id: some Hashable, diff --git a/Ferrite/Models/AllDebridModels.swift b/Ferrite/Models/AllDebridModels.swift index 755ae4a..c12b5c1 100644 --- a/Ferrite/Models/AllDebridModels.swift +++ b/Ferrite/Models/AllDebridModels.swift @@ -130,6 +130,19 @@ public extension AllDebrid { let link: String } + // MARK: - SavedLinksResponse + + struct SavedLinksResponse: Codable { + let links: [SavedLink] + } + + struct SavedLink: Codable, Hashable { + let link: String + let date: Int + let filename: String + let size: Int + } + // MARK: - InstantAvailabilityResponse struct InstantAvailabilityResponse: Codable { diff --git a/Ferrite/Utils/FerriteKeychain.swift b/Ferrite/Utils/FerriteKeychain.swift new file mode 100644 index 0000000..b39ea83 --- /dev/null +++ b/Ferrite/Utils/FerriteKeychain.swift @@ -0,0 +1,13 @@ +// +// FerriteKeychain.swift +// Ferrite +// +// Created by Brian Dashore on 4/30/23. +// + +import Foundation +import KeychainSwift + +class FerriteKeychain { + static let shared = KeychainSwift() +} diff --git a/Ferrite/ViewModels/DebridManager.swift b/Ferrite/ViewModels/DebridManager.swift index 107fbc0..d5cae76 100644 --- a/Ferrite/ViewModels/DebridManager.swift +++ b/Ferrite/ViewModels/DebridManager.swift @@ -39,8 +39,24 @@ public class DebridManager: ObservableObject { var downloadUrl: String = "" var authUrl: URL? + // Is the current debrid type processing an auth request + func authProcessing(_ passedDebridType: DebridType?) -> Bool { + guard let debridType = passedDebridType ?? selectedDebridType else { + return false + } + + switch debridType { + case .realDebrid: + return realDebridAuthProcessing + case .allDebrid: + return allDebridAuthProcessing + case .premiumize: + return premiumizeAuthProcessing + } + } + // RealDebrid auth variables - @Published var realDebridAuthProcessing: Bool = false + var realDebridAuthProcessing: Bool = false // RealDebrid fetch variables @Published var realDebridIAValues: [RealDebrid.IA] = [] @@ -58,7 +74,7 @@ public class DebridManager: ObservableObject { var realDebridCloudTTL: Double = 0.0 // AllDebrid auth variables - @Published var allDebridAuthProcessing: Bool = false + var allDebridAuthProcessing: Bool = false // AllDebrid fetch variables @Published var allDebridIAValues: [AllDebrid.IA] = [] @@ -68,10 +84,11 @@ public class DebridManager: ObservableObject { // AllDebrid cloud variables @Published var allDebridCloudMagnets: [AllDebrid.MagnetStatusData] = [] + @Published var allDebridCloudLinks: [AllDebrid.SavedLink] = [] var allDebridCloudTTL: Double = 0.0 // Premiumize auth variables - @Published var premiumizeAuthProcessing: Bool = false + var premiumizeAuthProcessing: Bool = false // Premiumize fetch variables @Published var premiumizeIAValues: [Premiumize.IA] = [] @@ -325,34 +342,32 @@ public class DebridManager: ObservableObject { // MARK: - Authentication UI linked functions // Common function to delegate what debrid service to authenticate with - public func authenticateDebrid(debridType: DebridType) async { + public func authenticateDebrid(debridType: DebridType, apiKey: String?) async { switch debridType { case .realDebrid: - let success = await authenticateRd() + let success = apiKey == nil ? await authenticateRd() : realDebrid.setApiKey(apiKey!) completeDebridAuth(debridType, success: success) case .allDebrid: - let success = await authenticateAd() + // Async can't work with nil mapping method + let success = apiKey == nil ? await authenticateAd() : allDebrid.setApiKey(apiKey!) completeDebridAuth(debridType, success: success) case .premiumize: - await authenticatePm() - } - } - - public func getAuthProcessingBool(debridType: DebridType) -> Bool { - switch debridType { - case .realDebrid: - return realDebridAuthProcessing - case .allDebrid: - return allDebridAuthProcessing - case .premiumize: - return premiumizeAuthProcessing + if let apiKey { + let success = premiumize.setApiKey(apiKey) + completeDebridAuth(debridType, success: success) + } else { + await authenticatePm() + } } } // Callback to finish debrid auth since functions can be split - func completeDebridAuth(_ debridType: DebridType, success: Bool = true) { - if enabledDebrids.count == 1, success { - selectedDebridType = enabledDebrids.first + func completeDebridAuth(_ debridType: DebridType, success: Bool) { + if success { + enabledDebrids.insert(debridType) + if enabledDebrids.count == 1 { + selectedDebridType = enabledDebrids.first + } } switch debridType { @@ -365,6 +380,47 @@ public class DebridManager: ObservableObject { } } + // Get a truncated manual API key if it's being used + func getManualAuthKey(_ passedDebridType: DebridType?) async -> String? { + guard let debridType = passedDebridType ?? selectedDebridType else { + return nil + } + + let debridToken: String? + switch debridType { + case .realDebrid: + if UserDefaults.standard.bool(forKey: "RealDebrid.UseManualKey") { + debridToken = FerriteKeychain.shared.get("RealDebrid.AccessToken") + } else { + debridToken = nil + } + case .allDebrid: + if UserDefaults.standard.bool(forKey: "AllDebrid.UseManualKey") { + debridToken = FerriteKeychain.shared.get("AllDebrid.ApiKey") + } else { + debridToken = nil + } + case .premiumize: + if UserDefaults.standard.bool(forKey: "Premiumize.UseManualKey") { + debridToken = FerriteKeychain.shared.get("Premiumize.AccessToken") + } else { + debridToken = nil + } + } + + if let debridToken { + let splitString = debridToken.suffix(4) + + if debridToken.count > 4 { + return String(repeating: "*", count: debridToken.count - 4) + splitString + } else { + return String(splitString) + } + } else { + return nil + } + } + // Wrapper function to validate and present an auth URL to the user @discardableResult func validateAuthUrl(_ url: URL?, useAuthSession: Bool = false) -> Bool { guard let url else { @@ -389,12 +445,10 @@ public class DebridManager: ObservableObject { if validateAuthUrl(URL(string: verificationResponse.directVerificationURL)) { try await realDebrid.getDeviceCredentials(deviceCode: verificationResponse.deviceCode) - enabledDebrids.insert(.realDebrid) + return true } else { throw RealDebrid.RDError.AuthQuery(description: "The verification URL was invalid") } - - return true } catch { await sendDebridError(error, prefix: "RealDebrid authentication error") @@ -410,12 +464,10 @@ public class DebridManager: ObservableObject { if validateAuthUrl(URL(string: pinResponse.userURL)) { try await allDebrid.getApiKey(checkID: pinResponse.check, pin: pinResponse.pin) - enabledDebrids.insert(.allDebrid) + return true } else { throw AllDebrid.ADError.AuthQuery(description: "The PIN URL was invalid") } - - return true } catch { await sendDebridError(error, prefix: "AllDebrid authentication error") @@ -446,8 +498,7 @@ public class DebridManager: ObservableObject { if let callbackUrl = url { try premiumize.handleAuthCallback(url: callbackUrl) - enabledDebrids.insert(.premiumize) - completeDebridAuth(.premiumize) + completeDebridAuth(.premiumize, success: true) } else { throw Premiumize.PMError.AuthQuery(description: "The callback URL was invalid") } @@ -528,19 +579,6 @@ public class DebridManager: ObservableObject { } } - public func fetchDebridCloud() async { - switch selectedDebridType { - case .realDebrid: - await fetchRdCloud() - case .allDebrid: - await fetchAdCloud() - case .premiumize: - await fetchPmCloud() - case .none: - return - } - } - func fetchRdDownload(magnet: Magnet?, existingLink: String?) async { // If an existing link is passed in args, set it to that. Otherwise, find one from RD cloud. let torrentLink: String? @@ -609,6 +647,19 @@ public class DebridManager: ObservableObject { } } + public func fetchDebridCloud(bypassTTL: Bool = false) async { + switch selectedDebridType { + case .realDebrid: + await fetchRdCloud(bypassTTL: bypassTTL) + case .allDebrid: + await fetchAdCloud(bypassTTL: bypassTTL) + case .premiumize: + await fetchPmCloud(bypassTTL: bypassTTL) + case .none: + return + } + } + // Refreshes torrents and downloads from a RD user's account public func fetchRdCloud(bypassTTL: Bool = false) async { if bypassTTL || Date().timeIntervalSince1970 > realDebridCloudTTL { @@ -664,6 +715,7 @@ public class DebridManager: ObservableObject { } } + // TODO: Integrate with AD saved links func fetchAdDownload(magnet: Magnet?, existingLockedLink: String?) async { // If an existing link is passed in args, set it to that. Otherwise, find one from AD cloud. let lockedLink: String? @@ -678,8 +730,10 @@ public class DebridManager: ObservableObject { } do { - if let lockedLink { - downloadUrl = try await allDebrid.unlockLink(lockedLink: lockedLink) + if let lockedLink, + let unlockedLink = await checkAdUserLinks(lockedLink: lockedLink) + { + downloadUrl = unlockedLink } else if let magnet { let magnetID = try await allDebrid.addMagnet(magnet: magnet) let lockedLink = try await allDebrid.fetchMagnetStatus( @@ -687,6 +741,7 @@ public class DebridManager: ObservableObject { selectedIndex: selectedAllDebridFile?.id ?? 0 ) + try await allDebrid.saveLink(link: lockedLink) downloadUrl = try await allDebrid.unlockLink(lockedLink: lockedLink) } else { throw AllDebrid.ADError.FailedRequest(description: "Could not fetch your file from AllDebrid's cache or API") @@ -699,11 +754,28 @@ public class DebridManager: ObservableObject { } } + func checkAdUserLinks(lockedLink: String) async -> String? { + do { + let existingLinks = allDebridCloudLinks.first { $0.link == lockedLink } + if let existingLink = existingLinks?.link { + return existingLink + } else { + try await allDebrid.saveLink(link: lockedLink) + return try await allDebrid.unlockLink(lockedLink: lockedLink) + } + } catch { + await sendDebridError(error, prefix: "AllDebrid download check error") + + return nil + } + } + // Refreshes torrents and downloads from a RD user's account public func fetchAdCloud(bypassTTL: Bool = false) async { if bypassTTL || Date().timeIntervalSince1970 > allDebridCloudTTL { do { allDebridCloudMagnets = try await allDebrid.userMagnets() + allDebridCloudLinks = try await allDebrid.savedLinks() // 5 minutes allDebridCloudTTL = Date().timeIntervalSince1970 + 300 @@ -713,13 +785,23 @@ public class DebridManager: ObservableObject { } } + func deleteAdLink(link: String) async { + do { + try await allDebrid.deleteLink(link: link) + + await fetchAdCloud(bypassTTL: true) + } catch { + await sendDebridError(error, prefix: "AllDebrid link delete error") + } + } + func deleteAdMagnet(magnetId: Int) async { do { try await allDebrid.deleteMagnet(magnetId: magnetId) await fetchAdCloud(bypassTTL: true) } catch { - await sendDebridError(error, prefix: "AllDebrid delete error") + await sendDebridError(error, prefix: "AllDebrid magnet delete error") } } diff --git a/Ferrite/Views/CommonViews/HybridSecureField.swift b/Ferrite/Views/CommonViews/HybridSecureField.swift index df3fec0..d1ac923 100644 --- a/Ferrite/Views/CommonViews/HybridSecureField.swift +++ b/Ferrite/Views/CommonViews/HybridSecureField.swift @@ -14,22 +14,34 @@ struct HybridSecureField: View { } @Binding var text: String + var onCommit: () -> Void = {} + @State private var showPassword = false @FocusState private var focusedField: Field? + private var isFieldDisabled: Bool = false + + init(text: Binding, onCommit: (() -> Void)? = nil, showPassword: Bool = false) { + self._text = text + if let onCommit { + self.onCommit = onCommit + } + self.showPassword = showPassword + } var body: some View { HStack { Group { if showPassword { - TextField("Password", text: $text) + TextField("Password", text: $text, onCommit: onCommit) .focused($focusedField, equals: .plain) } else { - SecureField("Password", text: $text) + SecureField("Password", text: $text, onCommit: onCommit) .focused($focusedField, equals: .secure) } } .autocorrectionDisabled(true) .autocapitalization(.none) + .disabledAppearance(isFieldDisabled) Button { showPassword.toggle() @@ -42,3 +54,9 @@ struct HybridSecureField: View { } } } + +extension HybridSecureField { + public func fieldDisabled(_ isFieldDisabled: Bool) -> Self { + modifyViewProp({ $0.isFieldDisabled = isFieldDisabled }) + } +} diff --git a/Ferrite/Views/ComponentViews/Library/Cloud/AllDebridCloudView.swift b/Ferrite/Views/ComponentViews/Library/Cloud/AllDebridCloudView.swift index f871e34..f0cf0ce 100644 --- a/Ferrite/Views/ComponentViews/Library/Cloud/AllDebridCloudView.swift +++ b/Ferrite/Views/ComponentViews/Library/Cloud/AllDebridCloudView.swift @@ -15,6 +15,43 @@ struct AllDebridCloudView: View { @Binding var searchText: String var body: some View { + DisclosureGroup("Links") { + ForEach(debridManager.allDebridCloudLinks.filter { + searchText.isEmpty ? true : $0.filename.lowercased().contains(searchText.lowercased()) + }, id: \.self) { downloadResponse in + Button(downloadResponse.filename) { + navModel.resultFromCloud = true + navModel.selectedTitle = downloadResponse.filename + debridManager.downloadUrl = downloadResponse.link + + PersistenceController.shared.createHistory( + HistoryEntryJson( + name: downloadResponse.filename, + url: downloadResponse.link, + source: DebridType.allDebrid.toString() + ), + performSave: true + ) + + pluginManager.runDefaultAction( + urlString: debridManager.downloadUrl, + navModel: navModel + ) + } + .disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2)) + .tint(.primary) + } + .onDelete { offsets in + for index in offsets { + if let savedLink = debridManager.allDebridCloudLinks[safe: index] { + Task { + await debridManager.deleteAdLink(link: savedLink.link) + } + } + } + } + } + DisclosureGroup("Magnets") { ForEach(debridManager.allDebridCloudMagnets.filter { searchText.isEmpty ? true : $0.filename.lowercased().contains(searchText.lowercased()) @@ -69,8 +106,8 @@ struct AllDebridCloudView: View { .font(.caption) } } - .disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2)) - .tint(.black) + .disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.9, animation: .easeOut(duration: 0.2)) + .tint(.primary) } .onDelete { offsets in for index in offsets { diff --git a/Ferrite/Views/ComponentViews/Library/Cloud/PremiumizeCloudView.swift b/Ferrite/Views/ComponentViews/Library/Cloud/PremiumizeCloudView.swift index 380d959..d309684 100644 --- a/Ferrite/Views/ComponentViews/Library/Cloud/PremiumizeCloudView.swift +++ b/Ferrite/Views/ComponentViews/Library/Cloud/PremiumizeCloudView.swift @@ -44,7 +44,7 @@ struct PremiumizeCloudView: View { } } .disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2)) - .tint(.black) + .tint(.primary) } .onDelete { offsets in for index in offsets { diff --git a/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift b/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift index 11059e9..b133ef8 100644 --- a/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift +++ b/Ferrite/Views/ComponentViews/Library/Cloud/RealDebridCloudView.swift @@ -39,6 +39,7 @@ struct RealDebridCloudView: View { navModel: navModel ) } + .disabledAppearance(navModel.currentChoiceSheet != nil, dimmedOpacity: 0.7, animation: .easeOut(duration: 0.2)) .tint(.primary) } .onDelete { offsets in diff --git a/Ferrite/Views/ComponentViews/Library/DebridCloudView.swift b/Ferrite/Views/ComponentViews/Library/DebridCloudView.swift index b0343ea..b9e271f 100644 --- a/Ferrite/Views/ComponentViews/Library/DebridCloudView.swift +++ b/Ferrite/Views/ComponentViews/Library/DebridCloudView.swift @@ -30,7 +30,7 @@ struct DebridCloudView: View { await debridManager.fetchDebridCloud() } .refreshable { - await debridManager.fetchDebridCloud() + await debridManager.fetchDebridCloud(bypassTTL: true) } .onChange(of: debridManager.selectedDebridType) { newType in if newType != nil { diff --git a/Ferrite/Views/ComponentViews/Settings/SettingsDebridInfoView.swift b/Ferrite/Views/ComponentViews/Settings/SettingsDebridInfoView.swift index 0872a2a..cebd792 100644 --- a/Ferrite/Views/ComponentViews/Settings/SettingsDebridInfoView.swift +++ b/Ferrite/Views/ComponentViews/Settings/SettingsDebridInfoView.swift @@ -12,6 +12,8 @@ struct SettingsDebridInfoView: View { let debridType: DebridType + @State private var apiKeyTempText: String = "" + var body: some View { List { Section(header: InlineHeader("Description")) { @@ -30,19 +32,44 @@ struct SettingsDebridInfoView: View { Task { if debridManager.enabledDebrids.contains(debridType) { await debridManager.logoutDebrid(debridType: debridType) - } else if !debridManager.getAuthProcessingBool(debridType: debridType) { - await debridManager.authenticateDebrid(debridType: debridType) + } else if !debridManager.authProcessing(debridType) { + await debridManager.authenticateDebrid(debridType: debridType, apiKey: nil) } + + apiKeyTempText = await debridManager.getManualAuthKey(debridType) ?? "" } } label: { Text( debridManager.enabledDebrids.contains(debridType) ? "Logout" - : (debridManager.getAuthProcessingBool(debridType: debridType) ? "Processing" : "Login") + : (debridManager.authProcessing(debridType) ? "Processing" : "Login") ) .foregroundColor(debridManager.enabledDebrids.contains(debridType) ? .red : .blue) } } + + Section( + header: InlineHeader("API key"), + footer: Text("Add a permanent API key here. Only use this if web authentication does not work!") + ) { + HybridSecureField( + text: $apiKeyTempText, + onCommit: { + Task { + if !apiKeyTempText.isEmpty { + await debridManager.authenticateDebrid(debridType: debridType, apiKey: apiKeyTempText) + apiKeyTempText = await debridManager.getManualAuthKey(debridType) ?? "" + } + } + } + ) + .fieldDisabled(debridManager.enabledDebrids.contains(debridType)) + } + .onAppear { + Task { + apiKeyTempText = await debridManager.getManualAuthKey(debridType) ?? "" + } + } } .listStyle(.insetGrouped) .navigationTitle(debridType.toString()) diff --git a/Ferrite/Views/RepresentableViews/WebView.swift b/Ferrite/Views/RepresentableViews/WebView.swift index e903622..e9b87a8 100644 --- a/Ferrite/Views/RepresentableViews/WebView.swift +++ b/Ferrite/Views/RepresentableViews/WebView.swift @@ -9,19 +9,23 @@ import SwiftUI import WebKit struct WebView: UIViewRepresentable { + @AppStorage("Behavior.UseEphemeralAuth") var useEphemeralAuth: Bool = true var url: URL func makeUIView(context: Context) -> WKWebView { - // Make the WebView ephemeral + // Make the WebView ephemeral depending on the ephemeral auth setting let config = WKWebViewConfiguration() - config.websiteDataStore = WKWebsiteDataStore.nonPersistent() + + config.websiteDataStore = useEphemeralAuth ? .nonPersistent() : .default() let webView = WKWebView(frame: .zero, configuration: config) let _ = webView.load(URLRequest(url: url)) return webView } - func updateUIView(_ uiView: WKWebView, context: Context) {} + func updateUIView(_ webView: WKWebView, context: Context) { + webView.configuration.websiteDataStore = useEphemeralAuth ? .nonPersistent() : .default() + } } struct WebView_Previews: PreviewProvider { diff --git a/Ferrite/Views/SettingsView.swift b/Ferrite/Views/SettingsView.swift index 8e08cfd..28f18fc 100644 --- a/Ferrite/Views/SettingsView.swift +++ b/Ferrite/Views/SettingsView.swift @@ -8,6 +8,7 @@ import BetterSafariView import Introspect import SwiftUI +import WebKit struct SettingsView: View { @EnvironmentObject var debridManager: DebridManager @@ -24,6 +25,7 @@ struct SettingsView: View { @AppStorage("Behavior.AutocorrectSearch") var autocorrectSearch = true @AppStorage("Behavior.UsesRandomSearchText") var usesRandomSearchText = false + @AppStorage("Behavior.UseEphemeralAuth") var useEphemeralAuth = true @AppStorage("Behavior.DisableRequestTimeout") var disableRequestTimeout = false @AppStorage("Behavior.RequestTimeoutSecs") var requestTimeoutSecs: Double = 15 @@ -73,7 +75,10 @@ struct SettingsView: View { Section( header: InlineHeader("Behavior"), - footer: Text("Only disable search timeout if results are slow to fetch") + footer: VStack(alignment: .leading, spacing: 8) { + Text("Temporarily disable ephemeral auth if you cannot log into a service") + Text("Only disable search timeout if results are slow to fetch") + } ) { Toggle(isOn: $autocorrectSearch) { Text("Autocorrect search") @@ -83,6 +88,21 @@ struct SettingsView: View { Text("Random searchbar text") } + Toggle(isOn: $useEphemeralAuth) { + Text("Ephemeral authentication") + } + .onChange(of: useEphemeralAuth) { changed in + // Does not work with ASWebAuthenticationSession + if changed { + Task { + let dataRecords = await WKWebsiteDataStore.default().dataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) + + await WKWebsiteDataStore.default().removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), for: dataRecords) + } + } + } + + // TODO: Change this to enable search timeout instead Toggle(isOn: $disableRequestTimeout) { Text("Disable search timeout") } @@ -210,7 +230,7 @@ struct SettingsView: View { await debridManager.handleCallback(url: callbackURL, error: error) } } - .prefersEphemeralWebBrowserSession(true) + .prefersEphemeralWebBrowserSession(useEphemeralAuth) } .navigationTitle("Settings") .toolbar {