-
-
Notifications
You must be signed in to change notification settings - Fork 707
[Agent] feat(cheats): Cloudflare Worker middleware proxy for GameHacking.org cheat lookup #3518
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
4684955
10db63a
e4dfcca
ee5c6dc
78053a6
5096bc0
a858ea2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| ### Added | ||
| - **GameHacking.org Cheat Proxy** — Cloudflare Workers middleware (`Scripts/cheat-proxy/`) that scrapes GameHacking.org and returns normalised JSON cheat entries with 24h server-side KV caching; reduces scraper fragility and load on the source site | ||
| - **Cheat proxy settings** — `useCheatProxy` (default `true`) and `cheatProxyURL` settings allow the proxy endpoint to be enabled/disabled and configured without an app update | ||
| - **Automatic proxy fallback** — When the proxy is unreachable or returns no results, `GameHackingOrgLookup` transparently falls back to the existing direct HTML scraper |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,6 +21,7 @@ | |
|
|
||
| import Foundation | ||
| import PVLogging | ||
| import PVSettings | ||
|
|
||
| // MARK: - GameHackingOrgLookup | ||
|
|
||
|
|
@@ -49,6 +50,10 @@ | |
| private static let maxMemoryCacheEntries = 50 | ||
| /// Minimum seconds between requests to be polite to the server. | ||
| private static let minRequestInterval: TimeInterval = 1.0 | ||
| /// Compile-time default proxy URL. Can be overridden via PVSettings `cheatProxyURL`. | ||
| /// Set to a non-empty string after deploying the Cloudflare Worker | ||
| /// (see `Scripts/cheat-proxy/README.md`). | ||
| static let defaultProxyURL: String = "" | ||
|
|
||
| // MARK: - State | ||
|
|
||
|
|
@@ -83,9 +88,26 @@ | |
| return diskHit.entries | ||
| } | ||
|
|
||
| // 3. Network fetch — never throws outward; always returns empty on failure. | ||
| // 3. Network fetch — try proxy first (if enabled), then fall back to direct scraping. | ||
| DLOG("GameHackingOrgLookup: fetching online for title='\(title)' slug=\(systemSlug ?? "nil")") | ||
| let results = await fetchWithFallback(title: title, systemSlug: systemSlug) | ||
| let results: [CheatDatabaseEntry] | ||
|
|
||
| let proxyURL = resolvedProxyURL() | ||
| if !proxyURL.isEmpty, Defaults[.useCheatProxy] { | ||
| // fetchFromProxy returns nil when the proxy is unreachable or fails (caller should | ||
| // fallback to direct scraping), or a non-nil array when the proxy successfully | ||
| // responded — including an empty array meaning "no cheats found" (no fallback needed). | ||
| let proxyResults = await fetchFromProxy(title: title, systemSlug: systemSlug, proxyBaseURL: proxyURL) | ||
| if let proxyResults { | ||
| DLOG("GameHackingOrgLookup: proxy returned \(proxyResults.count) codes for '\(title)'") | ||
| results = proxyResults | ||
| } else { | ||
| DLOG("GameHackingOrgLookup: proxy failed/unreachable, falling back to direct scrape for '\(title)'") | ||
| results = await fetchWithFallback(title: title, systemSlug: systemSlug) | ||
| } | ||
| } else { | ||
| results = await fetchWithFallback(title: title, systemSlug: systemSlug) | ||
| } | ||
|
|
||
| evictMemoryCacheIfNeeded() | ||
| memoryCache[key] = (Date(), results) | ||
|
|
@@ -95,8 +117,93 @@ | |
| return results | ||
| } | ||
|
|
||
| // MARK: - Proxy URL Resolution | ||
|
|
||
| /// Returns the effective proxy base URL, preferring the user-configured value over the compile-time default. | ||
| private func resolvedProxyURL() -> String { | ||
| let stored = Defaults[.cheatProxyURL].trimmingCharacters(in: .whitespacesAndNewlines) | ||
| if !stored.isEmpty { return stored } | ||
| return Self.defaultProxyURL | ||
| } | ||
|
|
||
| // MARK: - Fetch Logic | ||
|
|
||
| /// Fetch cheat entries from the Provenance cheat proxy worker. | ||
| /// | ||
| /// The proxy endpoint is `GET <proxyBaseURL>/cheats?title=<title>&system=<slug>`. | ||
| /// | ||
| /// Returns: | ||
| /// - `nil` when the proxy is unreachable, returns a non-2xx status, or a network/decode | ||
| /// error occurs — the caller should fall back to direct scraping. | ||
| /// - `[]` when the proxy successfully contacted upstream but found no cheats | ||
| /// (signalled by `X-Proxy-Status: ok` in the response) — no fallback needed. | ||
| /// - `[entries]` when the proxy found results. | ||
| private func fetchFromProxy(title: String, systemSlug: String?, proxyBaseURL: String) async -> [CheatDatabaseEntry]? { | ||
| var components = URLComponents(string: proxyBaseURL.hasSuffix("/") | ||
| ? proxyBaseURL + "cheats" | ||
| : proxyBaseURL + "/cheats") | ||
| var queryItems = [URLQueryItem(name: "title", value: title)] | ||
| if let slug = systemSlug { | ||
| queryItems.append(URLQueryItem(name: "system", value: slug)) | ||
| } | ||
| components?.queryItems = queryItems | ||
|
|
||
| guard let url = components?.url else { | ||
| WLOG("GameHackingOrgLookup: invalid proxy URL '\(proxyBaseURL)'") | ||
| return nil | ||
| } | ||
|
|
||
| do { | ||
| var request = URLRequest(url: url) | ||
| request.setValue("Provenance-Emu/1.0", forHTTPHeaderField: "User-Agent") | ||
| request.timeoutInterval = 10 | ||
| let (data, response) = try await URLSession.shared.data(for: request) | ||
|
Comment on lines
+157
to
+160
|
||
| guard let http = response as? HTTPURLResponse, | ||
| (200..<300).contains(http.statusCode) else { | ||
| WLOG("GameHackingOrgLookup: proxy non-200 for '\(title)'") | ||
| return nil | ||
| } | ||
|
|
||
| let raw = try JSONDecoder().decode([ProxyCheatEntry].self, from: data) | ||
| if raw.isEmpty { | ||
| // Only trust an empty result as "no cheats found" when the proxy confirms it | ||
| // successfully contacted upstream via X-Proxy-Status: ok. Without this header | ||
| // the empty array may be a transient error — fall back to direct scraping. | ||
| let proxyStatus = http.value(forHTTPHeaderField: "X-Proxy-Status") | ||
| guard proxyStatus == "ok" else { | ||
| DLOG("GameHackingOrgLookup: proxy returned empty without ok status for '\(title)' — falling back") | ||
| return nil | ||
| } | ||
| return [] | ||
| } | ||
|
|
||
| return raw.enumerated().map { index, entry in | ||
| CheatDatabaseEntry( | ||
| id: Self.idOffset + index, | ||
| cheatName: entry.name, | ||
| cheatCode: entry.code, | ||
| cheatDescription: nil, | ||
| deviceName: "GameHacking.org", | ||
| deviceFormat: nil, | ||
| category: entry.category ?? "General", | ||
| romTitle: title, | ||
| systemName: systemSlug, | ||
| isOnlineResult: true | ||
| ) | ||
| } | ||
| } catch { | ||
| WLOG("GameHackingOrgLookup: proxy fetch error for '\(title)': \(error)") | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| /// JSON model returned by the cheat proxy worker. | ||
| private struct ProxyCheatEntry: Decodable { | ||
| let name: String | ||
| let code: String | ||
| let category: String? | ||
| } | ||
|
|
||
| /// Try fetching with system filter first; fall back to no-system search on failure. | ||
| private func fetchWithFallback(title: String, systemSlug: String?) async -> [CheatDatabaseEntry] { | ||
| // Strategy 1: search with system filter (if we have a slug) | ||
|
|
@@ -582,7 +689,7 @@ | |
| /// Decode common HTML entities. | ||
| var htmlDecoded: String { | ||
| self | ||
| .replacingOccurrences(of: "&", with: "&") | ||
| .replacingOccurrences(of: "<", with: "<") | ||
| .replacingOccurrences(of: ">", with: ">") | ||
| .replacingOccurrences(of: """, with: "\"") | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,12 @@ | ||
| // GameHackingOrgLookupTests.swift | ||
| // PVLibraryTests | ||
| // | ||
| // Unit tests for GameHackingOrgLookup's HTML parsing strategies. | ||
| // Unit tests for GameHackingOrgLookup's HTML parsing strategies and proxy path. | ||
| // Tests run against static HTML fixtures — no network required. | ||
|
|
||
| @testable import PVLibrary | ||
| import Defaults | ||
| import PVSettings | ||
| import XCTest | ||
|
|
||
| final class GameHackingOrgLookupTests: XCTestCase { | ||
|
|
@@ -114,4 +116,170 @@ final class GameHackingOrgLookupTests: XCTestCase { | |
| let result = await lookup.looksLikeCode("Infinite Lives") | ||
| XCTAssertFalse(result) | ||
| } | ||
|
|
||
| // MARK: - Proxy Path (URLProtocol stubs) | ||
|
|
||
| func testSearchCheats_proxyReturnsResults() async { | ||
| XCTAssertTrue(URLProtocol.registerClass(ProxyCannedProtocol.self), "URLProtocol registration failed — test may hit real network") | ||
| defer { URLProtocol.unregisterClass(ProxyCannedProtocol.self) } | ||
|
|
||
| let json = #"[{"name":"Infinite Lives","code":"DEADBEEF00000001","category":"General"}]"# | ||
| ProxyCannedProtocol.cannedJSON = Data(json.utf8) | ||
| ProxyCannedProtocol.statusCode = 200 | ||
| ProxyCannedProtocol.cannedHeaders = ["X-Proxy-Status": "ok"] | ||
| ProxyCannedProtocol.lastRequest = nil | ||
| defer { ProxyCannedProtocol.cannedHeaders = [:] } | ||
|
|
||
| Defaults[.useCheatProxy] = true | ||
| Defaults[.cheatProxyURL] = "https://test.proxy.pvemu.invalid" | ||
| defer { | ||
| Defaults.reset(.useCheatProxy) | ||
| Defaults.reset(.cheatProxyURL) | ||
| } | ||
|
|
||
| let title = "ProxyHappyPath_\(UUID().uuidString)" | ||
| let entries = await GameHackingOrgLookup.shared.searchCheats(title: title, systemSlug: "n64") | ||
|
|
||
| // The proxy URL should have been contacted with the correct path/query | ||
| let intercepted = ProxyCannedProtocol.lastRequest?.url?.absoluteString ?? "" | ||
| XCTAssertTrue(intercepted.contains("/cheats"), "Expected /cheats in proxy request URL, got: \(intercepted)") | ||
| XCTAssertTrue(intercepted.contains("title="), "Expected title= query param in proxy request URL") | ||
|
|
||
| // Results should be decoded from the proxy JSON | ||
| XCTAssertEqual(entries.count, 1) | ||
| XCTAssertEqual(entries.first?.cheatName, "Infinite Lives") | ||
| XCTAssertEqual(entries.first?.cheatCode, "DEADBEEF00000001") | ||
| XCTAssertEqual(entries.first?.deviceName, "GameHacking.org") | ||
| XCTAssertTrue(entries.first?.isOnlineResult ?? false) | ||
| } | ||
|
|
||
| func testSearchCheats_proxyReturnsEmpty_noFallback() async { | ||
| // Proxy returns [] with X-Proxy-Status: ok — meaning "upstream confirmed no cheats". | ||
| // searchCheats should trust this and NOT fall back to direct scraping. | ||
| // DirectScrapeBlockerProtocol is registered to ensure no gamehacking.org request is made. | ||
| XCTAssertTrue(URLProtocol.registerClass(ProxyCannedProtocol.self), "URLProtocol registration failed — test may hit real network") | ||
| XCTAssertTrue(URLProtocol.registerClass(DirectScrapeBlockerProtocol.self), "URLProtocol registration failed — test may hit real network") | ||
| defer { | ||
| URLProtocol.unregisterClass(ProxyCannedProtocol.self) | ||
| URLProtocol.unregisterClass(DirectScrapeBlockerProtocol.self) | ||
| } | ||
|
Comment on lines
+122
to
+165
|
||
|
|
||
| ProxyCannedProtocol.cannedJSON = Data("[]".utf8) | ||
| ProxyCannedProtocol.statusCode = 200 | ||
| // X-Proxy-Status: ok tells the client the proxy successfully ran and found nothing | ||
| ProxyCannedProtocol.cannedHeaders = ["X-Proxy-Status": "ok"] | ||
| ProxyCannedProtocol.lastRequest = nil | ||
| DirectScrapeBlockerProtocol.requestCount = 0 | ||
| defer { | ||
| ProxyCannedProtocol.cannedHeaders = [:] | ||
| DirectScrapeBlockerProtocol.requestCount = 0 | ||
| } | ||
|
|
||
| Defaults[.useCheatProxy] = true | ||
| Defaults[.cheatProxyURL] = "https://test.proxy.pvemu.invalid" | ||
| defer { | ||
| Defaults.reset(.useCheatProxy) | ||
| Defaults.reset(.cheatProxyURL) | ||
| } | ||
|
|
||
| let title = "ProxyEmptyNoFallback_\(UUID().uuidString)" | ||
| let entries = await GameHackingOrgLookup.shared.searchCheats(title: title, systemSlug: nil) | ||
|
|
||
| // Proxy was contacted and returned empty with ok status — no direct scrape should happen | ||
| XCTAssertTrue(entries.isEmpty) | ||
| let intercepted = ProxyCannedProtocol.lastRequest?.url?.absoluteString ?? "" | ||
| XCTAssertTrue(intercepted.contains("/cheats"), "Proxy should have been contacted") | ||
| XCTAssertEqual(DirectScrapeBlockerProtocol.requestCount, 0, "Direct scrape should NOT occur when proxy confirms no cheats") | ||
| } | ||
|
|
||
| func testSearchCheats_proxyDisabled_doesNotContactProxy() async { | ||
| // Proxy is disabled — only the direct scrape path runs. | ||
| // DirectScrapeBlockerProtocol intercepts gamehacking.org requests so no real | ||
| // network call is made; it returns empty HTML so the scrape yields no results. | ||
| XCTAssertTrue(URLProtocol.registerClass(ProxyCannedProtocol.self), "URLProtocol registration failed — test may hit real network") | ||
| XCTAssertTrue(URLProtocol.registerClass(DirectScrapeBlockerProtocol.self), "URLProtocol registration failed — test may hit real network") | ||
| defer { | ||
| URLProtocol.unregisterClass(ProxyCannedProtocol.self) | ||
| URLProtocol.unregisterClass(DirectScrapeBlockerProtocol.self) | ||
| } | ||
|
|
||
| ProxyCannedProtocol.cannedJSON = Data() | ||
| ProxyCannedProtocol.lastRequest = nil | ||
| DirectScrapeBlockerProtocol.requestCount = 0 | ||
| defer { DirectScrapeBlockerProtocol.requestCount = 0 } | ||
|
|
||
| Defaults[.useCheatProxy] = false | ||
| Defaults[.cheatProxyURL] = "https://test.proxy.pvemu.invalid" | ||
| defer { | ||
| Defaults.reset(.useCheatProxy) | ||
| Defaults.reset(.cheatProxyURL) | ||
| } | ||
|
|
||
| let title = "ProxyDisabled_\(UUID().uuidString)" | ||
| _ = await GameHackingOrgLookup.shared.searchCheats(title: title, systemSlug: nil) | ||
| // The proxy should not have been contacted when useCheatProxy is false | ||
| XCTAssertNil(ProxyCannedProtocol.lastRequest, "Proxy should not be contacted when useCheatProxy is false") | ||
| } | ||
|
Comment on lines
+195
to
+222
|
||
| } | ||
|
|
||
| // MARK: - URLProtocol stub for proxy tests | ||
|
|
||
| /// Intercepts requests to the test proxy host and returns canned JSON. | ||
| private final class ProxyCannedProtocol: URLProtocol { | ||
| static var cannedJSON: Data = Data() | ||
| static var statusCode: Int = 200 | ||
| static var cannedHeaders: [String: String] = [:] | ||
| static var lastRequest: URLRequest? | ||
|
|
||
| override class func canInit(with request: URLRequest) -> Bool { | ||
| request.url?.host?.contains("test.proxy.pvemu.invalid") ?? false | ||
| } | ||
|
|
||
| override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } | ||
|
|
||
| override func startLoading() { | ||
| ProxyCannedProtocol.lastRequest = request | ||
| var headers = ["Content-Type": "application/json"] | ||
| for (key, value) in ProxyCannedProtocol.cannedHeaders { | ||
| headers[key] = value | ||
| } | ||
| let response = HTTPURLResponse( | ||
| url: request.url!, | ||
| statusCode: ProxyCannedProtocol.statusCode, | ||
| httpVersion: "HTTP/1.1", | ||
| headerFields: headers | ||
| )! | ||
| client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) | ||
| client?.urlProtocol(self, didLoad: ProxyCannedProtocol.cannedJSON) | ||
| client?.urlProtocolDidFinishLoading(self) | ||
| } | ||
|
|
||
| override func stopLoading() {} | ||
| } | ||
|
|
||
| /// Intercepts requests to gamehacking.org and returns empty HTML, preventing real network calls | ||
| /// during tests that exercise the direct-scrape fallback path. | ||
| private final class DirectScrapeBlockerProtocol: URLProtocol { | ||
| static var requestCount: Int = 0 | ||
|
|
||
| override class func canInit(with request: URLRequest) -> Bool { | ||
| request.url?.host?.contains("gamehacking.org") ?? false | ||
| } | ||
|
|
||
| override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } | ||
|
|
||
| override func startLoading() { | ||
| DirectScrapeBlockerProtocol.requestCount += 1 | ||
| let response = HTTPURLResponse( | ||
| url: request.url!, | ||
| statusCode: 200, | ||
| httpVersion: "HTTP/1.1", | ||
| headerFields: ["Content-Type": "text/html"] | ||
| )! | ||
| client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) | ||
| client?.urlProtocol(self, didLoad: Data("<html><body></body></html>".utf8)) | ||
| client?.urlProtocolDidFinishLoading(self) | ||
| } | ||
|
|
||
| override func stopLoading() {} | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
New proxy-first behavior (URL construction + JSON decoding + fallback when proxy is unreachable/empty) isn’t covered by unit tests. Since PVLibrary already has
GameHackingOrgLookupTests, consider adding tests that stubURLSession(viaURLProtocol) to validate these proxy scenarios without network access.