diff --git a/Flights/Services/RouteExplorerClient.swift b/Flights/Services/RouteExplorerClient.swift index b0aa889..d2048dc 100644 --- a/Flights/Services/RouteExplorerClient.swift +++ b/Flights/Services/RouteExplorerClient.swift @@ -112,23 +112,17 @@ actor RouteExplorerClient { ] do { let token = try await currentToken() - let url = baseURL.appendingPathComponent("api/flight-search") let body = try JSONSerialization.data(withJSONObject: [ "endpoint": "/schedule", "body": ["json": payload] ]) - var req = URLRequest(url: url) - req.httpMethod = "POST" - Self.applyBrowserHeaders(to: &req) - req.setValue("application/json", forHTTPHeaderField: "Content-Type") - req.setValue("application/json", forHTTPHeaderField: "Accept") - req.setValue(token, forHTTPHeaderField: "X-API-Token") - req.httpBody = body - - let (data, response) = try await session.data(for: req) - let status = (response as? HTTPURLResponse)?.statusCode ?? -1 - guard (200...299).contains(status) else { return [] } - + let respStr = try await fetchViaWebView( + method: "POST", + apiPath: "/api/flight-search", + extraHeaders: ["X-API-Token": token], + requestBody: body + ) + guard let data = respStr.data(using: .utf8) else { return [] } let decoded = try JSONDecoder.routeExplorer().decode( RouteExplorerScheduleResponse.self, from: data ) @@ -163,21 +157,22 @@ actor RouteExplorerClient { if let cached = cachedToken, cached.expiresAt > Date() { return cached.value } - let url = baseURL.appendingPathComponent("api/token") - var req = URLRequest(url: url) - req.httpMethod = "GET" - Self.applyBrowserHeaders(to: &req) - req.setValue("application/json", forHTTPHeaderField: "Accept") - - let (data, response) = try await session.data(for: req) - let status = (response as? HTTPURLResponse)?.statusCode ?? -1 - if status != 200 { - let bodyStr = String(data: data, encoding: .utf8) ?? "" - print("[RouteExplorer] /api/token failed status=\(status) body=\(bodyStr.prefix(300))") - throw ClientError.tokenFetchFailed(status: status) - } - + // route-explorer's edge now rejects URLSession-shaped requests + // (returns 403 "clearance"). A WKWebView running inside the + // route-explorer.com origin passes the gate, presumably because + // the TLS fingerprint + same-origin cookies match what their + // bot rules expect. We route both /api/token and + // /api/flight-search through that path. + let bodyStr = try await fetchViaWebView( + method: "GET", + apiPath: "/api/token", + extraHeaders: [:], + requestBody: nil + ) struct TokenResponse: Decodable { let token: String } + guard let data = bodyStr.data(using: .utf8) else { + throw ClientError.tokenFetchFailed(status: -1) + } do { let decoded = try JSONDecoder().decode(TokenResponse.self, from: data) cachedToken = (decoded.token, Date().addingTimeInterval(30 * 60)) @@ -187,6 +182,53 @@ actor RouteExplorerClient { } } + /// Runs an XHR from inside a WKWebView that's been navigated to + /// `https://route-explorer.com/`. The page context provides the + /// real Safari TLS fingerprint and any first-party cookies the + /// edge expects. Returns the response body as a string, or + /// throws on non-200. + private func fetchViaWebView( + method: String, + apiPath: String, + extraHeaders: [String: String], + requestBody: Data? + ) async throws -> String { + let fetcher = await WebViewFetcher() + var headers: [String: String] = [ + "Accept": "application/json", + "Content-Type": "application/json" + ] + for (k, v) in extraHeaders { headers[k] = v } + + // For POST, body must be a JS literal — re-emit the JSON as + // a JSON.stringify(...) so it survives template substitution. + var bodyJS: String? + if let requestBody { + // Inline the JSON body verbatim — XMLHttpRequest.send() + // takes a string and we're already JSON-encoded. + let raw = String(data: requestBody, encoding: .utf8) ?? "null" + bodyJS = "JSON.stringify(\(raw))" + } + + let result = await fetcher.fetch( + navigateTo: "https://route-explorer.com/", + fetchURL: "https://route-explorer.com\(apiPath)", + method: method, + headers: headers, + body: bodyJS, + userAgent: nil, + includeCredentials: true + ) + if let err = result.error { + print("[RouteExplorer] WebView \(method) \(apiPath) failed: \(err)") + throw ClientError.tokenFetchFailed(status: -1) + } + guard let data = result.data else { + throw ClientError.tokenFetchFailed(status: -1) + } + return data + } + /// Browser-shaped headers — `/api/token` and `/api/flight-search` are /// gated by Origin/Referer in production. Without these the server /// returns 403 even though the endpoints are otherwise anonymous. @@ -209,65 +251,41 @@ actor RouteExplorerClient { json: [String: Any] ) async throws -> RouteSearchResult { let token = try await currentToken() - let url = baseURL.appendingPathComponent("api/flight-search") - let outerBody: [String: Any] = [ "endpoint": endpoint, "body": ["json": json] ] let bodyData = try JSONSerialization.data(withJSONObject: outerBody) - var req = URLRequest(url: url) - req.httpMethod = "POST" - Self.applyBrowserHeaders(to: &req) - req.setValue("application/json", forHTTPHeaderField: "Content-Type") - req.setValue("application/json", forHTTPHeaderField: "Accept") - req.setValue(token, forHTTPHeaderField: "X-API-Token") - req.httpBody = bodyData - - let (data, response) = try await session.data(for: req) - let status = (response as? HTTPURLResponse)?.statusCode ?? -1 - - // 401 / 403 likely means the token rotated. Drop cache and retry once. - if status == 401 || status == 403 { - cachedToken = nil - return try await retryAfterTokenRotation(endpoint: endpoint, json: json) + do { + let respStr = try await fetchViaWebView( + method: "POST", + apiPath: "/api/flight-search", + extraHeaders: ["X-API-Token": token], + requestBody: bodyData + ) + guard let data = respStr.data(using: .utf8) else { + throw ClientError.requestFailed(status: -1, body: nil) + } + return try decode(data: data) + } catch let err as ClientError { + // Token may have rotated server-side. Drop cache and retry once. + if case .tokenFetchFailed = err { + cachedToken = nil + let token2 = try await currentToken() + let respStr = try await fetchViaWebView( + method: "POST", + apiPath: "/api/flight-search", + extraHeaders: ["X-API-Token": token2], + requestBody: bodyData + ) + guard let data = respStr.data(using: .utf8) else { + throw ClientError.requestFailed(status: -1, body: nil) + } + return try decode(data: data) + } + throw err } - - guard (200...299).contains(status) else { - let bodyStr = String(data: data, encoding: .utf8) - throw ClientError.requestFailed(status: status, body: bodyStr) - } - - return try decode(data: data) - } - - private func retryAfterTokenRotation( - endpoint: String, - json: [String: Any] - ) async throws -> RouteSearchResult { - let token = try await currentToken() - let url = baseURL.appendingPathComponent("api/flight-search") - - let outerBody: [String: Any] = [ - "endpoint": endpoint, - "body": ["json": json] - ] - let bodyData = try JSONSerialization.data(withJSONObject: outerBody) - - var req = URLRequest(url: url) - req.httpMethod = "POST" - Self.applyBrowserHeaders(to: &req) - req.setValue("application/json", forHTTPHeaderField: "Content-Type") - req.setValue(token, forHTTPHeaderField: "X-API-Token") - req.httpBody = bodyData - - let (data, response) = try await session.data(for: req) - let status = (response as? HTTPURLResponse)?.statusCode ?? -1 - guard (200...299).contains(status) else { - throw ClientError.requestFailed(status: status, body: String(data: data, encoding: .utf8)) - } - return try decode(data: data) } private func decode(data: Data) throws -> RouteSearchResult {