diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt index d015ec94b..4c0623ded 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt @@ -24,13 +24,28 @@ interface EditorHTTPClientProtocol { suspend fun perform(method: EditorHttpMethod, url: String): EditorHTTPClientResponse } +/** + * The response data from an HTTP request, either in-memory bytes or a downloaded file. + */ +sealed class EditorResponseData { + data class Bytes(val data: ByteArray) : EditorResponseData() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Bytes) return false + return data.contentEquals(other.data) + } + override fun hashCode(): Int = data.contentHashCode() + } + data class File(val file: java.io.File) : EditorResponseData() +} + /** * A delegate for observing HTTP requests made by the editor. * * Implement this interface to inspect or log all network requests. */ interface EditorHTTPClientDelegate { - fun didPerformRequest(url: String, method: EditorHttpMethod, response: Response, data: ByteArray) + fun didPerformRequest(url: String, method: EditorHttpMethod, response: Response, data: EditorResponseData) } /** @@ -141,18 +156,32 @@ class EditorHTTPClient( override suspend fun download(url: String, destination: File): EditorHTTPClientDownloadResponse = withContext(Dispatchers.IO) { + Log.d(TAG, "DOWNLOAD $url") + Log.d(TAG, " Destination: ${destination.absolutePath}") + val request = Request.Builder() .url(url) .addHeader("Authorization", authHeader) .get() .build() - val response = client.newCall(request).execute() + Log.d(TAG, " Request headers: ${redactHeaders(request.headers)}") + + val response: Response + try { + response = client.newCall(request).execute() + } catch (e: IOException) { + Log.e(TAG, "DOWNLOAD $url – network error: ${e.message}", e) + throw e + } + val statusCode = response.code val headers = extractHeaders(response) + Log.d(TAG, "DOWNLOAD $url – $statusCode") + Log.d(TAG, " Response headers: ${redactHeaders(response.headers)}") if (statusCode !in 200..299) { - Log.e(TAG, "HTTP error downloading $url: $statusCode") + Log.e(TAG, "DOWNLOAD $url – HTTP error: $statusCode") throw EditorHTTPClientError.DownloadFailed(statusCode) } @@ -163,8 +192,14 @@ class EditorHTTPClient( input.copyTo(output) } } - Log.d(TAG, "Downloaded file: file=${destination.absolutePath}, size=${destination.length()} bytes, url=$url") - } ?: throw EditorHTTPClientError.DownloadFailed(statusCode) + Log.d(TAG, "DOWNLOAD $url – complete (${destination.length()} bytes)") + Log.d(TAG, " Saved to: ${destination.absolutePath}") + } ?: run { + Log.e(TAG, "DOWNLOAD $url – empty response body") + throw EditorHTTPClientError.DownloadFailed(statusCode) + } + + delegate?.didPerformRequest(url, EditorHttpMethod.GET, response, EditorResponseData.File(destination)) EditorHTTPClientDownloadResponse( file = destination, @@ -175,6 +210,8 @@ class EditorHTTPClient( override suspend fun perform(method: EditorHttpMethod, url: String): EditorHTTPClientResponse = withContext(Dispatchers.IO) { + Log.d(TAG, "$method $url") + // OkHttp requires a body for POST, PUT, PATCH methods // GET, HEAD, OPTIONS, DELETE don't require a body val requiresBody = method in listOf( @@ -190,7 +227,15 @@ class EditorHTTPClient( .method(method.toString(), requestBody) .build() - val response = client.newCall(request).execute() + Log.d(TAG, " Request headers: ${redactHeaders(request.headers)}") + + val response: Response + try { + response = client.newCall(request).execute() + } catch (e: IOException) { + Log.e(TAG, "$method $url – network error: ${e.message}", e) + throw e + } // Note: This loads the entire response into memory. This is acceptable because // this method is only used for WordPress REST API responses (editor settings, post @@ -200,14 +245,22 @@ class EditorHTTPClient( val statusCode = response.code val headers = extractHeaders(response) - delegate?.didPerformRequest(url, method, response, data) + Log.d(TAG, "$method $url – $statusCode (${data.size} bytes)") + Log.d(TAG, " Response headers: ${redactHeaders(response.headers)}") + + delegate?.didPerformRequest(url, method, response, EditorResponseData.Bytes(data)) if (statusCode !in 200..299) { - Log.e(TAG, "HTTP error fetching $url: $statusCode") + Log.e(TAG, "$method $url – HTTP error: $statusCode") + // Log the raw body to aid debugging unexpected error formats. + // This is acceptable because the WordPress REST API should never + // include sensitive information (tokens, credentials) in responses. + Log.e(TAG, " Response body: ${data.toString(Charsets.UTF_8)}") // Try to parse as WordPress error val wpError = tryParseWPError(data) if (wpError != null) { + Log.e(TAG, " WP error – code: ${wpError.code}, message: ${wpError.message}") throw EditorHTTPClientError.WPErrorResponse(wpError) } @@ -259,5 +312,17 @@ class EditorHTTPClient( companion object { private const val TAG = "EditorHTTPClient" private val gson = Gson() + + private val SENSITIVE_HEADERS = setOf("authorization", "cookie", "set-cookie") + + /** + * Returns a string representation of the given OkHttp headers with + * sensitive values (Authorization, Cookie) redacted. + */ + internal fun redactHeaders(headers: okhttp3.Headers): String { + return headers.joinToString(", ") { (name, value) -> + if (name.lowercase() in SENSITIVE_HEADERS) "$name: " else "$name: $value" + } + } } } diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index 68dec2bca..e9e2abec5 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt @@ -27,6 +27,12 @@ data class EditorConfiguration( val enableAssetCaching: Boolean = false, val cachedAssetHosts: Set = emptySet(), val editorAssetsEndpoint: String? = null, + /** + * Enables the JavaScript editor to surface network request/response details + * to the native host app (via the bridge). This does **not** control the + * native [EditorHTTPClient][org.wordpress.gutenberg.EditorHTTPClient]'s own + * debug logging, which always runs at the platform debug level. + */ val enableNetworkLogging: Boolean = false, var enableOfflineMode: Boolean = false ): Parcelable { diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorHTTPClientTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorHTTPClientTest.kt index 667e00ea8..ebc480b32 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorHTTPClientTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorHTTPClientTest.kt @@ -263,10 +263,10 @@ class EditorHTTPClientTest { var delegateCalled = false var capturedUrl: String? = null var capturedMethod: EditorHttpMethod? = null - var capturedData: ByteArray? = null + var capturedData: EditorResponseData? = null val delegate = object : EditorHTTPClientDelegate { - override fun didPerformRequest(url: String, method: EditorHttpMethod, response: Response, data: ByteArray) { + override fun didPerformRequest(url: String, method: EditorHttpMethod, response: Response, data: EditorResponseData) { delegateCalled = true capturedUrl = url capturedMethod = method @@ -280,7 +280,8 @@ class EditorHTTPClientTest { assertTrue(delegateCalled) assertTrue(capturedUrl?.contains("test") == true) assertEquals(EditorHttpMethod.GET, capturedMethod) - assertEquals("response data", capturedData?.toString(Charsets.UTF_8)) + val bytes = (capturedData as? EditorResponseData.Bytes)?.data + assertEquals("response data", bytes?.toString(Charsets.UTF_8)) } @Test diff --git a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift index 431a03828..0f96d451a 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift @@ -73,15 +73,34 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol { public func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) { let configuredRequest = self.configureRequest(urlRequest) - let (data, response) = try await self.urlSession.data(for: configuredRequest) + let url = configuredRequest.url!.absoluteString + let method = configuredRequest.httpMethod ?? "GET" + Logger.http.debug("📡 \(method) \(url)") + Logger.http.debug("📡 Request headers: \(self.redactHeaders(configuredRequest.allHTTPHeaderFields))") + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await self.urlSession.data(for: configuredRequest) + } catch { + Logger.http.error("📡 \(method) \(url) – network error: \(error.localizedDescription)") + throw error + } + self.delegate?.didPerformRequest(configuredRequest, response: response, data: .bytes(data)) let httpResponse = response as! HTTPURLResponse + Logger.http.debug("📡 \(method) \(url) – \(httpResponse.statusCode) (\(data.count) bytes)") + Logger.http.debug("📡 Response headers: \(self.redactHeaders(httpResponse.allHeaderFields))") guard 200...299 ~= httpResponse.statusCode else { - Logger.http.error("📡 HTTP error fetching \(configuredRequest.url!.absoluteString): \(httpResponse.statusCode)") + Logger.http.error("📡 \(method) \(url) – HTTP error: \(httpResponse.statusCode)") + // Log the raw body to aid debugging unexpected error formats. + // This is acceptable because the WordPress REST API should never + // include sensitive information (tokens, credentials) in responses. + Logger.http.error("📡 Response body: \(String(data: data, encoding: .utf8) ?? "")") if let wpError = try? JSONDecoder().decode(WPError.self, from: data) { + Logger.http.error("📡 WP error – code: \(wpError.code), message: \(wpError.message)") throw ClientError.wpError(wpError) } @@ -94,13 +113,27 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol { public func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse) { let configuredRequest = self.configureRequest(urlRequest) - let (url, response) = try await self.urlSession.download(for: configuredRequest, delegate: nil) + let requestURL = configuredRequest.url!.absoluteString + Logger.http.debug("📡 DOWNLOAD \(requestURL)") + Logger.http.debug("📡 Request headers: \(self.redactHeaders(configuredRequest.allHTTPHeaderFields))") + + let (url, response): (URL, URLResponse) + do { + (url, response) = try await self.urlSession.download(for: configuredRequest, delegate: nil) + } catch { + Logger.http.error("📡 DOWNLOAD \(requestURL) – network error: \(error.localizedDescription)") + throw error + } + self.delegate?.didPerformRequest(configuredRequest, response: response, data: .file(url)) let httpResponse = response as! HTTPURLResponse + Logger.http.debug("📡 DOWNLOAD \(requestURL) – \(httpResponse.statusCode)") + Logger.http.debug("📡 Downloaded to: \(url.path)") + Logger.http.debug("📡 Response headers: \(self.redactHeaders(httpResponse.allHeaderFields))") guard 200...299 ~= httpResponse.statusCode else { - Logger.http.error("📡 HTTP error fetching \(configuredRequest.url!.absoluteString): \(httpResponse.statusCode)") + Logger.http.error("📡 DOWNLOAD \(requestURL) – HTTP error: \(httpResponse.statusCode)") throw ClientError.downloadFailed(statusCode: httpResponse.statusCode) } @@ -108,6 +141,24 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol { return (url, response as! HTTPURLResponse) } + private static let sensitiveHeaders: Set = ["authorization", "cookie", "set-cookie"] + + private func redactHeaders(_ headers: [String: String]?) -> String { + guard let headers else { return "[:]" } + let redacted = headers.map { key, value in + Self.sensitiveHeaders.contains(key.lowercased()) ? "\(key): " : "\(key): \(value)" + } + return "[\(redacted.joined(separator: ", "))]" + } + + private func redactHeaders(_ headers: [AnyHashable: Any]) -> String { + let redacted = headers.map { key, value in + let name = "\(key)" + return Self.sensitiveHeaders.contains(name.lowercased()) ? "\(name): " : "\(name): \(value)" + } + return "[\(redacted.joined(separator: ", "))]" + } + private func configureRequest(_ request: URLRequest) -> URLRequest { var mutableRequest = request mutableRequest.addValue(self.authHeader, forHTTPHeaderField: "Authorization") diff --git a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift index 92d8bfa37..173197654 100644 --- a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift +++ b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift @@ -57,7 +57,10 @@ public struct EditorConfiguration: Sendable, Hashable, Equatable { public let editorAssetsEndpoint: URL? /// Logs emitted at or above this level will be printed to the debug console public let logLevel: EditorLogLevel - /// Enables logging of all network requests/responses to the native host + /// Enables the JavaScript editor to surface network request/response details + /// to the native host app (via the bridge). This does **not** control the + /// native `EditorHTTPClient`'s own debug logging, which always runs at the + /// platform debug level and is stripped from release builds. public let enableNetworkLogging: Bool /// Don't make HTTP requests public let isOfflineModeEnabled: Bool