Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

/**
Expand Down Expand Up @@ -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)
}

Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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)
}

Expand Down Expand Up @@ -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: <redacted>" else "$name: $value"
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ data class EditorConfiguration(
val enableAssetCaching: Boolean = false,
val cachedAssetHosts: Set<String> = 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
59 changes: 55 additions & 4 deletions ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) ?? "<non-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)
}

Expand All @@ -94,20 +113,52 @@ 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)
}

return (url, response as! HTTPURLResponse)
}

private static let sensitiveHeaders: Set<String> = ["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): <redacted>" : "\(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): <redacted>" : "\(name): \(value)"
}
return "[\(redacted.joined(separator: ", "))]"
}

private func configureRequest(_ request: URLRequest) -> URLRequest {
var mutableRequest = request
mutableRequest.addValue(self.authHeader, forHTTPHeaderField: "Authorization")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading