diff --git a/Package.resolved b/Package.resolved
index 62e7aa381..2ff636af3 100644
--- a/Package.resolved
+++ b/Package.resolved
@@ -1,5 +1,5 @@
{
- "originHash" : "e4f07fb846fe4e484ddbb0d1c5783d0a62ffee053e850e8a2abe0b8e0323bbd4",
+ "originHash" : "c6b2e9abe520d144490a97766c0d11f9249bd0d35e0e6f16609262f5e91acea0",
"pins" : [
{
"identity" : "svgview",
diff --git a/Package.swift b/Package.swift
index 510647fcb..e0513343a 100644
--- a/Package.swift
+++ b/Package.swift
@@ -27,7 +27,7 @@ let package = Package(
targets: [
.target(
name: "GutenbergKit",
- dependencies: ["SwiftSoup", "SVGView", "GutenbergKitResources"],
+ dependencies: ["SwiftSoup", "SVGView", "GutenbergKitResources", "GutenbergKitHTTP"],
path: "ios/Sources/GutenbergKit",
exclude: ["Gutenberg"],
packageAccess: false
diff --git a/android/Gutenberg/build.gradle.kts b/android/Gutenberg/build.gradle.kts
index 02aab6734..91747a1f0 100644
--- a/android/Gutenberg/build.gradle.kts
+++ b/android/Gutenberg/build.gradle.kts
@@ -173,6 +173,7 @@ dependencies {
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.activity.compose)
+ testImplementation(libs.json)
testImplementation(libs.junit)
testImplementation(kotlin("test"))
testImplementation(libs.kotlinx.coroutines.test)
diff --git a/android/Gutenberg/detekt-baseline.xml b/android/Gutenberg/detekt-baseline.xml
index 428e9d6ff..4f6c96915 100644
--- a/android/Gutenberg/detekt-baseline.xml
+++ b/android/Gutenberg/detekt-baseline.xml
@@ -10,6 +10,7 @@
CyclomaticComplexMethod:MultipartPart.kt$MultipartPart.Companion$fun parseChunked( source: RequestBody.FileBacked, boundary: String ): List<MultipartPart>
ExplicitItLambdaParameter:EditorAssetsLibrary.kt$EditorAssetsLibrary${ str, it -> str + "%02x".format(it) }
FunctionNaming:EditorURLCache.kt$EditorURLCache$private fun __store( response: EditorURLResponse, url: String, httpMethod: EditorHttpMethod, currentDate: Date )
+ LargeClass:GutenbergView.kt$GutenbergView : FrameLayout
LongMethod:FixtureTests.kt$FixtureTests$@Test fun `request parsing - all basic cases pass`()
LongMethod:FixtureTests.kt$FixtureTests$@Test fun `request parsing - all incremental cases pass`()
LongMethod:HTTPRequestParser.kt$HTTPRequestParser$fun append(data: ByteArray): Unit
diff --git a/android/Gutenberg/src/androidTest/java/org/wordpress/gutenberg/http/InstrumentedFixtureTests.kt b/android/Gutenberg/src/androidTest/java/org/wordpress/gutenberg/http/InstrumentedFixtureTests.kt
index 1bc7f5c93..ae739cedc 100644
--- a/android/Gutenberg/src/androidTest/java/org/wordpress/gutenberg/http/InstrumentedFixtureTests.kt
+++ b/android/Gutenberg/src/androidTest/java/org/wordpress/gutenberg/http/InstrumentedFixtureTests.kt
@@ -151,13 +151,34 @@ class InstrumentedFixtureTests {
try {
parser.parseRequest()
- fail("$description: expected error $expectedError but parsing succeeded")
+ // Non-fatal errors (e.g., payloadTooLarge) are exposed via
+ // pendingParseError instead of being thrown.
+ val pendingError = parser.pendingParseError
+ if (pendingError != null) {
+ assertEquals(
+ expectedError,
+ pendingError.errorId,
+ "$description: expected $expectedError but got ${pendingError.errorId}"
+ )
+ assertEquals(
+ HTTPRequestParseError.Disposition.RECOVERABLE,
+ pendingError.disposition,
+ "$description: ${pendingError.errorId} surfaced via pendingParseError but is not RECOVERABLE"
+ )
+ } else {
+ fail("$description: expected error $expectedError but parsing succeeded")
+ }
} catch (e: HTTPRequestParseException) {
assertEquals(
expectedError,
e.error.errorId,
"$description: expected $expectedError but got ${e.error.errorId}"
)
+ assertEquals(
+ HTTPRequestParseError.Disposition.FATAL,
+ e.error.disposition,
+ "$description: ${e.error.errorId} was thrown but is not FATAL"
+ )
}
}
}
diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt
index 0740ad222..47b1fe28a 100644
--- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt
+++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt
@@ -12,6 +12,7 @@ import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
+import android.security.NetworkSecurityPolicy
import android.util.Log
import android.view.Gravity
import android.view.inputmethod.InputMethodManager
@@ -111,6 +112,58 @@ class GutenbergView : FrameLayout {
var requestInterceptor: GutenbergRequestInterceptor = DefaultGutenbergRequestInterceptor()
+ /** Optional delegate for customizing media upload behavior (resize, transcode, custom upload). */
+ var mediaUploadDelegate: MediaUploadDelegate? = null
+ set(value) {
+ if (field === value) return
+ field = value
+ // Stop any previously running server before starting a new one.
+ uploadServer?.stop()
+ uploadServer = null
+ // (Re)start the upload server so it captures the delegate. This
+ // handles the common case where the delegate is set after
+ // construction but before the editor finishes loading.
+ if (value != null) {
+ startUploadServer()
+ }
+ // Reflect the resulting server state in the page: advertise a freshly
+ // (re)started server, or clear the port when the server did NOT
+ // (re)start (delegate cleared, auth missing, or cleartext-to-localhost
+ // blocked) so the JS upload middleware routes to the default path
+ // instead of fetching a now-dead port.
+ syncUploadServerJavaScriptVariables()
+ }
+
+ private var uploadServer: MediaUploadServer? = null
+ private val uploadHttpClient: okhttp3.OkHttpClient by lazy {
+ // The read/write inactivity timeouts mirror URLSession's 60s
+ // timeoutIntervalForRequest default — an inactivity timer that resets on
+ // progress — so the iOS and Android upload clients behave the same. Like
+ // URLSession, which governs uploads with that inactivity timer rather than a
+ // total-duration cap, there is deliberately NO callTimeout here (unlike the
+ // sibling EditorHTTPClient, which caps total call duration — fine for small
+ // REST payloads, wrong for uploads): because each timeout resets on progress,
+ // a large upload is never failed on total duration, only on a genuine 60s
+ // stall in the active direction.
+ //
+ // - writeTimeout (upload): a transient loss of connectivity during the upload
+ // fails within ~60s so it surfaces and can be retried, rather than hanging.
+ // - readTimeout (download): gives WordPress time to generate image sub-sizes
+ // synchronously inside POST /wp/v2/media, during which it sends no response
+ // bytes. The bare OkHttpClient() 10s default fired mid-resize — and since the
+ // attachment row exists before resizing finishes, that orphaned it
+ // server-side and duplicated it on retry.
+ // - connectTimeout: a much shorter 15s — establishing a socket should be
+ // quick, so this fails fast on an unreachable host instead of making the
+ // user wait out the full window. (URLSession has no separate connect dial;
+ // it folds connection setup into the same 60s request timer.)
+ okhttp3.OkHttpClient.Builder()
+ .connectTimeout(CONNECT_TIMEOUT_SECONDS, java.util.concurrent.TimeUnit.SECONDS)
+ .readTimeout(UPLOAD_TIMEOUT_SECONDS, java.util.concurrent.TimeUnit.SECONDS)
+ .writeTimeout(UPLOAD_TIMEOUT_SECONDS, java.util.concurrent.TimeUnit.SECONDS)
+ .build()
+ }
+
private var onFileChooserRequested: ((Intent, Int) -> Unit)? = null
private var contentChangeListener: ContentChangeListener? = null
private var historyChangeListener: HistoryChangeListener? = null
@@ -579,7 +632,12 @@ class GutenbergView : FrameLayout {
}
private fun setGlobalJavaScriptVariables() {
- val gbKit = GBKitGlobal.fromConfiguration(configuration, dependencies)
+ val gbKit = GBKitGlobal.fromConfiguration(
+ configuration,
+ dependencies,
+ nativeUploadPort = uploadServer?.port,
+ nativeUploadToken = uploadServer?.token
+ )
val gbKitJson = gbKit.toJsonString()
val gbKitConfig = """
window.GBKit = $gbKitJson;
@@ -589,6 +647,79 @@ class GutenbergView : FrameLayout {
webView.evaluateJavascript(gbKitConfig, null)
}
+ /**
+ * Syncs the current upload server's port and token into the already-loaded
+ * page.
+ *
+ * Advertises a running server so JS uploads route through it, and clears them
+ * (to `null`) when the server is stopped or was never started — so the JS
+ * upload middleware routes to the default path instead of fetching a now-dead
+ * port. The initial injection is handled by [setGlobalJavaScriptVariables]
+ * from `onPageStarted`; this keeps JS in sync when the server (re)starts or
+ * stops *after* the page has loaded (e.g. when [mediaUploadDelegate] is
+ * assigned, replaced, or cleared).
+ *
+ * The `window.GBKit` guard makes this a no-op before the page has loaded, so
+ * it is safe to call on the initial start too.
+ */
+ private fun syncUploadServerJavaScriptVariables() {
+ val portJs = uploadServer?.port?.toString() ?: "null"
+ val tokenJs = uploadServer?.token?.let { JSONObject.quote(it) } ?: "null"
+ val js = """
+ if (window.GBKit) {
+ window.GBKit.nativeUploadPort = $portJs;
+ window.GBKit.nativeUploadToken = $tokenJs;
+ localStorage.setItem('GBKit', JSON.stringify(window.GBKit));
+ }
+ """.trimIndent()
+ // evaluateJavascript must run on the WebView's (UI) thread; post it so a
+ // delegate set from a background thread doesn't throw thread-affinity.
+ webView.post { webView.evaluateJavascript(js, null) }
+ }
+
+ private fun startUploadServer() {
+ // The native upload server relays through DefaultMediaUploader, which needs a
+ // site root and an auth header (every host provides one — the editor injects
+ // it because the WebView has no auth cookies). Without both there is nothing
+ // to upload through, so leave the server down and let uploads fall to the
+ // default WebView path rather than start a server that could only fail.
+ if (configuration.siteApiRoot.isEmpty() || configuration.authHeader.isEmpty()) return
+
+ // The editor reaches the loopback server over cleartext http://localhost. If
+ // the host app's network-security config doesn't permit cleartext to
+ // localhost, the WebView blocks every upload fetch (ERR_CLEARTEXT_NOT_PERMITTED)
+ // before it leaves the page. Detect that here and don't start the server, so
+ // the JS middleware routes uploads down the default path instead of a server
+ // it can never reach. Hosts that want native media processing must permit
+ // cleartext to localhost (see the demo's res/xml/network_security_config.xml).
+ if (!NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted(LOOPBACK_HOST)) {
+ Log.w(
+ TAG,
+ "Cleartext to $LOOPBACK_HOST is not permitted, so the native media upload " +
+ "server can't be reached from the WebView. Permit cleartext to $LOOPBACK_HOST " +
+ "in the app's network security config to enable native media processing."
+ )
+ return
+ }
+
+ try {
+ val defaultUploader = DefaultMediaUploader(
+ httpClient = uploadHttpClient,
+ siteApiRoot = configuration.siteApiRoot,
+ authHeader = configuration.authHeader,
+ siteApiNamespace = configuration.siteApiNamespace.toList()
+ )
+ uploadServer = MediaUploadServer(
+ uploadDelegate = mediaUploadDelegate,
+ defaultUploader = defaultUploader,
+ cacheDir = context.cacheDir,
+ scope = coroutineScope
+ )
+ // JS is synced by the mediaUploadDelegate setter after this returns.
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to start upload server", e)
+ }
+ }
fun clearConfig() {
val jsCode = """
@@ -1018,6 +1149,8 @@ class GutenbergView : FrameLayout {
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
stopNetworkMonitoring()
+ uploadServer?.stop()
+ uploadServer = null
clearConfig()
// Cancel in-flight animations to prevent withEndAction callbacks from
// firing on detached views.
@@ -1091,11 +1224,25 @@ class GutenbergView : FrameLayout {
}
companion object {
+ private const val TAG = "GutenbergView"
+
/** Hosts that are safe to serve assets over HTTP (local development only). */
private val LOCAL_HOSTS = setOf("localhost", "127.0.0.1", "10.0.2.2")
private const val ASSET_LOADING_TIMEOUT_MS = 5000L
+ /**
+ * Read/write inactivity timeout for media uploads, matching URLSession's
+ * 60s `timeoutIntervalForRequest` default. See [uploadHttpClient].
+ */
+ private const val UPLOAD_TIMEOUT_SECONDS = 60L
+
+ /** Connection-setup timeout for media uploads — short, to fail fast on an unreachable host. */
+ private const val CONNECT_TIMEOUT_SECONDS = 15L
+
+ /** Host the WebView uses to reach the loopback upload server (must match the JS fetch host). */
+ private const val LOOPBACK_HOST = "localhost"
+
// Warmup state management
private var warmupHandler: Handler? = null
private var warmupRunnable: Runnable? = null
diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/HttpServer.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/HttpServer.kt
index fcc98d655..8b610614f 100644
--- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/HttpServer.kt
+++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/HttpServer.kt
@@ -39,8 +39,29 @@ data class HttpRequest(
val target: String,
val headers: Map,
val body: org.wordpress.gutenberg.http.RequestBody? = null,
- val parseDurationMs: Double = 0.0
+ val parseDurationMs: Double = 0.0,
+ /** A server-detected error that occurred after headers were parsed
+ * (e.g., payload too large). When set, the handler is responsible
+ * for building an appropriate error response. */
+ val serverError: org.wordpress.gutenberg.http.HTTPRequestParseError? = null
) {
+ /**
+ * The path portion of [target], without the query component
+ * (e.g., "/wp/v2/posts" for "/wp/v2/posts?per_page=10").
+ *
+ * Use this for routing — matching against [target] fails as soon as a
+ * client appends a query string.
+ */
+ val path: String
+ get() = target.substringBefore('?')
+
+ /**
+ * The query component of [target], including the leading "?"
+ * (e.g., "?per_page=10"), or an empty string when there is no query.
+ */
+ val query: String
+ get() = target.substringAfter('?', "").let { if (it.isEmpty()) "" else "?$it" }
+
/**
* Returns the value of the first header matching the given name (case-insensitive).
*/
@@ -67,6 +88,45 @@ data class HttpResponse(
val body: ByteArray = ByteArray(0)
)
+/** CORS behavior for an [HttpServer]. */
+enum class CorsPolicy {
+ /** No CORS headers are added (the default). */
+ None,
+
+ /**
+ * Permissive CORS for a loopback-only server serving a WebView: allows any
+ * origin and the methods/headers this library's clients use. The server
+ * answers OPTIONS preflight requests itself and stamps these headers on every
+ * response — including ones it generates internally (timeouts, parse errors)
+ * that never reach the handler.
+ */
+ Permissive;
+
+ /** Headers added to every response under this policy. */
+ val responseHeaders: Map
+ get() = when (this) {
+ None -> emptyMap()
+ Permissive -> mapOf(
+ "Access-Control-Allow-Origin" to "*",
+ "Access-Control-Allow-Methods" to "GET, POST, PUT, DELETE, OPTIONS",
+ "Access-Control-Allow-Headers" to "Authorization, Relay-Authorization, Content-Type",
+ "Access-Control-Max-Age" to "86400"
+ )
+ }
+}
+
+/**
+ * Returns a copy with [newHeaders] added, skipping any whose name
+ * (case-insensitive) is already present.
+ */
+private fun HttpResponse.addingHeadersIfAbsent(newHeaders: Map): HttpResponse {
+ if (newHeaders.isEmpty()) return this
+ val existing = headers.keys.map { it.lowercase() }.toSet()
+ val toAdd = newHeaders.filterKeys { it.lowercase() !in existing }
+ if (toAdd.isEmpty()) return this
+ return copy(headers = headers + toAdd)
+}
+
/**
* A lightweight local HTTP/1.1 server.
*
@@ -140,6 +200,7 @@ data class HttpResponse(
* server.stop()
* ```
*/
+@Suppress("LongParameterList")
class HttpServer(
val name: String,
private val requestedPort: Int = 0,
@@ -150,6 +211,7 @@ class HttpServer(
private val readTimeoutMs: Int = DEFAULT_READ_TIMEOUT_MS,
private val idleTimeoutMs: Int = DEFAULT_IDLE_TIMEOUT_MS,
private val cacheDir: File? = null,
+ private val cors: CorsPolicy = CorsPolicy.None,
private val handler: suspend (HttpRequest) -> HttpResponse
) {
@Volatile
@@ -277,14 +339,7 @@ class HttpServer(
val buffer = ByteArray(READ_CHUNK_SIZE)
// Phase 1: receive headers only.
- while (!parser.state.hasHeaders) {
- if (System.nanoTime() > deadlineNanos) {
- throw SocketTimeoutException("Read deadline exceeded")
- }
- val bytesRead = input.read(buffer)
- if (bytesRead == -1) break
- parser.append(buffer.copyOfRange(0, bytesRead))
- }
+ readUntil(parser, input, buffer, deadlineNanos) { it.hasHeaders }
// Validate headers (triggers full RFC validation).
val partial = try {
@@ -312,8 +367,10 @@ class HttpServer(
return
}
- // Check auth before consuming body to avoid buffering up to
- // maxBodySize for unauthenticated clients.
+ // Check auth on headers alone, before draining or consuming any
+ // body bytes — an unauthenticated client must not be able to make
+ // the server read (and discard) an arbitrarily large body, and the
+ // handler must never see an unauthenticated request.
// OPTIONS is exempt because CORS preflight requests
// never include credentials (Fetch spec §3.3.5).
if (requiresAuthentication && partial.method.uppercase() != "OPTIONS") {
@@ -328,6 +385,38 @@ class HttpServer(
}
}
+ // Drain the oversized body before responding so the (authenticated)
+ // client receives the 413 instead of a connection reset
+ // (RFC 9110 §15.5.14).
+ if (parser.state == HTTPRequestParser.State.DRAINING) {
+ readUntil(parser, input, buffer, deadlineNanos) { it.isComplete }
+ }
+
+ // If the parser detected a non-fatal error (e.g., payload too
+ // large after drain), let the handler build the response.
+ parser.pendingParseError?.let { error ->
+ val parseDurationMs = (System.nanoTime() - parseStart) / 1_000_000.0
+ val request = HttpRequest(
+ method = partial.method,
+ target = partial.target,
+ headers = partial.headers,
+ parseDurationMs = parseDurationMs,
+ serverError = error
+ )
+ val response = try {
+ handler(request)
+ } catch (e: Exception) {
+ Log.e(TAG, "Handler threw", e)
+ HttpResponse(
+ status = error.httpStatus,
+ body = (STATUS_TEXT[error.httpStatus] ?: "Error").toByteArray()
+ )
+ }
+ sendResponse(socket, response)
+ Log.d(TAG, "${partial.method} ${partial.target} → ${response.status} (${"%.1f".format(parseDurationMs)}ms)")
+ return
+ }
+
// Reject body-bearing methods without Content-Length.
// We don't support Transfer-Encoding: chunked, so
// Content-Length is the only way to determine body size.
@@ -341,14 +430,7 @@ class HttpServer(
}
// Phase 2: receive body (skipped if already complete).
- while (!parser.state.isComplete) {
- if (System.nanoTime() > deadlineNanos) {
- throw SocketTimeoutException("Read deadline exceeded")
- }
- val bytesRead = input.read(buffer)
- if (bytesRead == -1) break
- parser.append(buffer.copyOfRange(0, bytesRead))
- }
+ readUntil(parser, input, buffer, deadlineNanos) { it.isComplete }
// Final parse with body.
val parsed = try {
@@ -391,24 +473,52 @@ class HttpServer(
body = parsed.body,
parseDurationMs = parseDurationMs
)
- val response = try {
- handler(request)
- } catch (e: Exception) {
- Log.e(TAG, "Handler threw", e)
- HttpResponse(
- status = 500,
- body = "Internal Server Error".toByteArray()
- )
- }
+ val response = resolveResponse(request)
sendResponse(socket, response)
Log.d(TAG, "${parsed.method} ${parsed.target} → ${response.status} (${"%.1f".format(parseDurationMs)}ms)")
}
}
}
+ /** Reads data into the parser until [condition] is satisfied or the connection closes. */
+ private fun readUntil(
+ parser: HTTPRequestParser,
+ input: BufferedInputStream,
+ buffer: ByteArray,
+ deadlineNanos: Long,
+ condition: (HTTPRequestParser.State) -> Boolean
+ ) {
+ while (!condition(parser.state)) {
+ if (System.nanoTime() > deadlineNanos) {
+ throw SocketTimeoutException("Read deadline exceeded")
+ }
+ val bytesRead = input.read(buffer)
+ if (bytesRead == -1) break
+ parser.append(buffer.copyOfRange(0, bytesRead))
+ }
+ }
+
+ /**
+ * Resolves the response for a request: the CORS preflight (under a permissive
+ * policy) or the handler's response. Kept separate from [handleRequest] so
+ * that already-complex function doesn't grow.
+ */
+ private suspend fun resolveResponse(request: HttpRequest): HttpResponse {
+ if (cors == CorsPolicy.Permissive && request.method.uppercase() == "OPTIONS") {
+ return HttpResponse(status = 204, body = ByteArray(0))
+ }
+ return try {
+ handler(request)
+ } catch (e: Exception) {
+ Log.e(TAG, "Handler threw", e)
+ HttpResponse(status = 500, body = "Internal Server Error".toByteArray())
+ }
+ }
+
private fun sendResponse(socket: Socket, response: HttpResponse) {
+ val decorated = response.addingHeadersIfAbsent(cors.responseHeaders)
val output = socket.getOutputStream()
- output.write(serializeResponse(response))
+ output.write(serializeResponse(decorated))
output.flush()
}
diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt
new file mode 100644
index 000000000..c289866a8
--- /dev/null
+++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt
@@ -0,0 +1,468 @@
+package org.wordpress.gutenberg
+
+import android.util.Log
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import org.wordpress.gutenberg.http.HeaderValue
+import org.wordpress.gutenberg.http.MultipartPart
+import org.wordpress.gutenberg.http.HTTPRequestParseError
+import org.wordpress.gutenberg.http.MultipartParseException
+import java.io.File
+import java.io.IOException
+import java.util.UUID
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.RequestBody.Companion.asRequestBody
+import okhttp3.RequestBody.Companion.toRequestBody
+import okio.source
+
+/**
+ * A raw response from the WordPress REST API media endpoint.
+ *
+ * GutenbergKit relays this to the editor verbatim — it does not interpret the
+ * body. The editor receives the exact attachment object (on success) or
+ * WordPress REST error object (on failure) it would get from a direct upload,
+ * so every consumer — image sub-sizes, attachment links, error notices —
+ * behaves identically to a non-native upload.
+ */
+class MediaUploadResponse(
+ /** The HTTP status code WordPress (or the host's upload service) returned. */
+ val statusCode: Int,
+ /**
+ * The raw response body — a WordPress REST attachment on success, or a
+ * WordPress REST error object (`{ "code", "message", "data" }`) on failure.
+ */
+ val body: ByteArray
+)
+
+/**
+ * The result of a delegate's [MediaUploadDelegate.processFile].
+ */
+sealed class ProcessedProxyFile {
+ /** The delegate did not modify the file; the original upload is forwarded unchanged. */
+ data object Original : ProcessedProxyFile()
+
+ /**
+ * The delegate produced a file to upload, along with its MIME type and
+ * filename. Both are used verbatim, so a format change (e.g. transcoding MOV
+ * to MP4, or an in-place EXIF strip) must report the resulting type and
+ * filename for WordPress to store the file correctly.
+ */
+ data class Processed(val file: File, val mimeType: String, val filename: String) : ProcessedProxyFile()
+}
+
+/**
+ * Interface for customizing media upload behavior.
+ *
+ * The native host app can provide an implementation to resize images,
+ * transcode video, or use its own upload service.
+ */
+interface MediaUploadDelegate {
+ /**
+ * Process a file before upload (e.g., resize image, transcode video).
+ *
+ * Return [ProcessedProxyFile.Original] to upload the file unchanged, or
+ * [ProcessedProxyFile.Processed] with the processed file and its metadata.
+ * When the format changes, report the new mimeType and filename so WordPress
+ * stores it with the correct extension and type.
+ */
+ suspend fun processFile(file: File, mimeType: String, filename: String): ProcessedProxyFile = ProcessedProxyFile.Original
+
+ /**
+ * Upload a processed file to the remote WordPress site.
+ *
+ * Return the raw WordPress response (status code + body), which GutenbergKit
+ * relays to the editor unchanged, or null to use the default uploader. A host
+ * that uploads to WordPress should return the exact response it received so
+ * the editor sees a complete attachment object.
+ */
+ suspend fun uploadFile(file: File, mimeType: String, filename: String): MediaUploadResponse? = null
+}
+
+/**
+ * A local HTTP server that receives file uploads from the WebView and routes
+ * them through the native media processing pipeline.
+ *
+ * Built on [HttpServer], which handles TCP binding, HTTP parsing, bearer token
+ * authentication, and connection management. This class provides the upload-
+ * specific handler: receiving a file, delegating to the host app for
+ * processing/upload, and returning the result as JSON.
+ *
+ * Lifecycle is tied to [GutenbergView] — start when the editor loads,
+ * stop on detach.
+ */
+internal class MediaUploadServer(
+ private val uploadDelegate: MediaUploadDelegate?,
+ private val defaultUploader: DefaultMediaUploader?,
+ cacheDir: File? = null,
+ scope: CoroutineScope = CoroutineScope(Dispatchers.IO),
+ ioDispatcher: CoroutineDispatcher = Dispatchers.IO
+) {
+ /** The port the server is listening on. */
+ val port: Int get() = server.port
+
+ /** Per-session auth token for validating incoming requests. */
+ val token: String get() = server.token
+
+ private val server: HttpServer
+
+ /**
+ * Directory for staging uploaded files, under the injected cache dir (with a
+ * system-temp fallback) so orphans share the app's managed cache lifecycle.
+ */
+ private val uploadsTempDir: File =
+ File(cacheDir ?: File(System.getProperty("java.io.tmpdir")), "gutenbergkit-uploads")
+
+ /**
+ * Sweeps crash-orphaned temp files off the caller's thread. Exposed so tests
+ * can await it; injecting `Dispatchers.Unconfined` for [ioDispatcher] runs the
+ * sweep synchronously.
+ */
+ @Suppress("TooGenericExceptionCaught")
+ val cleanupJob: Job = scope.launch(ioDispatcher) {
+ try {
+ cleanOrphanedUploads()
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to sweep orphaned uploads", e)
+ }
+ }
+
+ init {
+ server = HttpServer(
+ name = "media-upload",
+ externallyAccessible = false,
+ requiresAuthentication = true,
+ cacheDir = cacheDir,
+ cors = CorsPolicy.Permissive,
+ handler = { request -> handleRequest(request) }
+ )
+ server.start()
+ }
+
+ /** Stops the server and releases resources. */
+ fun stop() {
+ cleanupJob.cancel()
+ server.stop()
+ }
+
+ /**
+ * Deletes upload temp files left behind by a prior crash. Files still in
+ * flight (only seconds old) are preserved by the age threshold, so this is
+ * safe even if another editor instance is mid-upload.
+ */
+ private fun cleanOrphanedUploads() {
+ val cutoff = System.currentTimeMillis() - 60 * 60 * 1000L // 1 hour
+ uploadsTempDir.listFiles()?.forEach { file ->
+ if (file.lastModified() < cutoff) {
+ file.delete()
+ }
+ }
+ }
+
+ // MARK: - Request Handling
+
+ private suspend fun handleRequest(request: HttpRequest): HttpResponse {
+ // Server-detected error (e.g., payload too large) — build the
+ // error response here so it includes CORS headers.
+ request.serverError?.let { error ->
+ val message = when (error) {
+ HTTPRequestParseError.PAYLOAD_TOO_LARGE -> "The file is too large to upload in the editor."
+ else -> error.errorId
+ }
+ return errorResponse(error.httpStatus, message)
+ }
+
+ // Route: only POST /upload is handled. (OPTIONS preflight is answered by
+ // the HTTP library under its permissive CORS policy.) Match on the path
+ // alone — the target carries a query string (e.g. `?_embed`) that the
+ // upload handler relays on to WordPress.
+ if (request.method.uppercase() != "POST" || request.path != "/upload") {
+ return errorResponse(404, "Not found")
+ }
+
+ return handleUpload(request)
+ }
+
+ private suspend fun handleUpload(request: HttpRequest): HttpResponse {
+ val parts = parseParts(request)
+ ?: return errorResponse(400, "Expected multipart/form-data with a file")
+ val filePart = parts.firstOrNull { it.filename != null }
+ ?: return errorResponse(400, "Expected multipart/form-data with a file")
+
+ // The non-file parts (post, additionalData) and the original query
+ // (e.g. ?_embed) must reach WordPress too — relay them alongside the file.
+ val extraParts = parts.filter { it.filename == null }
+ val query = request.query
+
+ val tempFile = writePartToTempFile(filePart)
+ ?: return errorResponse(500, "Failed to save file")
+
+ return processAndRespond(request, tempFile, filePart, extraParts, query)
+ }
+
+ private fun parseParts(request: HttpRequest): List? {
+ val contentType = request.header("Content-Type") ?: return null
+ val boundary = HeaderValue.extractParameter("boundary", contentType) ?: return null
+ val body = request.body ?: return null
+
+ return try {
+ val inMemory = body.inMemoryData
+ if (inMemory != null) {
+ MultipartPart.parse(body, inMemory, 0L, boundary)
+ } else {
+ @Suppress("UNCHECKED_CAST")
+ MultipartPart.parseChunked(
+ body as org.wordpress.gutenberg.http.RequestBody.FileBacked,
+ boundary
+ )
+ }
+ } catch (e: MultipartParseException) {
+ Log.e(TAG, "Multipart parse failed", e)
+ null
+ }
+ }
+
+ private fun writePartToTempFile(filePart: MultipartPart): File? {
+ val filename = sanitizeFilename(filePart.filename ?: "upload")
+ uploadsTempDir.mkdirs()
+ val tempFile = File(uploadsTempDir, "${UUID.randomUUID()}-$filename")
+
+ return try {
+ filePart.body.inputStream().use { input ->
+ tempFile.outputStream().use { output ->
+ input.copyTo(output)
+ }
+ }
+ tempFile
+ } catch (e: IOException) {
+ tempFile.delete()
+ Log.e(TAG, "Failed to write upload to disk", e)
+ null
+ }
+ }
+
+ @Suppress("TooGenericExceptionCaught")
+ private suspend fun processAndRespond(
+ request: HttpRequest, tempFile: File, filePart: MultipartPart,
+ extraParts: List, query: String
+ ): HttpResponse {
+ try {
+ val uploadResult = processAndUpload(
+ tempFile, filePart.contentType, filePart.filename ?: "upload", extraParts, query
+ )
+ val response = when (uploadResult) {
+ is UploadResult.Uploaded -> {
+ Log.d(TAG, "Uploaded file to WordPress")
+ uploadResult.response
+ }
+ is UploadResult.Passthrough -> {
+ // Delegate didn't modify the file — forward the original
+ // request body to WordPress without re-encoding.
+ Log.d(TAG, "Passthrough: forwarding original request body to WordPress")
+ performPassthroughUpload(request, query)
+ }
+ }
+ // Relay WordPress's exact status and body to the editor so it sees
+ // the same attachment object (or error) as a direct upload.
+ return HttpResponse(
+ status = response.statusCode,
+ headers = mapOf("Content-Type" to "application/json"),
+ body = response.body
+ )
+ } catch (e: MediaUploadException) {
+ Log.e(TAG, "Upload processing failed", e)
+ return errorResponse(500, e.message ?: "Upload failed")
+ } catch (e: kotlin.coroutines.cancellation.CancellationException) {
+ throw e // Never swallow coroutine cancellation.
+ } catch (e: Exception) {
+ // Any other failure — IOException from the upload call, JSON parse
+ // errors, a throwing host delegate, or "no uploader configured" —
+ // must still be answered WITH CORS headers. Otherwise it escapes to
+ // HttpServer's header-less 500 fallback and the browser rejects the
+ // preflighted cross-origin fetch with an opaque "Failed to fetch",
+ // hiding the real error from the editor (mirrors the iOS catch-all).
+ Log.e(TAG, "Upload failed", e)
+ return errorResponse(500, e.message ?: "Upload failed")
+ } finally {
+ tempFile.delete()
+ }
+ }
+
+ // MARK: - Delegate Pipeline
+
+ private sealed class UploadResult {
+ data class Uploaded(val response: MediaUploadResponse) : UploadResult()
+ data object Passthrough : UploadResult()
+ }
+
+ private suspend fun performPassthroughUpload(request: HttpRequest, query: String): MediaUploadResponse {
+ val body = request.body
+ val contentType = request.header("Content-Type")
+ val uploader = defaultUploader
+ if (body == null || contentType == null || uploader == null) {
+ throw MediaUploadException("Passthrough upload requires a request body, Content-Type, and default uploader")
+ }
+ return uploader.passthroughUpload(body, contentType, query)
+ }
+
+ private suspend fun processAndUpload(
+ file: File, mimeType: String, filename: String,
+ extraParts: List, query: String
+ ): UploadResult {
+ val processed = uploadDelegate?.processFile(file, mimeType, filename) ?: ProcessedProxyFile.Original
+
+ // Resolve the file to upload and its metadata. Processed uses the
+ // delegate's values verbatim, so a format change is reported to WordPress.
+ val targetFile: File
+ val targetMimeType: String
+ val targetFilename: String
+ when (processed) {
+ is ProcessedProxyFile.Original -> {
+ targetFile = file
+ targetMimeType = mimeType
+ targetFilename = filename
+ }
+ is ProcessedProxyFile.Processed -> {
+ targetFile = processed.file
+ targetMimeType = processed.mimeType
+ targetFilename = processed.filename
+ }
+ }
+
+ try {
+ // If the delegate provided its own upload, use that.
+ uploadDelegate?.uploadFile(targetFile, targetMimeType, targetFilename)?.let {
+ return UploadResult.Uploaded(it)
+ }
+
+ // Unmodified — forward the original request body directly, skipping
+ // multipart re-encoding.
+ if (processed is ProcessedProxyFile.Original) {
+ return UploadResult.Passthrough
+ }
+
+ val result = defaultUploader?.upload(targetFile, targetMimeType, targetFilename, extraParts, query)
+ ?: error("No upload delegate or default uploader configured")
+ return UploadResult.Uploaded(result)
+ } finally {
+ // The processed file (if the delegate produced a new one) is ours to
+ // clean up — covers the success and throw paths alike.
+ if (targetFile != file) {
+ targetFile.delete()
+ }
+ }
+ }
+
+ // MARK: - Response Building
+
+ private fun errorResponse(status: Int, message: String): HttpResponse {
+ // Emit a WordPress-REST-style error object so the JS middleware normalizes
+ // it (and surfaces `message`) the same way it does a relayed WordPress
+ // error — the local server's own errors need no special-casing.
+ val json = org.json.JSONObject()
+ .put("code", "upload_error")
+ .put("message", message)
+ .toString()
+ return HttpResponse(
+ status = status,
+ headers = mapOf("Content-Type" to "application/json"),
+ body = json.toByteArray()
+ )
+ }
+
+ // MARK: - Helpers
+
+ /** Sanitizes a filename to prevent path traversal. */
+ private fun sanitizeFilename(name: String): String {
+ val safe = File(name).name.replace(Regex("[/\\\\]"), "")
+ return safe.ifEmpty { "upload" }
+ }
+
+ companion object {
+ private const val TAG = "MediaUploadServer"
+ }
+}
+
+/** Exception thrown when a media upload fails. */
+internal class MediaUploadException(message: String, cause: Throwable? = null) : Exception(message, cause)
+
+/**
+ * Uploads files to the WordPress REST API using OkHttp.
+ */
+internal open class DefaultMediaUploader(
+ private val httpClient: okhttp3.OkHttpClient,
+ private val siteApiRoot: String,
+ private val authHeader: String,
+ private val siteApiNamespace: List = emptyList()
+) {
+ /**
+ * The WordPress media endpoint URL, built through the shared [RestUrlBuilder]
+ * namespacing (so it matches every other REST URL) and carrying the original
+ * request query (e.g. `?_embed`) through to WordPress.
+ */
+ private fun mediaEndpointUrl(query: String): String =
+ RestUrlBuilder.namespaced(siteApiRoot, siteApiNamespace.firstOrNull(), "/wp/v2/media") + query
+
+ open suspend fun upload(
+ file: File, mimeType: String, filename: String,
+ extraParts: List, query: String
+ ): MediaUploadResponse {
+ val mediaType = mimeType.toMediaType()
+ val builder = okhttp3.MultipartBody.Builder().setType(okhttp3.MultipartBody.FORM)
+ // Preserve the non-file parts (post, additionalData) through the re-encode.
+ // Append each field's raw bytes (not via String) so a non-UTF-8 value is
+ // forwarded verbatim rather than coerced. filename=null makes it a plain
+ // field, matching okhttp's String overload byte-for-byte.
+ for (part in extraParts) {
+ builder.addFormDataPart(part.name, null, part.body.readBytes().toRequestBody())
+ }
+ builder.addFormDataPart("file", filename, file.asRequestBody(mediaType))
+
+ val request = okhttp3.Request.Builder()
+ .url(mediaEndpointUrl(query))
+ .addHeader("Authorization", authHeader)
+ .post(builder.build())
+ .build()
+
+ return performUpload(request)
+ }
+
+ /**
+ * Forwards the original request body to WordPress without re-encoding.
+ *
+ * Used when the delegate's `processFile` returned the file unchanged —
+ * the incoming multipart body is already valid for WordPress.
+ */
+ open suspend fun passthroughUpload(
+ body: org.wordpress.gutenberg.http.RequestBody,
+ contentType: String,
+ query: String
+ ): MediaUploadResponse {
+ val streamBody = object : okhttp3.RequestBody() {
+ override fun contentType() = contentType.toMediaType()
+ override fun contentLength() = body.size
+ override fun writeTo(sink: okio.BufferedSink) {
+ body.inputStream().use { sink.writeAll(it.source()) }
+ }
+ }
+
+ val request = okhttp3.Request.Builder()
+ .url(mediaEndpointUrl(query))
+ .addHeader("Authorization", authHeader)
+ .post(streamBody)
+ .build()
+
+ return performUpload(request)
+ }
+
+ private fun performUpload(request: okhttp3.Request): MediaUploadResponse {
+ // Relay WordPress's response verbatim — including non-2xx statuses — so
+ // the editor sees WordPress's real status and error body, exactly as a
+ // direct upload would.
+ return httpClient.newCall(request).execute().use { response ->
+ MediaUploadResponse(response.code, response.body?.bytes() ?: ByteArray(0))
+ }
+ }
+}
diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt
index 692ca79ca..14375f39b 100644
--- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt
+++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt
@@ -23,10 +23,6 @@ class RESTAPIRepository(
) {
private val json = Json { ignoreUnknownKeys = true }
- private val apiRoot = configuration.siteApiRoot.trimEnd('/')
- private val namespace = configuration.siteApiNamespace.firstOrNull()?.let {
- it.trimEnd('/') + "/"
- }
private val editorSettingsUrl = buildNamespacedUrl(EDITOR_SETTINGS_PATH)
private val activeThemeUrl = buildNamespacedUrl(ACTIVE_THEME_PATH)
private val siteSettingsUrl = buildNamespacedUrl(SITE_SETTINGS_PATH)
@@ -217,25 +213,13 @@ class RESTAPIRepository(
return urlResponse
}
- /**
- * Builds a URL from the API root and path, inserting the site API namespace
- * after the version segment if one is configured.
- *
- * For example, with namespace `sites/123/` and path `/wp/v2/types`:
- * the result is `$apiRoot/wp/v2/sites/123/types`.
- */
- private fun buildNamespacedUrl(path: String): String {
- if (namespace == null) {
- return "$apiRoot$path"
- }
-
- val parts = path.removePrefix("/").split("/", limit = 3)
- if (parts.size < 3) {
- return "$apiRoot$path"
- }
-
- return "$apiRoot/${parts[0]}/${parts[1]}/$namespace${parts[2]}"
- }
+ /** Builds a namespaced REST URL via the shared [RestUrlBuilder]. */
+ private fun buildNamespacedUrl(path: String): String =
+ RestUrlBuilder.namespaced(
+ configuration.siteApiRoot,
+ configuration.siteApiNamespace.firstOrNull(),
+ path
+ )
companion object {
private const val EDITOR_SETTINGS_PATH = "/wp-block-editor/v1/settings"
diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RestUrlBuilder.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RestUrlBuilder.kt
new file mode 100644
index 000000000..068d60141
--- /dev/null
+++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RestUrlBuilder.kt
@@ -0,0 +1,30 @@
+package org.wordpress.gutenberg
+
+/**
+ * Single source of truth for building namespaced WordPress REST API URLs, so the
+ * media endpoint and every [RESTAPIRepository] endpoint normalize the site API
+ * root and namespace identically (no drift).
+ */
+internal object RestUrlBuilder {
+ /**
+ * Builds a URL from [siteApiRoot] and [path], inserting [siteApiNamespace]
+ * after the version segment if one is configured. A `null` namespace appends
+ * the path unchanged.
+ *
+ * Trailing slashes on the root and namespace are normalized, so an unslashed
+ * root or namespace still joins cleanly. For example, with namespace `sites/123`
+ * and path `/wp/v2/types`, the result is `$root/wp/v2/sites/123/types`.
+ */
+ fun namespaced(siteApiRoot: String, siteApiNamespace: String?, path: String): String {
+ val root = siteApiRoot.trimEnd('/')
+ val namespace = siteApiNamespace?.let { it.trimEnd('/') + "/" }
+ ?: return "$root$path"
+
+ val parts = path.removePrefix("/").split("/", limit = 3)
+ if (parts.size < 3) {
+ return "$root$path"
+ }
+
+ return "$root/${parts[0]}/${parts[1]}/$namespace${parts[2]}"
+ }
+}
diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/http/HTTPRequestParser.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/http/HTTPRequestParser.kt
index 20e46a758..004ad53aa 100644
--- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/http/HTTPRequestParser.kt
+++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/http/HTTPRequestParser.kt
@@ -37,12 +37,18 @@ class HTTPRequestParser(
NEEDS_MORE_DATA,
/** Headers have been fully received but the body is still incomplete. */
HEADERS_COMPLETE,
+ /**
+ * The request body exceeds the maximum allowed size and is being
+ * drained (read and discarded) so the server can send a clean 413
+ * response. No body bytes are buffered in this state.
+ */
+ DRAINING,
/** All data has been received (headers and body). */
COMPLETE;
/** Whether headers have been fully received. */
val hasHeaders: Boolean
- get() = this == HEADERS_COMPLETE || this == COMPLETE
+ get() = this == HEADERS_COMPLETE || this == DRAINING || this == COMPLETE
/** Whether all data has been received. */
val isComplete: Boolean
@@ -76,6 +82,15 @@ class HTTPRequestParser(
/** The current buffering state. */
val state: State get() = synchronized(lock) { _state }
+ /**
+ * The parse error detected during buffering, if any.
+ *
+ * Non-fatal errors like [HTTPRequestParseError.PAYLOAD_TOO_LARGE] are
+ * exposed here instead of being thrown by [parseRequest], allowing the
+ * caller to still access the parsed headers.
+ */
+ val pendingParseError: HTTPRequestParseError? get() = synchronized(lock) { parseError }
+
/** Creates a parser and immediately parses the given raw HTTP string. */
constructor(
input: String,
@@ -107,6 +122,14 @@ class HTTPRequestParser(
fun append(data: ByteArray): Unit = synchronized(lock) {
if (_state == State.COMPLETE) return
+ // In drain mode, discard bytes without buffering and check
+ // whether the full Content-Length has been consumed.
+ if (_state == State.DRAINING) {
+ bytesWritten += data.size.toLong()
+ drainIfComplete()
+ return
+ }
+
val accepted: Boolean
try {
accepted = buffer.append(data)
@@ -166,7 +189,11 @@ class HTTPRequestParser(
if (expectedContentLength > maxBodySize) {
parseError = HTTPRequestParseError.PAYLOAD_TOO_LARGE
- _state = State.COMPLETE
+ _state = State.DRAINING
+ // Complete immediately if body bytes already received
+ // satisfy the drain — small requests may arrive as a
+ // single read.
+ drainIfComplete()
return
}
}
@@ -181,6 +208,14 @@ class HTTPRequestParser(
}
}
+ /** Transitions from DRAINING to COMPLETE if all body bytes have been received. */
+ private fun drainIfComplete() {
+ val offset = headerEndOffset ?: return
+ if (bytesWritten - offset >= expectedContentLength) {
+ _state = State.COMPLETE
+ }
+ }
+
/**
* Parses the buffered data into a structured HTTP request.
*
@@ -194,7 +229,13 @@ class HTTPRequestParser(
fun parseRequest(): ParsedHTTPRequest? = synchronized(lock) {
if (!_state.hasHeaders) return null
- parseError?.let { throw HTTPRequestParseException(it) }
+ // Recoverable errors (e.g. payloadTooLarge — valid headers, rejected
+ // body) are surfaced to the caller so the handler can build a response.
+ // Fatal errors indicate genuinely malformed requests and are thrown,
+ // closing the connection before the handler runs.
+ parseError?.let {
+ if (it.disposition == HTTPRequestParseError.Disposition.FATAL) throw HTTPRequestParseException(it)
+ }
if (parsedHeaders == null) {
val headerData = buffer.read(0, minOf(bytesWritten, MAX_HEADER_SIZE.toLong()).toInt())
@@ -210,7 +251,11 @@ class HTTPRequestParser(
val headers = parsedHeaders ?: return null
- if (_state != State.COMPLETE) {
+ // Return partial (headers only) when the body was rejected or
+ // hasn't fully arrived yet. The payloadTooLarge case goes through
+ // drain mode which discards body bytes without buffering them, so
+ // there is no body to extract even though the state is COMPLETE.
+ if (_state != State.COMPLETE || parseError != null) {
return ParsedHTTPRequest(
method = headers.method,
target = headers.target,
diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/http/HTTPRequestSerializer.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/http/HTTPRequestSerializer.kt
index 5a56493ea..be00ffba2 100644
--- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/http/HTTPRequestSerializer.kt
+++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/http/HTTPRequestSerializer.kt
@@ -7,25 +7,43 @@ enum class HTTPRequestParseError(
/** The HTTP status code that should be sent for this error. */
val httpStatus: Int,
/** A camelCase identifier matching the Swift error case names and JSON fixture keys. */
- val errorId: String
+ val errorId: String,
+ /**
+ * Whether this error aborts the connection ([Disposition.FATAL], thrown so the
+ * handler never runs) or is surfaced to the handler via `pendingParseError`
+ * ([Disposition.RECOVERABLE]) so it can build a response. Only genuinely
+ * recoverable errors — request line and headers well-formed — may be
+ * RECOVERABLE; anything smuggling-relevant (framing, Content-Length) must stay
+ * FATAL so the request never reaches the handler.
+ */
+ val disposition: Disposition
) {
- EMPTY_HEADER_SECTION(400, "emptyHeaderSection"),
- MALFORMED_REQUEST_LINE(400, "malformedRequestLine"),
- OBS_FOLD_DETECTED(400, "obsFoldDetected"),
- WHITESPACE_BEFORE_COLON(400, "whitespaceBeforeColon"),
- INVALID_CONTENT_LENGTH(400, "invalidContentLength"),
- CONFLICTING_CONTENT_LENGTH(400, "conflictingContentLength"),
- UNSUPPORTED_TRANSFER_ENCODING(400, "unsupportedTransferEncoding"),
- INVALID_HTTP_VERSION(400, "invalidHTTPVersion"),
- INVALID_FIELD_NAME(400, "invalidFieldName"),
- INVALID_FIELD_VALUE(400, "invalidFieldValue"),
- MISSING_HOST_HEADER(400, "missingHostHeader"),
- MULTIPLE_HOST_HEADERS(400, "multipleHostHeaders"),
- PAYLOAD_TOO_LARGE(413, "payloadTooLarge"),
- HEADERS_TOO_LARGE(431, "headersTooLarge"),
- TOO_MANY_HEADERS(431, "tooManyHeaders"),
- INVALID_ENCODING(400, "invalidEncoding"),
- BUFFER_IO_ERROR(500, "bufferIOError");
+ EMPTY_HEADER_SECTION(400, "emptyHeaderSection", Disposition.FATAL),
+ MALFORMED_REQUEST_LINE(400, "malformedRequestLine", Disposition.FATAL),
+ OBS_FOLD_DETECTED(400, "obsFoldDetected", Disposition.FATAL),
+ WHITESPACE_BEFORE_COLON(400, "whitespaceBeforeColon", Disposition.FATAL),
+ INVALID_CONTENT_LENGTH(400, "invalidContentLength", Disposition.FATAL),
+ CONFLICTING_CONTENT_LENGTH(400, "conflictingContentLength", Disposition.FATAL),
+ UNSUPPORTED_TRANSFER_ENCODING(400, "unsupportedTransferEncoding", Disposition.FATAL),
+ INVALID_HTTP_VERSION(400, "invalidHTTPVersion", Disposition.FATAL),
+ INVALID_FIELD_NAME(400, "invalidFieldName", Disposition.FATAL),
+ INVALID_FIELD_VALUE(400, "invalidFieldValue", Disposition.FATAL),
+ MISSING_HOST_HEADER(400, "missingHostHeader", Disposition.FATAL),
+ MULTIPLE_HOST_HEADERS(400, "multipleHostHeaders", Disposition.FATAL),
+ PAYLOAD_TOO_LARGE(413, "payloadTooLarge", Disposition.RECOVERABLE),
+ HEADERS_TOO_LARGE(431, "headersTooLarge", Disposition.FATAL),
+ TOO_MANY_HEADERS(431, "tooManyHeaders", Disposition.FATAL),
+ INVALID_ENCODING(400, "invalidEncoding", Disposition.FATAL),
+ BUFFER_IO_ERROR(500, "bufferIOError", Disposition.FATAL);
+
+ /** How the parser disposes of a parse error. */
+ enum class Disposition {
+ /** Abort the connection; the malformed request never reaches the handler. */
+ FATAL,
+
+ /** Surface to the handler via `pendingParseError` so it can build a response. */
+ RECOVERABLE
+ }
}
/**
diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt
index d0d4a411c..76c051dbc 100644
--- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt
+++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt
@@ -58,6 +58,10 @@ data class GBKitGlobal(
val logLevel: String = "warn",
/** Whether to log network requests in the JavaScript console. */
val enableNetworkLogging: Boolean,
+ /** Port the local HTTP server is listening on for native media uploads. */
+ val nativeUploadPort: Int? = null,
+ /** Per-session auth token for requests to the local upload server. */
+ val nativeUploadToken: String? = null,
/** The raw editor settings JSON from the WordPress REST API. */
val editorSettings: JsonElement?,
/** Pre-fetched API responses JSON for faster editor initialization. */
@@ -94,10 +98,14 @@ data class GBKitGlobal(
*
* @param configuration The editor configuration.
* @param dependencies The pre-fetched editor dependencies.
+ * @param nativeUploadPort Port of the local upload server, or null if not running.
+ * @param nativeUploadToken Auth token for the local upload server, or null if not running.
*/
fun fromConfiguration(
configuration: EditorConfiguration,
- dependencies: EditorDependencies?
+ dependencies: EditorDependencies?,
+ nativeUploadPort: Int? = null,
+ nativeUploadToken: String? = null
): GBKitGlobal {
val postId = (configuration.postId?.toInt() ?: -1).takeIf({ it != 0 })
@@ -122,6 +130,8 @@ data class GBKitGlobal(
content = configuration.content.encodeForEditor()
),
enableNetworkLogging = configuration.enableNetworkLogging,
+ nativeUploadPort = nativeUploadPort,
+ nativeUploadToken = nativeUploadToken,
editorSettings = dependencies?.editorSettings?.jsonValue,
preloadData = dependencies?.preloadList?.build(),
editorAssets = dependencies?.assetBundle?.let { bundle ->
diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/HttpRequestTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/HttpRequestTest.kt
new file mode 100644
index 000000000..6aae33ebd
--- /dev/null
+++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/HttpRequestTest.kt
@@ -0,0 +1,47 @@
+package org.wordpress.gutenberg
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class HttpRequestTest {
+
+ private fun request(target: String) = HttpRequest(
+ method = "POST",
+ target = target,
+ headers = emptyMap()
+ )
+
+ @Test
+ fun `path is the whole target when there is no query`() {
+ assertEquals("/upload", request("/upload").path)
+ assertEquals("", request("/upload").query)
+ }
+
+ @Test
+ fun `path and query split on the first question mark`() {
+ val parsed = request("/upload?_embed=wp:featuredmedia")
+ assertEquals("/upload", parsed.path)
+ assertEquals("?_embed=wp:featuredmedia", parsed.query)
+ }
+
+ @Test
+ fun `a trailing question mark yields an empty query`() {
+ val parsed = request("/upload?")
+ assertEquals("/upload", parsed.path)
+ assertEquals("", parsed.query)
+ }
+
+ @Test
+ fun `later question marks belong to the query`() {
+ val parsed = request("/search?q=a?b")
+ assertEquals("/search", parsed.path)
+ assertEquals("?q=a?b", parsed.query)
+ }
+
+ @Test
+ fun `multiple query parameters are preserved`() {
+ val parsed = request("/wp/v2/posts?per_page=10&page=2")
+ assertEquals("/wp/v2/posts", parsed.path)
+ assertEquals("?per_page=10&page=2", parsed.query)
+ }
+}
diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/HttpServerAuthenticationTests.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/HttpServerAuthenticationTests.kt
index 3c0a27ed2..c7d882dc1 100644
--- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/HttpServerAuthenticationTests.kt
+++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/HttpServerAuthenticationTests.kt
@@ -255,6 +255,76 @@ class HttpServerAuthenticationTests {
}
}
+ // Oversized Payloads (auth precedes drain)
+
+ @Test
+ fun `oversized request without token returns 407, not 413`() {
+ val smallServer = oversizedTestServer()
+ try {
+ val conn = oversizedPost(smallServer)
+ try {
+ // Auth is checked on headers alone, before the oversized body is
+ // drained or the handler runs — so the request is rejected with
+ // 407, not answered with the handler's 413. An unauthenticated
+ // client must not be able to make the server read (and discard)
+ // an arbitrarily large body.
+ assertEquals(407, conn.responseCode)
+ } finally {
+ conn.disconnect()
+ }
+ } finally {
+ smallServer.stop()
+ }
+ }
+
+ @Test
+ fun `oversized request with valid token reaches handler as serverError 413`() {
+ val smallServer = oversizedTestServer()
+ try {
+ val conn = oversizedPost(smallServer) {
+ it.setRequestProperty("Proxy-Authorization", "Bearer ${smallServer.token}")
+ }
+ try {
+ assertEquals(413, conn.responseCode)
+ } finally {
+ conn.disconnect()
+ }
+ } finally {
+ smallServer.stop()
+ }
+ }
+
+ /** A server whose 1 KB body limit lets a 2 KB POST exercise the drain path. */
+ private fun oversizedTestServer(): HttpServer {
+ val smallServer = HttpServer(
+ name = "auth-drain-test",
+ externallyAccessible = false,
+ requiresAuthentication = true,
+ maxBodySize = 1024L,
+ handler = { request ->
+ request.serverError?.let { error ->
+ HttpResponse(status = error.httpStatus, body = "too large".toByteArray())
+ } ?: HttpResponse(body = "OK\n".toByteArray())
+ }
+ )
+ smallServer.start()
+ return smallServer
+ }
+
+ /** Sends a 2 KB POST to [smallServer], applying [configure] before writing the body. */
+ private fun oversizedPost(
+ smallServer: HttpServer,
+ configure: (HttpURLConnection) -> Unit = {}
+ ): HttpURLConnection {
+ val conn = URL("http://127.0.0.1:${smallServer.port}/test").openConnection() as HttpURLConnection
+ conn.requestMethod = "POST"
+ configure(conn)
+ conn.doOutput = true
+ conn.setFixedLengthStreamingMode(OVERSIZED_BODY_SIZE)
+ conn.outputStream.use { it.write(ByteArray(OVERSIZED_BODY_SIZE)) }
+ return conn
+ }
+
// Auth Disabled
@Test
@@ -279,4 +349,9 @@ class HttpServerAuthenticationTests {
noAuthServer.stop()
}
}
+
+ companion object {
+ /** Twice the oversized test server's 1 KB `maxBodySize`. */
+ private const val OVERSIZED_BODY_SIZE = 2048
+ }
}
diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt
new file mode 100644
index 000000000..7ce925f0f
--- /dev/null
+++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt
@@ -0,0 +1,630 @@
+package org.wordpress.gutenberg
+
+import com.google.gson.JsonParser
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import java.io.File
+import java.net.Socket
+
+class MediaUploadServerTest {
+
+ @get:Rule
+ val tempFolder = TemporaryFolder()
+
+ private lateinit var server: MediaUploadServer
+
+ @Before
+ fun setUp() {
+ server = MediaUploadServer(uploadDelegate = null, defaultUploader = null, cacheDir = tempFolder.root)
+ }
+
+ @After
+ fun tearDown() {
+ server.stop()
+ }
+
+ // MARK: - Server lifecycle
+
+ @Test
+ fun `starts and provides a port and token`() {
+ assertTrue(server.port > 0)
+ assertTrue(server.token.isNotEmpty())
+ }
+
+ // MARK: - Auth validation
+
+ @Test
+ fun `rejects requests without auth token`() {
+ val response = sendRawRequest(
+ method = "POST",
+ path = "/upload",
+ headers = mapOf("Content-Type" to "text/plain"),
+ body = "hello".toByteArray()
+ )
+
+ assertTrue(response.statusLine.contains("407"))
+ }
+
+ @Test
+ fun `rejects requests with wrong token`() {
+ val response = sendRawRequest(
+ method = "POST",
+ path = "/upload",
+ headers = mapOf(
+ "Relay-Authorization" to "Bearer wrong-token",
+ "Content-Type" to "text/plain"
+ ),
+ body = "hello".toByteArray()
+ )
+
+ assertTrue(response.statusLine.contains("407"))
+ }
+
+ // MARK: - CORS preflight
+
+ @Test
+ fun `responds to OPTIONS preflight with CORS headers`() {
+ val response = sendRawRequest(
+ method = "OPTIONS",
+ path = "/upload",
+ headers = emptyMap(),
+ body = null
+ )
+
+ assertTrue(response.statusLine.contains("204"))
+ assertEquals("*", response.headers["access-control-allow-origin"])
+ assertTrue(response.headers["access-control-allow-methods"]?.contains("POST") == true)
+ assertTrue(response.headers["access-control-allow-headers"]?.contains("Relay-Authorization") == true)
+ }
+
+ // MARK: - Routing
+
+ @Test
+ fun `returns 404 for unknown paths`() {
+ val response = sendRawRequest(
+ method = "GET",
+ path = "/unknown",
+ headers = mapOf("Relay-Authorization" to "Bearer ${server.token}"),
+ body = null
+ )
+
+ assertTrue(response.statusLine.contains("404"))
+ }
+
+ @Test
+ fun `routes upload with a query string and relays the query`() {
+ val delegate = ProcessOnlyDelegate()
+ val mockUploader = MockDefaultUploader()
+ server.stop()
+ server = MediaUploadServer(uploadDelegate = delegate, defaultUploader = mockUploader, cacheDir = tempFolder.root)
+
+ // `@wordpress/media-utils` uploads to `/wp/v2/media?_embed=wp:featuredmedia`,
+ // so the middleware forwards that query on to the native server. Routing must
+ // match on the path alone, and the query must reach WordPress unchanged.
+ val boundary = "test-boundary-query"
+ val body = buildMultipartBody(boundary, "photo.jpg", "image/jpeg", "fake image data".toByteArray())
+
+ val response = sendRawRequest(
+ method = "POST",
+ path = "/upload?_embed=wp:featuredmedia",
+ headers = mapOf(
+ "Relay-Authorization" to "Bearer ${server.token}",
+ "Content-Type" to "multipart/form-data; boundary=$boundary"
+ ),
+ body = body
+ )
+
+ assertTrue("Expected 201 but got: ${response.statusLine}", response.statusLine.contains("201"))
+ // The delegate returns Original, so this is the passthrough branch.
+ // Pin which branch ran — `lastQuery` is recorded by both, so without this
+ // the query assertion would pass even if routing collapsed onto one path.
+ assertTrue(mockUploader.passthroughUploadCalled)
+ assertFalse(mockUploader.uploadCalled)
+ assertEquals("?_embed=wp:featuredmedia", mockUploader.lastQuery)
+ }
+
+ // MARK: - Upload with delegate
+
+ @Test
+ fun `calls delegate processFile and uploadFile`() {
+ val delegate = MockUploadDelegate()
+ server.stop()
+ server = MediaUploadServer(uploadDelegate = delegate, defaultUploader = null, cacheDir = tempFolder.root)
+
+ val boundary = "test-boundary-123"
+ val body = buildMultipartBody(boundary, "photo.jpg", "image/jpeg", "fake image data".toByteArray())
+
+ val response = sendRawRequest(
+ method = "POST",
+ path = "/upload",
+ headers = mapOf(
+ "Relay-Authorization" to "Bearer ${server.token}",
+ "Content-Type" to "multipart/form-data; boundary=$boundary"
+ ),
+ body = body
+ )
+
+ assertTrue("Expected 201 but got: ${response.statusLine}", response.statusLine.contains("201"))
+ assertTrue(delegate.processFileCalled)
+ assertTrue(delegate.uploadFileCalled)
+ assertEquals("image/jpeg", delegate.lastMimeType)
+ assertEquals("photo.jpg", delegate.lastFilename)
+
+ // The server relays WordPress's raw response body verbatim.
+ val json = JsonParser.parseString(response.body).asJsonObject
+ assertEquals(42, json.get("id").asInt)
+ assertEquals("https://example.com/photo.jpg", json.get("source_url").asString)
+ assertEquals("image", json.get("media_type").asString)
+ }
+
+ @Test
+ fun `forwards the delegate's processed metadata to the uploader`() {
+ val delegate = TranscodingDelegate()
+ val mockUploader = MockDefaultUploader()
+ server.stop()
+ server = MediaUploadServer(uploadDelegate = delegate, defaultUploader = mockUploader, cacheDir = tempFolder.root)
+
+ val boundary = "test-boundary-meta"
+ val body = buildMultipartBody(boundary, "clip.mov", "video/quicktime", "movie".toByteArray())
+
+ sendRawRequest(
+ method = "POST",
+ path = "/upload",
+ headers = mapOf(
+ "Relay-Authorization" to "Bearer ${server.token}",
+ "Content-Type" to "multipart/form-data; boundary=$boundary"
+ ),
+ body = body
+ )
+
+ // The delegate changed the format, so the uploader must receive the new
+ // metadata — not the original video/quicktime + clip.mov.
+ assertTrue(mockUploader.uploadCalled)
+ assertEquals("video/mp4", mockUploader.lastUploadMimeType)
+ assertEquals("clip.mp4", mockUploader.lastUploadFilename)
+ }
+
+ @Test
+ fun `deletes the delegate's processed file after upload`() {
+ val delegate = TranscodingDelegate()
+ val mockUploader = MockDefaultUploader()
+ server.stop()
+ server = MediaUploadServer(uploadDelegate = delegate, defaultUploader = mockUploader, cacheDir = tempFolder.root)
+
+ val boundary = "test-boundary-cleanup"
+ val body = buildMultipartBody(boundary, "clip.mov", "video/quicktime", "movie".toByteArray())
+
+ sendRawRequest(
+ method = "POST",
+ path = "/upload",
+ headers = mapOf(
+ "Relay-Authorization" to "Bearer ${server.token}",
+ "Content-Type" to "multipart/form-data; boundary=$boundary"
+ ),
+ body = body
+ )
+
+ // The server owns the file the delegate produced and must delete it once the
+ // upload finishes — the finally in processAndUpload covers success and throw
+ // paths alike. A leaked processed file is a full-size temp per upload.
+ val processed = requireNotNull(delegate.producedFile) { "processFile was not called" }
+ assertFalse("Processed temp file should be deleted after upload", processed.exists())
+ }
+
+ @Test
+ fun `startup sweep deletes stale upload temps but preserves fresh ones`() {
+ val uploadsDir = File(tempFolder.root, "gutenbergkit-uploads").apply { mkdirs() }
+ val stale = File(uploadsDir, "stale.tmp").apply { writeText("x") }
+ val fresh = File(uploadsDir, "fresh.tmp").apply { writeText("y") }
+ // Backdate the stale file well past the 1-hour cutoff.
+ assertTrue(
+ "Could not backdate the stale file",
+ stale.setLastModified(System.currentTimeMillis() - 2 * 60 * 60 * 1000L)
+ )
+
+ // The sweep runs via the injected dispatcher; Unconfined runs it synchronously
+ // so we can assert immediately. It must delete the aged file and keep the fresh
+ // one — a flipped comparison would do the opposite and wipe an in-flight upload.
+ server.stop()
+ server = MediaUploadServer(
+ uploadDelegate = null,
+ defaultUploader = null,
+ cacheDir = tempFolder.root,
+ ioDispatcher = Dispatchers.Unconfined
+ )
+
+ assertFalse("Stale temp should have been swept", stale.exists())
+ assertTrue("Fresh temp should be preserved", fresh.exists())
+ }
+
+ // MARK: - Fallback to default uploader
+
+ @Test
+ fun `uses passthrough when delegate does not modify file`() {
+ val delegate = ProcessOnlyDelegate()
+ val mockUploader = MockDefaultUploader()
+
+ server.stop()
+ server = MediaUploadServer(uploadDelegate = delegate, defaultUploader = mockUploader, cacheDir = tempFolder.root)
+
+ val boundary = "test-boundary-456"
+ val body = buildMultipartBody(boundary, "doc.pdf", "application/pdf", "fake pdf data".toByteArray())
+
+ val response = sendRawRequest(
+ method = "POST",
+ path = "/upload",
+ headers = mapOf(
+ "Relay-Authorization" to "Bearer ${server.token}",
+ "Content-Type" to "multipart/form-data; boundary=$boundary"
+ ),
+ body = body
+ )
+
+ assertTrue("Expected 201 but got: ${response.statusLine}", response.statusLine.contains("201"))
+ assertTrue(delegate.processFileCalled)
+ // Passthrough: original body forwarded directly, not re-encoded.
+ assertTrue(mockUploader.passthroughUploadCalled)
+ assertFalse(mockUploader.uploadCalled)
+
+ val json = JsonParser.parseString(response.body).asJsonObject
+ assertEquals(99, json.get("id").asInt)
+ }
+
+ // MARK: - DefaultMediaUploader
+
+ @Test
+ fun `DefaultMediaUploader relays the WordPress response`() {
+ val mockWpServer = MockWebServer()
+ val wpBody =
+ """{"id":1,"source_url":"https://example.com/u.jpg","media_type":"image"}"""
+ mockWpServer.enqueue(
+ MockResponse()
+ .setResponseCode(201)
+ .setHeader("Content-Type", "application/json")
+ .setBody(wpBody)
+ )
+ mockWpServer.start()
+
+ val wpBaseUrl = mockWpServer.url("/wp-json/").toString()
+ val uploader = DefaultMediaUploader(
+ httpClient = okhttp3.OkHttpClient(),
+ siteApiRoot = wpBaseUrl,
+ authHeader = "Bearer test-token"
+ )
+
+ val file = tempFolder.newFile("image.jpg")
+ file.writeBytes("fake image".toByteArray())
+
+ val response = runBlocking { uploader.upload(file, "image/jpeg", "image.jpg", emptyList(), "") }
+
+ // The uploader relays WordPress's exact status and body — no parsing.
+ assertEquals(201, response.statusCode)
+ assertEquals(wpBody, String(response.body))
+
+ val request = mockWpServer.takeRequest()
+ assertEquals("POST", request.method)
+ assertTrue(request.path!!.contains("wp/v2/media"))
+ assertEquals("Bearer test-token", request.getHeader("Authorization"))
+ assertTrue(request.getHeader("Content-Type")!!.contains("multipart/form-data"))
+
+ mockWpServer.shutdown()
+ }
+
+ @Test
+ fun `DefaultMediaUploader relays a WordPress error response instead of throwing`() {
+ val mockWpServer = MockWebServer()
+ mockWpServer.enqueue(MockResponse().setResponseCode(500).setBody("Internal error"))
+ mockWpServer.start()
+
+ val wpBaseUrl = mockWpServer.url("/wp-json/").toString()
+ val uploader = DefaultMediaUploader(
+ httpClient = okhttp3.OkHttpClient(),
+ siteApiRoot = wpBaseUrl,
+ authHeader = "Bearer test-token"
+ )
+
+ val file = tempFolder.newFile("fail.jpg")
+ file.writeBytes("data".toByteArray())
+
+ // WordPress's error status + body flow through to the editor, which
+ // surfaces the real message — the uploader does not throw.
+ val response = runBlocking { uploader.upload(file, "image/jpeg", "fail.jpg", emptyList(), "") }
+ assertEquals(500, response.statusCode)
+ assertEquals("Internal error", String(response.body))
+
+ mockWpServer.shutdown()
+ }
+
+ @Test
+ fun `DefaultMediaUploader normalizes an unslashed root and namespace`() {
+ val mockWpServer = MockWebServer()
+ mockWpServer.enqueue(MockResponse().setResponseCode(201).setBody("{}"))
+ mockWpServer.start()
+
+ val uploader = DefaultMediaUploader(
+ httpClient = okhttp3.OkHttpClient(),
+ siteApiRoot = mockWpServer.url("/wp-json").toString(), // no trailing slash
+ authHeader = "Bearer test-token",
+ siteApiNamespace = listOf("sites/123") // no trailing slash
+ )
+ val file = tempFolder.newFile("image.jpg")
+ file.writeBytes("x".toByteArray())
+
+ runBlocking { uploader.upload(file, "image/jpeg", "image.jpg", emptyList(), "") }
+
+ assertEquals("/wp-json/wp/v2/sites/123/media", mockWpServer.takeRequest().path)
+
+ mockWpServer.shutdown()
+ }
+
+ @Test
+ fun `DefaultMediaUploader re-encode preserves extra parts and query`() {
+ val mockWpServer = MockWebServer()
+ mockWpServer.enqueue(MockResponse().setResponseCode(201).setBody("{}"))
+ mockWpServer.start()
+
+ val uploader = DefaultMediaUploader(
+ httpClient = okhttp3.OkHttpClient(),
+ siteApiRoot = mockWpServer.url("/wp-json/").toString(),
+ authHeader = "Bearer test-token"
+ )
+ val file = tempFolder.newFile("image.jpg")
+ file.writeBytes("fake image".toByteArray())
+
+ val postPart = org.wordpress.gutenberg.http.MultipartPart(
+ name = "post",
+ filename = null,
+ contentType = "text/plain",
+ body = org.wordpress.gutenberg.http.RequestBody.InMemory("123".toByteArray())
+ )
+
+ runBlocking {
+ uploader.upload(file, "image/jpeg", "image.jpg", listOf(postPart), "?_embed=wp:featuredmedia")
+ }
+
+ val request = mockWpServer.takeRequest()
+ // The query and the non-file part must both reach WordPress.
+ assertTrue(request.path!!.contains("_embed"))
+ val bodyText = request.body.readUtf8()
+ assertTrue("Expected post field in multipart body", bodyText.contains("name=\"post\""))
+ assertTrue(bodyText.contains("123"))
+
+ mockWpServer.shutdown()
+ }
+
+ @Test
+ fun `re-encode forwards a non-UTF-8 field value verbatim`() {
+ val mockWpServer = MockWebServer()
+ mockWpServer.enqueue(MockResponse().setResponseCode(201).setBody("{}"))
+ mockWpServer.start()
+
+ val uploader = DefaultMediaUploader(
+ httpClient = okhttp3.OkHttpClient(),
+ siteApiRoot = mockWpServer.url("/wp-json/").toString(),
+ authHeader = "Bearer test-token"
+ )
+ val file = tempFolder.newFile("image.jpg")
+ file.writeBytes("fake image".toByteArray())
+
+ // A value that is not valid UTF-8 (a lone 0xFF byte between two ASCII bytes).
+ val binaryValue = byteArrayOf(0x61, 0xFF.toByte(), 0x62)
+ val blobPart = org.wordpress.gutenberg.http.MultipartPart(
+ name = "blob",
+ filename = null,
+ contentType = "application/octet-stream",
+ body = org.wordpress.gutenberg.http.RequestBody.InMemory(binaryValue)
+ )
+
+ runBlocking {
+ uploader.upload(file, "image/jpeg", "image.jpg", listOf(blobPart), "")
+ }
+
+ // The raw 0xFF byte survives verbatim — not coerced to a replacement char.
+ val bodyBytes = mockWpServer.takeRequest().body.readByteArray()
+ val found = bodyBytes.toList().windowed(binaryValue.size).any { it == binaryValue.toList() }
+ assertTrue("Non-UTF-8 field value should pass through verbatim", found)
+
+ mockWpServer.shutdown()
+ }
+
+ // MARK: - Bad request handling
+
+ @Test
+ fun `rejects upload without content type`() {
+ val response = sendRawRequest(
+ method = "POST",
+ path = "/upload",
+ headers = mapOf("Relay-Authorization" to "Bearer ${server.token}"),
+ body = "not multipart".toByteArray()
+ )
+
+ assertTrue(response.statusLine.contains("400"))
+ }
+
+ @Test
+ fun `rejects upload with non-multipart content type`() {
+ val response = sendRawRequest(
+ method = "POST",
+ path = "/upload",
+ headers = mapOf(
+ "Relay-Authorization" to "Bearer ${server.token}",
+ "Content-Type" to "application/json"
+ ),
+ body = """{"key": "value"}""".toByteArray()
+ )
+
+ assertTrue(response.statusLine.contains("400"))
+ }
+
+ // MARK: - Helpers
+
+ private data class RawHttpResponse(
+ val statusLine: String,
+ val headers: Map,
+ val body: String
+ )
+
+ private fun sendRawRequest(
+ method: String,
+ path: String,
+ headers: Map,
+ body: ByteArray?
+ ): RawHttpResponse {
+ val socket = Socket("127.0.0.1", server.port)
+ socket.soTimeout = 5000
+
+ val output = socket.getOutputStream()
+ val request = buildString {
+ append("$method $path HTTP/1.1\r\n")
+ append("Host: 127.0.0.1:${server.port}\r\n")
+ for ((key, value) in headers) {
+ append("$key: $value\r\n")
+ }
+ if (body != null) {
+ append("Content-Length: ${body.size}\r\n")
+ }
+ append("Connection: close\r\n")
+ append("\r\n")
+ }
+
+ output.write(request.toByteArray())
+ if (body != null) {
+ output.write(body)
+ }
+ output.flush()
+
+ val responseBytes = socket.getInputStream().readBytes()
+ socket.close()
+
+ val responseString = String(responseBytes, Charsets.UTF_8)
+ val headerEnd = responseString.indexOf("\r\n\r\n")
+ if (headerEnd < 0) {
+ return RawHttpResponse(responseString, emptyMap(), "")
+ }
+
+ val headerSection = responseString.substring(0, headerEnd)
+ val responseBody = responseString.substring(headerEnd + 4)
+ val lines = headerSection.split("\r\n")
+ val statusLine = lines.first()
+
+ val responseHeaders = mutableMapOf()
+ for (line in lines.drop(1)) {
+ val colonIndex = line.indexOf(':')
+ if (colonIndex > 0) {
+ val key = line.substring(0, colonIndex).trim().lowercase()
+ val value = line.substring(colonIndex + 1).trim()
+ responseHeaders[key] = value
+ }
+ }
+
+ return RawHttpResponse(statusLine, responseHeaders, responseBody)
+ }
+
+ private fun buildMultipartBody(
+ boundary: String,
+ filename: String,
+ mimeType: String,
+ data: ByteArray
+ ): ByteArray {
+ val out = java.io.ByteArrayOutputStream()
+ out.write("--$boundary\r\n".toByteArray())
+ out.write("Content-Disposition: form-data; name=\"file\"; filename=\"$filename\"\r\n".toByteArray())
+ out.write("Content-Type: $mimeType\r\n\r\n".toByteArray())
+ out.write(data)
+ out.write("\r\n--$boundary--\r\n".toByteArray())
+ return out.toByteArray()
+ }
+
+ // MARK: - Mocks
+
+ private class MockUploadDelegate : MediaUploadDelegate {
+ @Volatile var processFileCalled = false
+ @Volatile var uploadFileCalled = false
+ @Volatile var lastMimeType: String? = null
+ @Volatile var lastFilename: String? = null
+
+ override suspend fun processFile(file: File, mimeType: String, filename: String): ProcessedProxyFile {
+ processFileCalled = true
+ lastMimeType = mimeType
+ return ProcessedProxyFile.Original
+ }
+
+ override suspend fun uploadFile(file: File, mimeType: String, filename: String): MediaUploadResponse? {
+ uploadFileCalled = true
+ lastFilename = filename
+ val json = """{"id":42,"source_url":"https://example.com/photo.jpg","media_type":"image"}"""
+ return MediaUploadResponse(201, json.toByteArray())
+ }
+ }
+
+ private class ProcessOnlyDelegate : MediaUploadDelegate {
+ @Volatile var processFileCalled = false
+
+ override suspend fun processFile(file: File, mimeType: String, filename: String): ProcessedProxyFile {
+ processFileCalled = true
+ return ProcessedProxyFile.Original
+ }
+ }
+
+ /** A delegate that produces a new file with changed metadata (e.g. a transcode). */
+ private class TranscodingDelegate : MediaUploadDelegate {
+ /** The processed file this delegate wrote, for cleanup assertions. */
+ @Volatile var producedFile: File? = null
+
+ override suspend fun processFile(file: File, mimeType: String, filename: String): ProcessedProxyFile {
+ val newFile = File(file.parentFile, "processed-${file.name}")
+ newFile.writeBytes("processed".toByteArray())
+ producedFile = newFile
+ return ProcessedProxyFile.Processed(newFile, "video/mp4", "clip.mp4")
+ }
+ }
+
+ private class MockDefaultUploader : DefaultMediaUploader(
+ httpClient = okhttp3.OkHttpClient(),
+ siteApiRoot = "https://example.com/wp-json/",
+ authHeader = "Bearer mock"
+ ) {
+ @Volatile var uploadCalled = false
+ @Volatile var passthroughUploadCalled = false
+ @Volatile var lastUploadMimeType: String? = null
+ @Volatile var lastUploadFilename: String? = null
+ @Volatile var lastQuery: String? = null
+
+ override suspend fun upload(
+ file: File, mimeType: String, filename: String,
+ extraParts: List, query: String
+ ): MediaUploadResponse {
+ uploadCalled = true
+ lastUploadMimeType = mimeType
+ lastUploadFilename = filename
+ lastQuery = query
+ return mockResponse()
+ }
+
+ override suspend fun passthroughUpload(
+ body: org.wordpress.gutenberg.http.RequestBody,
+ contentType: String,
+ query: String
+ ): MediaUploadResponse {
+ passthroughUploadCalled = true
+ lastQuery = query
+ return mockResponse()
+ }
+
+ private fun mockResponse() = MediaUploadResponse(
+ 201,
+ """{"id":99,"source_url":"https://example.com/doc.pdf","media_type":"file"}""".toByteArray()
+ )
+ }
+
+}
diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RestUrlBuilderTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RestUrlBuilderTest.kt
new file mode 100644
index 000000000..73065c5fb
--- /dev/null
+++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RestUrlBuilderTest.kt
@@ -0,0 +1,47 @@
+package org.wordpress.gutenberg
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class RestUrlBuilderTest {
+
+ @Test
+ fun `appends the path when no namespace is configured`() {
+ assertEquals(
+ "https://example.com/wp-json/wp/v2/media",
+ RestUrlBuilder.namespaced("https://example.com/wp-json", null, "/wp/v2/media")
+ )
+ }
+
+ @Test
+ fun `inserts the namespace after the version segment`() {
+ assertEquals(
+ "https://example.com/wp-json/wp/v2/sites/123/media",
+ RestUrlBuilder.namespaced("https://example.com/wp-json", "sites/123/", "/wp/v2/media")
+ )
+ }
+
+ @Test
+ fun `normalizes an unslashed root and namespace`() {
+ assertEquals(
+ "https://example.com/wp-json/wp/v2/sites/123/media",
+ RestUrlBuilder.namespaced("https://example.com/wp-json", "sites/123", "/wp/v2/media")
+ )
+ }
+
+ @Test
+ fun `does not double the slash when the root already ends in one`() {
+ assertEquals(
+ "https://example.com/wp-json/wp/v2/sites/123/media",
+ RestUrlBuilder.namespaced("https://example.com/wp-json/", "sites/123", "/wp/v2/media")
+ )
+ }
+
+ @Test
+ fun `inserts the namespace after a non-wp-v2 version segment`() {
+ assertEquals(
+ "https://example.com/wp-json/wp-block-editor/v1/sites/123/settings",
+ RestUrlBuilder.namespaced("https://example.com/wp-json", "sites/123", "/wp-block-editor/v1/settings")
+ )
+ }
+}
diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/http/FixtureTests.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/http/FixtureTests.kt
index a08e4d373..d7459e684 100644
--- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/http/FixtureTests.kt
+++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/http/FixtureTests.kt
@@ -149,13 +149,34 @@ class FixtureTests {
try {
parser.parseRequest()
- fail("$description: expected error $expectedError but parsing succeeded")
+ // Non-fatal errors (e.g., payloadTooLarge) are exposed via
+ // pendingParseError instead of being thrown.
+ val pendingError = parser.pendingParseError
+ if (pendingError != null) {
+ assertEquals(
+ expectedError,
+ pendingError.errorId,
+ "$description: expected $expectedError but got ${pendingError.errorId}"
+ )
+ assertEquals(
+ HTTPRequestParseError.Disposition.RECOVERABLE,
+ pendingError.disposition,
+ "$description: ${pendingError.errorId} surfaced via pendingParseError but is not RECOVERABLE"
+ )
+ } else {
+ fail("$description: expected error $expectedError but parsing succeeded")
+ }
} catch (e: HTTPRequestParseException) {
assertEquals(
expectedError,
e.error.errorId,
"$description: expected $expectedError but got ${e.error.errorId}"
)
+ assertEquals(
+ HTTPRequestParseError.Disposition.FATAL,
+ e.error.disposition,
+ "$description: ${e.error.errorId} was thrown but is not FATAL"
+ )
}
}
}
diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/http/HTTPRequestParserTests.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/http/HTTPRequestParserTests.kt
index 93bdefa35..060a211e5 100644
--- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/http/HTTPRequestParserTests.kt
+++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/http/HTTPRequestParserTests.kt
@@ -15,6 +15,21 @@ import org.junit.Test
*/
class HTTPRequestParserTests {
+ // MARK: - Error Disposition
+
+ /**
+ * Locks the fatal/recoverable classification so a refactor can't silently
+ * make a smuggling-relevant error recoverable — which would let a malformed
+ * request reach the handler before auth.
+ */
+ @Test
+ fun `only payloadTooLarge is recoverable`() {
+ val recoverable = HTTPRequestParseError.entries.filter {
+ it.disposition == HTTPRequestParseError.Disposition.RECOVERABLE
+ }
+ assertEquals(listOf(HTTPRequestParseError.PAYLOAD_TOO_LARGE), recoverable)
+ }
+
// MARK: - Duplicate Header Key Casing (Internal Dict Representation)
@Test
@@ -97,6 +112,69 @@ class HTTPRequestParserTests {
assertArrayEquals(body.toByteArray(), request.body?.readBytes())
}
+ @Test
+ fun `drains oversized body and returns partial with parseError`() {
+ val parser = HTTPRequestParser(maxBodySize = 100)
+ parser.append("POST /upload HTTP/1.1\r\nHost: localhost\r\nContent-Length: 101\r\n\r\n".toByteArray())
+
+ // Parser enters drain mode — not yet complete.
+ assertEquals(HTTPRequestParser.State.DRAINING, parser.state)
+
+ // Feed the remaining body bytes to complete the drain.
+ parser.append(ByteArray(101) { 0x41 })
+ assertTrue(parser.state.isComplete)
+
+ // parseRequest() returns partial headers instead of throwing.
+ val request = parser.parseRequest()!!
+ assertEquals("POST", request.method)
+ assertEquals("/upload", request.target)
+ assertFalse(request.isComplete)
+ assertEquals(HTTPRequestParseError.PAYLOAD_TOO_LARGE, parser.pendingParseError)
+ }
+
+ @Test
+ fun `enters drain mode for oversized Content-Length even when body has not arrived`() {
+ val parser = HTTPRequestParser(maxBodySize = 50)
+ parser.append("POST /upload HTTP/1.1\r\nHost: localhost\r\nContent-Length: 999999\r\n\r\n".toByteArray())
+
+ // Parser enters drain mode — headers are available but not yet complete.
+ assertEquals(HTTPRequestParser.State.DRAINING, parser.state)
+ assertTrue(parser.state.hasHeaders)
+ assertFalse(parser.state.isComplete)
+
+ // Feed body bytes in chunks to complete the drain.
+ val chunkSize = 8192
+ var remaining = 999999
+ while (remaining > 0) {
+ val size = minOf(chunkSize, remaining)
+ parser.append(ByteArray(size) { 0x42 })
+ remaining -= size
+ }
+
+ assertTrue(parser.state.isComplete)
+ val request = parser.parseRequest()!!
+ assertEquals("POST", request.method)
+ assertFalse(request.isComplete)
+ assertEquals(HTTPRequestParseError.PAYLOAD_TOO_LARGE, parser.pendingParseError)
+ }
+
+ @Test
+ fun `drain mode does not buffer body bytes`() {
+ val parser = HTTPRequestParser(maxBodySize = 10)
+ parser.append("POST /upload HTTP/1.1\r\nHost: localhost\r\nContent-Length: 1000\r\n\r\n".toByteArray())
+ assertEquals(HTTPRequestParser.State.DRAINING, parser.state)
+
+ // Feed 1000 bytes of body data.
+ parser.append(ByteArray(1000) { 0x43 })
+ assertTrue(parser.state.isComplete)
+
+ // parseRequest() returns headers; error is on pendingParseError.
+ val request = parser.parseRequest()!!
+ assertEquals("POST", request.method)
+ assertFalse(request.isComplete)
+ assertEquals(HTTPRequestParseError.PAYLOAD_TOO_LARGE, parser.pendingParseError)
+ }
+
// MARK: - Error HTTP Status Mapping
@Test
diff --git a/android/app/detekt-baseline.xml b/android/app/detekt-baseline.xml
index ab0289fb0..786583b22 100644
--- a/android/app/detekt-baseline.xml
+++ b/android/app/detekt-baseline.xml
@@ -2,13 +2,13 @@
- LongMethod:EditorActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun EditorScreen( configuration: EditorConfiguration, dependencies: EditorDependencies? = null, accountId: ULong? = null, coroutineScope: CoroutineScope, onClose: () -> Unit, onGutenbergViewCreated: (GutenbergView) -> Unit = {} )
+ LongMethod:EditorActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun EditorScreen( configuration: EditorConfiguration, dependencies: EditorDependencies? = null, accountId: ULong? = null, enableNativeMediaUpload: Boolean = true, coroutineScope: CoroutineScope, onClose: () -> Unit, onGutenbergViewCreated: (GutenbergView) -> Unit = {} )
LongMethod:MainActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen( configurations: List<ConfigurationItem>, onConfigurationClick: (ConfigurationItem) -> Unit, onConfigurationLongClick: (ConfigurationItem) -> Boolean, onAddConfiguration: (String) -> Unit, onDeleteConfiguration: (ConfigurationItem) -> Unit, onMediaProxyServer: () -> Unit = {}, isDiscoveringSite: Boolean = false, onDismissDiscovering: () -> Unit = {}, isLoadingCapabilities: Boolean = false, authError: String? = null, onDismissAuthError: () -> Unit = {} )
LongMethod:MediaProxyServerActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun MediaProxyServerScreen(onBack: () -> Unit)
LongMethod:PostsListActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun PostsListScreen( viewModel: PostsListViewModel, onClose: () -> Unit, onPostSelected: (AnyPostWithEditContext) -> Unit )
- LongMethod:SitePreparationActivity.kt$@Composable private fun FeatureConfigurationCard( enableNativeInserter: Boolean, onEnableNativeInserterChange: (Boolean) -> Unit, enableInserterMediaStrip: Boolean, onEnableInserterMediaStripChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, postTypes: List<PostTypeDetails>, selectedPostType: PostTypeDetails?, onPostTypeChange: (PostTypeDetails) -> Unit, showBrowseButton: Boolean = false, onBrowsePosts: () -> Unit = {} )
+ LongMethod:SitePreparationActivity.kt$@Composable private fun FeatureConfigurationCard( enableNativeInserter: Boolean, onEnableNativeInserterChange: (Boolean) -> Unit, enableInserterMediaStrip: Boolean, onEnableInserterMediaStripChange: (Boolean) -> Unit, enableNativeMediaUpload: Boolean, onEnableNativeMediaUploadChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, postTypes: List<PostTypeDetails>, selectedPostType: PostTypeDetails?, onPostTypeChange: (PostTypeDetails) -> Unit, showBrowseButton: Boolean = false, onBrowsePosts: () -> Unit = {} )
LongParameterList:MainActivity.kt$( configurations: List<ConfigurationItem>, onConfigurationClick: (ConfigurationItem) -> Unit, onConfigurationLongClick: (ConfigurationItem) -> Boolean, onAddConfiguration: (String) -> Unit, onDeleteConfiguration: (ConfigurationItem) -> Unit, onMediaProxyServer: () -> Unit = {}, isDiscoveringSite: Boolean = false, onDismissDiscovering: () -> Unit = {}, isLoadingCapabilities: Boolean = false, authError: String? = null, onDismissAuthError: () -> Unit = {} )
- LongParameterList:SitePreparationActivity.kt$( enableNativeInserter: Boolean, onEnableNativeInserterChange: (Boolean) -> Unit, enableInserterMediaStrip: Boolean, onEnableInserterMediaStripChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, postTypes: List<PostTypeDetails>, selectedPostType: PostTypeDetails?, onPostTypeChange: (PostTypeDetails) -> Unit, showBrowseButton: Boolean = false, onBrowsePosts: () -> Unit = {} )
+ LongParameterList:SitePreparationActivity.kt$( enableNativeInserter: Boolean, onEnableNativeInserterChange: (Boolean) -> Unit, enableInserterMediaStrip: Boolean, onEnableInserterMediaStripChange: (Boolean) -> Unit, enableNativeMediaUpload: Boolean, onEnableNativeMediaUploadChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, postTypes: List<PostTypeDetails>, selectedPostType: PostTypeDetails?, onPostTypeChange: (PostTypeDetails) -> Unit, showBrowseButton: Boolean = false, onBrowsePosts: () -> Unit = {} )
MaxLineLength:MediaProxyServerActivity.kt$Text("Size", fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f))
MaxLineLength:MediaProxyServerActivity.kt$Text("Throughput", fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f))
MaxLineLength:MediaProxyServerActivity.kt$Text("Time", fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f))
diff --git a/android/app/src/main/java/com/example/gutenbergkit/DemoMediaUploadDelegate.kt b/android/app/src/main/java/com/example/gutenbergkit/DemoMediaUploadDelegate.kt
new file mode 100644
index 000000000..9524bb278
--- /dev/null
+++ b/android/app/src/main/java/com/example/gutenbergkit/DemoMediaUploadDelegate.kt
@@ -0,0 +1,109 @@
+package com.example.gutenbergkit
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Matrix
+import android.media.ExifInterface
+import android.util.Log
+import org.wordpress.gutenberg.MediaUploadDelegate
+import org.wordpress.gutenberg.ProcessedProxyFile
+import java.io.File
+import java.io.IOException
+
+/**
+ * Demo media upload delegate that resizes images to a maximum dimension of 2000px.
+ *
+ * Only overrides [processFile] — [uploadFile] returns null so the default uploader is used.
+ */
+class DemoMediaUploadDelegate : MediaUploadDelegate {
+ companion object {
+ private const val TAG = "DemoMediaUploadDelegate"
+ }
+
+ override suspend fun processFile(file: File, mimeType: String, filename: String): ProcessedProxyFile {
+ if (!mimeType.startsWith("image/") || mimeType == "image/gif") {
+ return ProcessedProxyFile.Original
+ }
+
+ val maxDimension = 2000
+
+ val options = BitmapFactory.Options().apply {
+ inJustDecodeBounds = true
+ }
+ BitmapFactory.decodeFile(file.absolutePath, options)
+
+ val width = options.outWidth
+ val height = options.outHeight
+ if (width <= 0 || height <= 0) return ProcessedProxyFile.Original
+
+ val longestSide = maxOf(width, height)
+ if (longestSide <= maxDimension) return ProcessedProxyFile.Original
+
+ // Calculate sample size for memory-efficient decoding
+ val sampleSize = Integer.highestOneBit(longestSide / maxDimension)
+ val decodeOptions = BitmapFactory.Options().apply {
+ inSampleSize = sampleSize
+ }
+ val sampled = BitmapFactory.decodeFile(file.absolutePath, decodeOptions) ?: return ProcessedProxyFile.Original
+
+ // Scale to exact target dimensions
+ val scale = maxDimension.toFloat() / longestSide.toFloat()
+ val targetWidth = (width * scale).toInt()
+ val targetHeight = (height * scale).toInt()
+ val scaled = Bitmap.createScaledBitmap(sampled, targetWidth, targetHeight, true)
+ if (scaled !== sampled) sampled.recycle()
+
+ // Bake the EXIF orientation into the pixels. Re-encoding via compress()
+ // writes no EXIF, so without this a portrait photo (stored landscape plus
+ // an orientation tag) would upload rotated.
+ val oriented = applyExifOrientation(scaled, file)
+
+ // Re-encoding normalizes everything but PNG to JPEG (Bitmap.compress can't
+ // round-trip WebP/HEIC/etc.), so report the ACTUAL output type and extension.
+ // Otherwise a WebP/HEIC upload would be JPEG bytes labeled image/webp, and
+ // WordPress would reject the content/extension mismatch.
+ val (format, outputMimeType, outputExtension) =
+ if (mimeType == "image/png") {
+ Triple(Bitmap.CompressFormat.PNG, "image/png", "png")
+ } else {
+ Triple(Bitmap.CompressFormat.JPEG, "image/jpeg", "jpg")
+ }
+
+ val outputFile = File(file.parent, "resized-${file.name}")
+ outputFile.outputStream().use { out ->
+ oriented.compress(format, 85, out)
+ }
+ oriented.recycle()
+
+ val outputFilename = filename.substringBeforeLast('.', filename) + ".$outputExtension"
+ Log.d(TAG, "Resized image from ${width}×${height} to ${targetWidth}×${targetHeight}")
+ return ProcessedProxyFile.Processed(outputFile, outputMimeType, outputFilename)
+ }
+
+ private fun applyExifOrientation(bitmap: Bitmap, sourceFile: File): Bitmap {
+ val orientation = try {
+ ExifInterface(sourceFile.absolutePath).getAttributeInt(
+ ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL
+ )
+ } catch (e: IOException) {
+ Log.w(TAG, "Failed to read EXIF orientation", e)
+ ExifInterface.ORIENTATION_NORMAL
+ }
+
+ val matrix = Matrix()
+ when (orientation) {
+ ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
+ ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
+ ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
+ ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
+ ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
+ ExifInterface.ORIENTATION_TRANSPOSE -> matrix.apply { postRotate(90f); postScale(-1f, 1f) }
+ ExifInterface.ORIENTATION_TRANSVERSE -> matrix.apply { postRotate(270f); postScale(-1f, 1f) }
+ else -> return bitmap
+ }
+
+ val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
+ if (rotated !== bitmap) bitmap.recycle()
+ return rotated
+ }
+}
diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt
index 213b75ab7..20f3e84b2 100644
--- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt
+++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt
@@ -64,6 +64,7 @@ class EditorActivity : ComponentActivity() {
companion object {
const val EXTRA_DEPENDENCIES_PATH = "dependencies_path"
const val EXTRA_ACCOUNT_ID = "account_id"
+ const val EXTRA_ENABLE_NATIVE_MEDIA_UPLOAD = "enable_native_media_upload"
}
private var gutenbergView: GutenbergView? = null
@@ -104,12 +105,15 @@ class EditorActivity : ComponentActivity() {
// Optional account ID for REST API persistence (set when launched from PostsListActivity)
val accountId = intent.getLongExtra(EXTRA_ACCOUNT_ID, -1L).takeIf { it >= 0 }?.toULong()
+ val enableNativeMediaUpload = intent.getBooleanExtra(EXTRA_ENABLE_NATIVE_MEDIA_UPLOAD, true)
+
setContent {
AppTheme {
EditorScreen(
configuration = configuration,
dependencies = dependencies,
accountId = accountId,
+ enableNativeMediaUpload = enableNativeMediaUpload,
coroutineScope = this.lifecycleScope,
onClose = { finish() },
onGutenbergViewCreated = { view ->
@@ -134,6 +138,7 @@ fun EditorScreen(
configuration: EditorConfiguration,
dependencies: EditorDependencies? = null,
accountId: ULong? = null,
+ enableNativeMediaUpload: Boolean = true,
coroutineScope: CoroutineScope,
onClose: () -> Unit,
onGutenbergViewCreated: (GutenbergView) -> Unit = {}
@@ -332,6 +337,9 @@ fun EditorScreen(
return null
}
})
+ if (enableNativeMediaUpload) {
+ mediaUploadDelegate = DemoMediaUploadDelegate()
+ }
onGutenbergViewCreated(this)
}
},
diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt
index fa5197eae..416b0aa90 100644
--- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt
+++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt
@@ -151,8 +151,8 @@ class SitePreparationActivity : ComponentActivity() {
viewModel = viewModel,
accountId = accountId,
onClose = { finish() },
- onStartEditor = { configuration, dependencies ->
- launchEditor(configuration, dependencies)
+ onStartEditor = { configuration, dependencies, enableNativeMediaUpload ->
+ launchEditor(configuration, dependencies, enableNativeMediaUpload)
},
onBrowsePosts = { configuration, dependencies, postType ->
accountId?.let {
@@ -166,10 +166,12 @@ class SitePreparationActivity : ComponentActivity() {
private fun launchEditor(
configuration: EditorConfiguration,
- dependencies: EditorDependencies?
+ dependencies: EditorDependencies?,
+ enableNativeMediaUpload: Boolean
) {
val intent = Intent(this, EditorActivity::class.java).apply {
putExtra(MainActivity.EXTRA_CONFIGURATION, configuration)
+ putExtra(EditorActivity.EXTRA_ENABLE_NATIVE_MEDIA_UPLOAD, enableNativeMediaUpload)
// Serialize dependencies to disk and pass the file path
if (dependencies != null) {
@@ -198,7 +200,7 @@ fun SitePreparationScreen(
viewModel: SitePreparationViewModel,
accountId: ULong?,
onClose: () -> Unit,
- onStartEditor: (EditorConfiguration, EditorDependencies?) -> Unit,
+ onStartEditor: (EditorConfiguration, EditorDependencies?, Boolean) -> Unit,
onBrowsePosts: (EditorConfiguration, EditorDependencies?, PostTypeDetails) -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
@@ -225,7 +227,7 @@ fun SitePreparationScreen(
Button(
onClick = {
viewModel.buildConfiguration()?.let { config ->
- onStartEditor(config, uiState.editorDependencies)
+ onStartEditor(config, uiState.editorDependencies, uiState.enableNativeMediaUpload)
}
},
modifier = Modifier.padding(end = 8.dp)
@@ -301,6 +303,8 @@ private fun LoadedView(
onEnableNativeInserterChange = viewModel::setEnableNativeInserter,
enableInserterMediaStrip = uiState.enableInserterMediaStrip,
onEnableInserterMediaStripChange = viewModel::setEnableInserterMediaStrip,
+ enableNativeMediaUpload = uiState.enableNativeMediaUpload,
+ onEnableNativeMediaUploadChange = viewModel::setEnableNativeMediaUpload,
enableNetworkLogging = uiState.enableNetworkLogging,
onEnableNetworkLoggingChange = viewModel::setEnableNetworkLogging,
postTypes = uiState.postTypes,
@@ -387,6 +391,8 @@ private fun FeatureConfigurationCard(
onEnableNativeInserterChange: (Boolean) -> Unit,
enableInserterMediaStrip: Boolean,
onEnableInserterMediaStripChange: (Boolean) -> Unit,
+ enableNativeMediaUpload: Boolean,
+ onEnableNativeMediaUploadChange: (Boolean) -> Unit,
enableNetworkLogging: Boolean,
onEnableNetworkLoggingChange: (Boolean) -> Unit,
postTypes: List,
@@ -434,6 +440,21 @@ private fun FeatureConfigurationCard(
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+ // Enable Native Media Upload Toggle
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text("Enable Native Media Upload")
+ Switch(
+ checked = enableNativeMediaUpload,
+ onCheckedChange = onEnableNativeMediaUploadChange
+ )
+ }
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+
// Enable Network Logging Toggle
Row(
modifier = Modifier.fillMaxWidth(),
diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt
index 360b08af0..fec2d7e02 100644
--- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt
+++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt
@@ -21,6 +21,7 @@ import uniffi.wp_api.PostType as WpPostType
data class SitePreparationUiState(
val enableNativeInserter: Boolean = false,
val enableInserterMediaStrip: Boolean = false,
+ val enableNativeMediaUpload: Boolean = true,
val enableNetworkLogging: Boolean = false,
/** All viewable post types fetched from the site, or empty while loading. */
val postTypes: List = emptyList(),
@@ -90,6 +91,10 @@ class SitePreparationViewModel(
_uiState.update { it.copy(enableInserterMediaStrip = enabled) }
}
+ fun setEnableNativeMediaUpload(enabled: Boolean) {
+ _uiState.update { it.copy(enableNativeMediaUpload = enabled) }
+ }
+
fun setEnableNetworkLogging(enabled: Boolean) {
_uiState.update { it.copy(enableNetworkLogging = enabled) }
}
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index 519415f38..80aa59dee 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -25,6 +25,7 @@ okhttp = "4.12.0"
detekt = "1.23.8"
buildkite-test-collector = "0.4.0"
androidsvg = "1.4"
+json = "20240303"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -59,6 +60,7 @@ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhtt
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" }
buildkite-test-collector-instrumented = { group = "com.buildkite.test-collector-android", name = "instrumented-test-collector", version.ref = "buildkite-test-collector" }
androidsvg = { group = "com.caverock", name = "androidsvg-aar", version.ref = "androidsvg" }
+json = { group = "org.json", name = "json", version.ref = "json" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
diff --git a/ios/Demo-iOS/Sources/ConfigurationItem.swift b/ios/Demo-iOS/Sources/ConfigurationItem.swift
index 88eb12489..e98392ae2 100644
--- a/ios/Demo-iOS/Sources/ConfigurationItem.swift
+++ b/ios/Demo-iOS/Sources/ConfigurationItem.swift
@@ -35,11 +35,13 @@ struct RunnableEditor: Equatable, Hashable {
let configuration: EditorConfiguration
let dependencies: EditorDependencies?
let apiClient: WordPressAPI?
+ var enableNativeMediaUpload: Bool = true
- init(configuration: EditorConfiguration, dependencies: EditorDependencies?, apiClient: WordPressAPI? = nil) {
+ init(configuration: EditorConfiguration, dependencies: EditorDependencies?, apiClient: WordPressAPI? = nil, enableNativeMediaUpload: Bool = true) {
self.configuration = configuration
self.dependencies = dependencies
self.apiClient = apiClient
+ self.enableNativeMediaUpload = enableNativeMediaUpload
}
// `apiClient` is intentionally excluded from `==` and `hash(into:)`:
@@ -47,12 +49,13 @@ struct RunnableEditor: Equatable, Hashable {
// and two editors with the same configuration but different client
// instances should be treated as equal for navigation/identity purposes.
static func == (lhs: RunnableEditor, rhs: RunnableEditor) -> Bool {
- lhs.configuration == rhs.configuration && lhs.dependencies == rhs.dependencies
+ lhs.configuration == rhs.configuration && lhs.dependencies == rhs.dependencies && lhs.enableNativeMediaUpload == rhs.enableNativeMediaUpload
}
func hash(into hasher: inout Hasher) {
hasher.combine(configuration)
hasher.combine(dependencies)
+ hasher.combine(enableNativeMediaUpload)
}
}
diff --git a/ios/Demo-iOS/Sources/GutenbergApp.swift b/ios/Demo-iOS/Sources/GutenbergApp.swift
index 3a741f03f..ee3b92386 100644
--- a/ios/Demo-iOS/Sources/GutenbergApp.swift
+++ b/ios/Demo-iOS/Sources/GutenbergApp.swift
@@ -59,7 +59,8 @@ struct GutenbergApp: App {
EditorView(
configuration: editor.configuration,
dependencies: editor.dependencies,
- apiClient: editor.apiClient
+ apiClient: editor.apiClient,
+ enableNativeMediaUpload: editor.enableNativeMediaUpload
)
}
}
diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift
index ca1aaed8a..47dd0755f 100644
--- a/ios/Demo-iOS/Sources/Views/EditorView.swift
+++ b/ios/Demo-iOS/Sources/Views/EditorView.swift
@@ -1,4 +1,7 @@
import SwiftUI
+import ImageIO
+import OSLog
+import UniformTypeIdentifiers
import GutenbergKit
import WordPressAPI
// `PostUpdateParams` is not yet re-exported from `WordPressAPI` in the pinned
@@ -7,19 +10,25 @@ import WordPressAPI
// including Automattic/wordpress-rs#1270 is adopted.
import WordPressAPIInternal
+private extension Logger {
+ static let demo = Logger(subsystem: "GutenbergKit-Demo", category: "media-upload")
+}
+
struct EditorView: View {
private let configuration: EditorConfiguration
private let dependencies: EditorDependencies?
private let apiClient: WordPressAPI?
+ private let enableNativeMediaUpload: Bool
@State private var viewModel = EditorViewModel()
@Environment(\.dismiss) var dismiss
- init(configuration: EditorConfiguration, dependencies: EditorDependencies? = nil, apiClient: WordPressAPI? = nil) {
+ init(configuration: EditorConfiguration, dependencies: EditorDependencies? = nil, apiClient: WordPressAPI? = nil, enableNativeMediaUpload: Bool = true) {
self.configuration = configuration
self.dependencies = dependencies
self.apiClient = apiClient
+ self.enableNativeMediaUpload = enableNativeMediaUpload
}
var body: some View {
@@ -27,6 +36,7 @@ struct EditorView: View {
configuration: configuration,
dependencies: dependencies,
apiClient: apiClient,
+ enableNativeMediaUpload: enableNativeMediaUpload,
viewModel: viewModel
)
.toolbar { toolbar }
@@ -101,17 +111,20 @@ private struct _EditorView: UIViewControllerRepresentable {
private let configuration: EditorConfiguration
private let dependencies: EditorDependencies?
private let apiClient: WordPressAPI?
+ private let enableNativeMediaUpload: Bool
private let viewModel: EditorViewModel
init(
configuration: EditorConfiguration,
dependencies: EditorDependencies? = nil,
apiClient: WordPressAPI? = nil,
+ enableNativeMediaUpload: Bool = true,
viewModel: EditorViewModel
) {
self.configuration = configuration
self.dependencies = dependencies
self.apiClient = apiClient
+ self.enableNativeMediaUpload = enableNativeMediaUpload
self.viewModel = viewModel
}
@@ -122,6 +135,9 @@ private struct _EditorView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> EditorViewController {
let viewController = EditorViewController(configuration: configuration, dependencies: dependencies)
viewController.delegate = context.coordinator
+ if enableNativeMediaUpload {
+ viewController.mediaUploadDelegate = context.coordinator
+ }
viewController.webView.isInspectable = true
viewModel.perform = { [weak viewController] in
@@ -173,7 +189,7 @@ private struct _EditorView: UIViewControllerRepresentable {
}
@MainActor
- class Coordinator: NSObject, EditorViewControllerDelegate {
+ class Coordinator: NSObject, EditorViewControllerDelegate, MediaUploadDelegate {
let viewModel: EditorViewModel
init(viewModel: EditorViewModel) {
@@ -278,6 +294,62 @@ private struct _EditorView: UIViewControllerRepresentable {
// In a real app, return the persisted title and content from autosave.
return nil
}
+
+ // MARK: - MediaUploadDelegate
+
+ /// Resizes images to a maximum dimension of 2000px before upload.
+ nonisolated func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile {
+ guard mimeType.hasPrefix("image/"), mimeType != "image/gif" else {
+ return .original
+ }
+
+ let maxDimension: CGFloat = 2000
+
+ guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
+ let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
+ let width = properties[kCGImagePropertyPixelWidth] as? CGFloat,
+ let height = properties[kCGImagePropertyPixelHeight] as? CGFloat else {
+ return .original
+ }
+
+ let longestSide = max(width, height)
+ guard longestSide > maxDimension else {
+ return .original
+ }
+
+ let options: [CFString: Any] = [
+ kCGImageSourceThumbnailMaxPixelSize: maxDimension,
+ kCGImageSourceCreateThumbnailFromImageAlways: true,
+ kCGImageSourceCreateThumbnailWithTransform: true
+ ]
+
+ guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
+ return .original
+ }
+
+ let outputURL = url.deletingLastPathComponent()
+ .appending(component: "resized-\(url.lastPathComponent)")
+
+ let sourceType = CGImageSourceGetType(source) ?? (UTType.png.identifier as CFString)
+ guard let destination = CGImageDestinationCreateWithURL(
+ outputURL as CFURL,
+ sourceType,
+ 1,
+ nil
+ ) else {
+ return .original
+ }
+
+ CGImageDestinationAddImage(destination, thumbnail, nil)
+ guard CGImageDestinationFinalize(destination) else {
+ return .original
+ }
+
+ Logger.demo.info("Resized image from \(Int(width))x\(Int(height)) to fit \(Int(maxDimension))px")
+ // Same format, so the original mimeType/filename carry over.
+ return .processed(outputURL, mimeType: mimeType, filename: filename)
+ }
+
}
}
diff --git a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift
index 68d6e0b8a..03dd496f6 100644
--- a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift
+++ b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift
@@ -55,6 +55,7 @@ struct SitePreparationView: View {
Section("Feature Configuration") {
Toggle("Enable Native Inserter", isOn: $viewModel.enableNativeInserter)
+ Toggle("Enable Native Media Upload", isOn: $viewModel.enableNativeMediaUpload)
Toggle("Enable Network Logging", isOn: $viewModel.enableNetworkLogging)
Picker("Network Fallback", selection: $viewModel.networkFallbackMode) {
@@ -154,6 +155,8 @@ class SitePreparationViewModel {
}
}
+ var enableNativeMediaUpload: Bool = true
+
var enableNetworkLogging: Bool {
get { editorConfiguration?.enableNetworkLogging ?? false }
set {
@@ -494,7 +497,8 @@ class SitePreparationViewModel {
let editor = RunnableEditor(
configuration: configuration,
- dependencies: self.editorDependencies
+ dependencies: self.editorDependencies,
+ enableNativeMediaUpload: self.enableNativeMediaUpload
)
navigation.present(editor)
diff --git a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift
index 6ad43e659..a13cbf550 100644
--- a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift
+++ b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift
@@ -4,9 +4,24 @@ import OSLog
/// A protocol for making authenticated HTTP requests to the WordPress REST API.
public protocol EditorHTTPClientProtocol: Sendable {
func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse)
+
+ /// Like ``perform(_:)`` but does **not** throw on a non-2xx status — returns
+ /// the raw response so the caller can relay WordPress's exact status and body.
+ /// Used by the media upload server, which forwards WordPress's response (and
+ /// its errors) to the editor unchanged.
+ func performRaw(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse)
+
func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse)
}
+public extension EditorHTTPClientProtocol {
+ /// Default implementation validates the status like ``perform(_:)``. Only
+ /// clients that need to relay non-2xx responses override this.
+ func performRaw(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) {
+ try await perform(urlRequest)
+ }
+}
+
/// A delegate for observing HTTP requests made by the editor.
///
/// Implement this protocol to inspect or log all network requests.
@@ -32,13 +47,21 @@ public struct WPError: Decodable, Sendable {
public actor EditorHTTPClient: EditorHTTPClientProtocol {
/// Errors that can occur during HTTP requests.
- public enum ClientError: Error, Sendable {
+ public enum ClientError: Error, LocalizedError, Sendable {
/// The server returned a WordPress-formatted error response.
case wpError(WPError, requestURL: URL)
/// A file download failed with the given HTTP status code.
case downloadFailed(statusCode: Int, requestURL: URL)
/// An unexpected error occurred with the given response data and status code.
case unknown(response: Data, statusCode: Int, requestURL: URL)
+
+ public var errorDescription: String? {
+ switch self {
+ case .wpError(let error, _): error.message
+ case .downloadFailed(let code, _): "Download failed (\(code))"
+ case .unknown(_, let code, _): "Request failed (\(code))"
+ }
+ }
}
/// The base user agent string identifying the platform.
@@ -96,6 +119,13 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol {
return (data, httpResponse)
}
+ public func performRaw(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) {
+ let configuredRequest = self.configureRequest(urlRequest)
+ let (data, response) = try await self.urlSession.data(for: configuredRequest)
+ self.delegate?.didPerformRequest(configuredRequest, response: response, data: .bytes(data))
+ return (data, response as! HTTPURLResponse)
+ }
+
public func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse) {
let configuredRequest = self.configureRequest(urlRequest)
diff --git a/ios/Sources/GutenbergKit/Sources/EditorLogging.swift b/ios/Sources/GutenbergKit/Sources/EditorLogging.swift
index 3f74e27a0..c24bf793e 100644
--- a/ios/Sources/GutenbergKit/Sources/EditorLogging.swift
+++ b/ios/Sources/GutenbergKit/Sources/EditorLogging.swift
@@ -22,6 +22,9 @@ extension Logger {
/// Logs editor navigation activity
public static let navigation = Logger(subsystem: "GutenbergKit", category: "navigation")
+
+ /// Logs upload server activity
+ static let uploadServer = Logger(subsystem: "GutenbergKit", category: "upload-server")
}
public struct SignpostMonitor: Sendable {
diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift
index 808c7034f..87d86a703 100644
--- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift
+++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift
@@ -104,12 +104,17 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
/// Used by `EditorViewController.warmup()` to reduce first-render latency.
private let isWarmupMode: Bool
+ /// Delegate for customizing media file processing and upload behavior.
+ public weak var mediaUploadDelegate: (any MediaUploadDelegate)?
+
// MARK: - Private Properties (Services)
private let editorService: EditorService
+ private let httpClient: any EditorHTTPClientProtocol
private let mediaPicker: MediaPickerController?
private let controller: GutenbergEditorController
private let bundleProvider: EditorAssetBundleProvider
private let lockdownModeMonitor: LockdownModeMonitor
+ private var uploadServer: MediaUploadServer?
// MARK: - Private Properties (UI)
@@ -165,6 +170,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
self.configuration = configuration
self.dependencies = dependencies
+ self.httpClient = httpClient
self.editorService = EditorService(
configuration: configuration,
httpClient: httpClient
@@ -237,11 +243,21 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
}
if let dependencies {
- // FAST PATH: Dependencies were provided at init() - load immediately
- do {
- try self.loadEditor(dependencies: dependencies)
- } catch {
- self.failToLoad(error)
+ // FAST PATH: Dependencies were provided at init() - load immediately.
+ //
+ // Deliberately NOT tracked in `dependencyTaskHandle`: `viewDidDisappear`
+ // cancels that handle to abort the async dependency *fetch*, but the
+ // fast path is cheap local work that must run to completion — a
+ // transient disappearance (e.g. a modal presented over the editor)
+ // cancelling it mid `startUploadServer()` silently disabled native
+ // uploads for the session. `[weak self]` still makes it a no-op once
+ // the controller is torn down.
+ Task(priority: .userInitiated) { [weak self] in
+ do {
+ try await self?.loadEditor(dependencies: dependencies)
+ } catch {
+ self?.failToLoad(error)
+ }
}
} else {
// ASYNC FLOW: No dependencies - fetch them asynchronously
@@ -266,6 +282,17 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
self.dependencyTaskHandle?.cancel()
}
+ deinit {
+ // Stop the upload server when the editor is permanently torn down.
+ //
+ // This deliberately does NOT happen in `viewDidDisappear`, which also
+ // fires when another view controller is merely pushed or presented over
+ // the editor. `HTTPServer.stop()` cancels the `NWListener`, which is
+ // terminal and has no restart path — stopping on disappear left uploads
+ // permanently broken once the user returned to the editor.
+ uploadServer?.stop()
+ }
+
/// Fetches all required dependencies and then loads the editor.
///
/// This method is the entry point for the **Async Flow** (when no dependencies were provided at init).
@@ -284,7 +311,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
self.dependencies = dependencies
// Continue to the shared loading path
- try self.loadEditor(dependencies: dependencies)
+ try await self.loadEditor(dependencies: dependencies)
} catch {
self.failToLoad(error)
}
@@ -305,12 +332,15 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
/// The editor will eventually emit an `onEditorLoaded` message, triggering `didLoadEditor()`.
///
@MainActor
- private func loadEditor(dependencies: EditorDependencies) throws {
+ private func loadEditor(dependencies: EditorDependencies) async throws {
self.displayActivityView()
// Set asset bundle for the URL scheme handler to serve cached plugin/theme assets
self.bundleProvider.set(bundle: dependencies.assetBundle)
+ // Start the local upload server for native media processing
+ await startUploadServer()
+
// Build and inject editor configuration as window.GBKit
let editorConfig = try buildEditorConfiguration(dependencies: dependencies)
webView.configuration.userContentController.addUserScript(editorConfig)
@@ -343,7 +373,12 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
/// when it initializes.
///
private func buildEditorConfiguration(dependencies: EditorDependencies) throws -> WKUserScript {
- let gbkitGlobal = try GBKitGlobal(configuration: self.configuration, dependencies: dependencies)
+ let gbkitGlobal = try GBKitGlobal(
+ configuration: self.configuration,
+ dependencies: dependencies,
+ nativeUploadPort: uploadServer.map { Int($0.port) },
+ nativeUploadToken: uploadServer?.token
+ )
let stringValue = try gbkitGlobal.toString()
let jsCode = """
@@ -355,6 +390,41 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
return WKUserScript(source: jsCode, injectionTime: .atDocumentStart, forMainFrameOnly: true)
}
+ /// Starts the local HTTP server for routing file uploads through native processing.
+ ///
+ /// The server binds to localhost on a random port. If it fails to start, the editor
+ /// falls back to Gutenberg's default upload behavior (the JS override won't activate
+ /// because `nativeUploadPort` will be nil in GBKit).
+ private func startUploadServer() async {
+ guard mediaUploadDelegate != nil else {
+ return
+ }
+
+ // The native upload server relays through DefaultMediaUploader, which needs a
+ // site root and an auth header (every host provides one — the editor injects
+ // it because the WebView has no auth cookies). Without both there is nothing
+ // to upload through, so leave the server down and let uploads fall to the
+ // default WebView path rather than start a server that could only fail.
+ guard !configuration.authHeader.isEmpty else {
+ return
+ }
+
+ let defaultUploader = DefaultMediaUploader(
+ httpClient: httpClient,
+ siteApiRoot: configuration.siteApiRoot,
+ siteApiNamespace: configuration.siteApiNamespace
+ )
+
+ do {
+ self.uploadServer = try await MediaUploadServer.start(
+ uploadDelegate: mediaUploadDelegate,
+ defaultUploader: defaultUploader
+ )
+ } catch {
+ Logger.uploadServer.error("Failed to start upload server: \(error). Falling back to default upload behavior.")
+ }
+ }
+
/// Deletes all cached editor data for all sites
public static func deleteAllData() throws {
if FileManager.default.directoryExists(at: Paths.defaultCacheRoot) {
diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift
new file mode 100644
index 000000000..ab4c809b9
--- /dev/null
+++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift
@@ -0,0 +1,69 @@
+import Foundation
+
+/// A raw response from the WordPress REST API media endpoint.
+///
+/// GutenbergKit relays this to the editor verbatim — it does not interpret the
+/// body. The editor therefore receives the exact attachment object (on success)
+/// or WordPress REST error object (on failure) it would get from a direct
+/// upload, so every consumer — image sub-sizes, attachment links, error notices —
+/// behaves identically to a non-native upload.
+public struct MediaUploadResponse: Sendable {
+ /// The HTTP status code WordPress (or the host's upload service) returned.
+ public let statusCode: Int
+
+ /// The raw response body — a WordPress REST attachment on success, or a
+ /// WordPress REST error object (`{ "code", "message", "data" }`) on failure.
+ public let body: Data
+
+ public init(statusCode: Int, body: Data) {
+ self.statusCode = statusCode
+ self.body = body
+ }
+}
+
+/// The result of a delegate's ``MediaUploadDelegate/processFile(at:mimeType:filename:)``.
+public enum ProcessedProxyFile: Sendable {
+ /// The delegate did not modify the file; the original upload is forwarded
+ /// to WordPress unchanged.
+ case original
+
+ /// The delegate produced a file to upload, along with its MIME type and
+ /// filename. Both are used verbatim, so a format change (e.g. transcoding
+ /// MOV to MP4, or an in-place EXIF strip) must report the resulting type and
+ /// filename for WordPress to store the file correctly.
+ case processed(URL, mimeType: String, filename: String)
+}
+
+/// Protocol for customizing media upload behavior.
+///
+/// The native host app can provide an implementation to resize images,
+/// transcode video, or use its own upload service. Default implementations
+/// pass files through unchanged and upload via the WordPress REST API.
+public protocol MediaUploadDelegate: AnyObject, Sendable {
+ /// Process a file before upload (e.g., resize image, transcode video).
+ ///
+ /// Return ``ProcessedProxyFile/original`` to upload the file unchanged, or
+ /// ``ProcessedProxyFile/processed(_:mimeType:filename:)`` with the processed
+ /// file and its metadata. When the format changes, report the new mimeType
+ /// and filename so WordPress stores it with the correct extension and type.
+ func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile
+
+ /// Upload a processed file to the remote WordPress site.
+ ///
+ /// Return the raw WordPress response (status code + body), which GutenbergKit
+ /// relays to the editor unchanged, or `nil` to use the default uploader. A
+ /// host that uploads to WordPress should return the exact response it
+ /// received so the editor sees a complete attachment object.
+ func uploadFile(at url: URL, mimeType: String, filename: String) async throws -> MediaUploadResponse?
+}
+
+/// Default implementations.
+extension MediaUploadDelegate {
+ public func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile {
+ .original
+ }
+
+ public func uploadFile(at url: URL, mimeType: String, filename: String) async throws -> MediaUploadResponse? {
+ nil
+ }
+}
diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift
new file mode 100644
index 000000000..ec813fd11
--- /dev/null
+++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift
@@ -0,0 +1,547 @@
+import Foundation
+import GutenbergKitHTTP
+import OSLog
+
+/// A local HTTP server that receives file uploads from the WebView and routes
+/// them through the native media processing pipeline.
+///
+/// Built on ``HTTPServer`` from `GutenbergKitHTTP`, which handles TCP binding,
+/// HTTP parsing, bearer token authentication, and multipart form-data parsing.
+/// This class provides the upload-specific handler: receiving a file, delegating
+/// to the host app for processing/upload, and returning the result as JSON.
+///
+/// Lifecycle is tied to `EditorViewController` — start when the editor loads,
+/// stop on deinit.
+final class MediaUploadServer: Sendable {
+
+ /// The port the server is listening on.
+ let port: UInt16
+
+ /// Per-session auth token for validating incoming requests.
+ let token: String
+
+ private let server: HTTPServer
+
+ /// Sweeps crash-orphaned upload temp files off the editor-startup path.
+ /// Exposed so tests can await completion. (Mirrors Android's `cleanupJob`.)
+ let cleanupTask: Task
+
+ /// Creates and starts a new upload server.
+ ///
+ /// - Parameters:
+ /// - uploadDelegate: Optional delegate for customizing file processing and upload.
+ /// - defaultUploader: Fallback uploader used when no delegate provides `uploadFile`.
+ /// - maxRequestBodySize: The maximum allowed request body size in bytes.
+ /// Requests exceeding this limit receive a 413 response. Defaults to 4 GB.
+ static func start(
+ uploadDelegate: (any MediaUploadDelegate)? = nil,
+ defaultUploader: DefaultMediaUploader? = nil,
+ maxRequestBodySize: Int64 = HTTPRequestParser.defaultMaxBodySize
+ ) async throws -> MediaUploadServer {
+ // Sweep temp files orphaned by a prior crash, off the editor-startup
+ // path — the sweep only deletes stale files (>1 hour old), so it cannot
+ // race this server's own in-flight uploads and nothing below depends on it.
+ let cleanupTask = Task.detached(priority: .utility) {
+ cleanOrphanedUploads()
+ }
+
+ let context = UploadContext(uploadDelegate: uploadDelegate, defaultUploader: defaultUploader)
+
+ let server = try await HTTPServer.start(
+ name: "media-upload",
+ requiresAuthentication: true,
+ maxRequestBodySize: maxRequestBodySize,
+ cors: .permissive,
+ handler: { request in
+ await Self.handleRequest(request, context: context)
+ }
+ )
+
+ return MediaUploadServer(server: server, cleanupTask: cleanupTask)
+ }
+
+ private init(server: HTTPServer, cleanupTask: Task) {
+ self.server = server
+ self.port = server.port
+ self.token = server.token
+ self.cleanupTask = cleanupTask
+ }
+
+ /// Stops the server and releases resources.
+ func stop() {
+ server.stop()
+ }
+
+ // MARK: - Request Handling
+
+ private static func handleRequest(_ request: HTTPServer.Request, context: UploadContext) async -> HTTPResponse {
+ let parsed = request.parsed
+
+ // Server-detected error (e.g., payload too large) — build the
+ // error response here so it includes CORS headers.
+ if let serverError = request.serverError {
+ let message: String = switch serverError {
+ case .payloadTooLarge: "The file is too large to upload in the editor."
+ default: "\(serverError.httpStatusText)"
+ }
+ return errorResponse(status: serverError.httpStatus, message: message)
+ }
+
+ // Route: only POST /upload is handled. (OPTIONS preflight is answered by
+ // the HTTP library under its permissive CORS policy.) Match on the path
+ // alone — the target carries a query string (e.g. `?_embed`) that the
+ // upload handler relays on to WordPress.
+ guard parsed.method.uppercased() == "POST", parsed.path == "/upload" else {
+ return errorResponse(status: 404, message: "Not found")
+ }
+
+ return await handleUpload(request, context: context)
+ }
+
+ private static func handleUpload(_ request: HTTPServer.Request, context: UploadContext) async -> HTTPResponse {
+ let parts: [MultipartPart]
+ do {
+ parts = try request.parsed.multipartParts()
+ } catch {
+ Logger.uploadServer.error("Multipart parse failed: \(error)")
+ return errorResponse(status: 400, message: "Expected multipart/form-data")
+ }
+
+ // Find the file part (the first part with a filename).
+ guard let filePart = parts.first(where: { $0.filename != nil }) else {
+ return errorResponse(status: 400, message: "No file found in request")
+ }
+
+ // The non-file parts (post, additionalData) and the original query
+ // (e.g. ?_embed) must reach WordPress too — relay them alongside the file.
+ let extraParts = parts.filter { $0.filename == nil }
+ let query = request.parsed.query
+
+ // Write part body to a dedicated temp file for the delegate.
+ //
+ // The library's RequestBody may be a byte-range slice of a larger temp
+ // file whose lifecycle is tied to ARC. The delegate needs a standalone
+ // file that outlives the handler return, so we stream to our own file.
+ let filename = sanitizeFilename(filePart.filename ?? "upload")
+ let mimeType = filePart.contentType
+
+ let tempDir = uploadsTempDirectory
+ try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+
+ let fileURL = tempDir.appending(component: "\(UUID().uuidString)-\(filename)")
+ do {
+ let inputStream = try filePart.body.makeInputStream()
+ try writeStream(inputStream, to: fileURL)
+ } catch {
+ try? FileManager.default.removeItem(at: fileURL)
+ Logger.uploadServer.error("Failed to write upload to disk: \(error)")
+ return errorResponse(status: 500, message: "Failed to save file")
+ }
+
+ // From here on always clean up the original temp file. The processed
+ // file (if the delegate produced a new one) is cleaned up inside
+ // processAndUpload so its throw paths are covered too.
+ defer { try? FileManager.default.removeItem(at: fileURL) }
+
+ do {
+ let uploadResult = try await processAndUpload(
+ fileURL: fileURL, mimeType: mimeType, filename: filePart.filename ?? "upload",
+ extraParts: extraParts, query: query, context: context
+ )
+ let response: MediaUploadResponse
+ switch uploadResult {
+ case .uploaded(let uploaded):
+ Logger.uploadServer.debug("Uploaded file to WordPress")
+ response = uploaded
+ case .passthrough:
+ // Delegate didn't modify the file — forward the original
+ // request body to WordPress without re-encoding.
+ Logger.uploadServer.debug("Passthrough: forwarding original request body to WordPress")
+ guard let body = request.parsed.body,
+ let contentType = request.parsed.header("Content-Type"),
+ let defaultUploader = context.defaultUploader else {
+ return errorResponse(status: 500, message: UploadError.noUploader.localizedDescription)
+ }
+ response = try await defaultUploader.passthroughUpload(body: body, contentType: contentType, query: query)
+ }
+ // Relay WordPress's exact status and body to the editor so it sees
+ // the same attachment object (or error) as a direct upload.
+ return HTTPResponse(
+ status: response.statusCode,
+ headers: [("Content-Type", "application/json")],
+ body: response.body
+ )
+ } catch {
+ Logger.uploadServer.error("Upload processing failed: \(error)")
+ return errorResponse(status: 500, message: error.localizedDescription)
+ }
+ }
+
+ // MARK: - Delegate Pipeline
+
+ /// Result of the delegate processing + upload pipeline.
+ private enum UploadResult {
+ /// The delegate (or default uploader) completed the upload; carries the
+ /// raw WordPress response to relay.
+ case uploaded(MediaUploadResponse)
+ /// The delegate didn't modify the file and `uploadFile` returned nil.
+ /// The caller should forward the original request body to WordPress.
+ case passthrough
+ }
+
+ private static func processAndUpload(
+ fileURL: URL, mimeType: String, filename: String,
+ extraParts: [MultipartPart], query: String, context: UploadContext
+ ) async throws -> UploadResult {
+ // Step 1: Process (resize, transcode, etc.)
+ let processed: ProcessedProxyFile
+ if let delegate = context.uploadDelegate {
+ processed = try await delegate.processFile(at: fileURL, mimeType: mimeType, filename: filename)
+ } else {
+ processed = .original
+ }
+
+ // Resolve the file to upload and its metadata. `.processed` uses the
+ // delegate's values verbatim, so a format change is reported to WordPress.
+ let uploadURL: URL
+ let uploadMimeType: String
+ let uploadFilename: String
+ switch processed {
+ case .original:
+ uploadURL = fileURL
+ uploadMimeType = mimeType
+ uploadFilename = filename
+ case let .processed(url, processedMimeType, processedFilename):
+ uploadURL = url
+ uploadMimeType = processedMimeType
+ uploadFilename = processedFilename
+ }
+
+ // The processed file (if the delegate produced a new one) is ours to
+ // clean up — on success it has been uploaded, on failure it is abandoned.
+ // Cleaning up here rather than in the caller covers the throw paths too.
+ defer {
+ if uploadURL != fileURL {
+ try? FileManager.default.removeItem(at: uploadURL)
+ }
+ }
+
+ // Step 2: Upload to remote WordPress
+ if let delegate = context.uploadDelegate,
+ let result = try await delegate.uploadFile(at: uploadURL, mimeType: uploadMimeType, filename: uploadFilename) {
+ return .uploaded(result)
+ } else if let defaultUploader = context.defaultUploader {
+ // Unmodified — forward the original request body directly, skipping
+ // multipart re-encoding.
+ if case .original = processed {
+ return .passthrough
+ }
+ let result = try await defaultUploader.upload(fileURL: uploadURL, mimeType: uploadMimeType, filename: uploadFilename, extraParts: extraParts, query: query)
+ return .uploaded(result)
+ } else {
+ throw UploadError.noUploader
+ }
+ }
+
+ private static func errorResponse(status: Int, message: String) -> HTTPResponse {
+ // Emit a WordPress-REST-style error object so the JS middleware normalizes
+ // it (and surfaces `message`) the same way it does a relayed WordPress
+ // error — the local server's own errors need no special-casing.
+ let payload = ["code": "upload_error", "message": message]
+ let body = (try? JSONSerialization.data(withJSONObject: payload))
+ ?? Data(#"{"code":"upload_error","message":"Upload failed"}"#.utf8)
+ return HTTPResponse(
+ status: status,
+ headers: [("Content-Type", "application/json")],
+ body: body
+ )
+ }
+
+ // MARK: - Helpers
+
+ /// Directory for staging uploaded files, under the system temp dir.
+ private static var uploadsTempDirectory: URL {
+ FileManager.default.temporaryDirectory
+ .appending(component: "GutenbergKit-uploads", directoryHint: .isDirectory)
+ }
+
+ /// Deletes upload temp files left behind by a prior crash. Files still in
+ /// flight (only seconds old) are preserved by the age threshold, so this is
+ /// safe even if another editor instance is mid-upload.
+ private static func cleanOrphanedUploads() {
+ let cutoff = Date(timeIntervalSinceNow: -3600) // 1 hour ago
+ guard let files = try? FileManager.default.contentsOfDirectory(
+ at: uploadsTempDirectory,
+ includingPropertiesForKeys: [.contentModificationDateKey]
+ ) else { return }
+ for file in files {
+ let modified = (try? file.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate
+ if let modified, modified < cutoff {
+ try? FileManager.default.removeItem(at: file)
+ }
+ }
+ }
+
+ /// Sanitizes a filename to prevent path traversal.
+ private static func sanitizeFilename(_ name: String) -> String {
+ let safe = (name as NSString).lastPathComponent
+ .replacingOccurrences(of: "/", with: "")
+ .replacingOccurrences(of: "\\", with: "")
+ return safe.isEmpty ? "upload" : safe
+ }
+
+ /// Streams an InputStream to a file URL.
+ private static func writeStream(_ inputStream: InputStream, to url: URL) throws {
+ inputStream.open()
+ defer { inputStream.close() }
+
+ // `OutputStream(url:append:)` returns nil if the file can't be opened for
+ // writing (e.g. the uploads directory was removed after it was created, or
+ // a permissions/sandbox failure). Throw rather than force-unwrap so the
+ // caller returns a clean 500 instead of trapping the process.
+ guard let outputStream = OutputStream(url: url, append: false) else {
+ throw UploadError.streamWriteFailed
+ }
+ outputStream.open()
+ defer { outputStream.close() }
+
+ let bufferSize = 65_536
+ let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize)
+ defer { buffer.deallocate() }
+
+ // Use read() return value as the sole termination signal. Do NOT check
+ // hasBytesAvailable — for piped streams (used by file-slice RequestBody),
+ // it can return false before the writer thread has pumped the next chunk,
+ // causing an early exit and a truncated file.
+ while true {
+ let bytesRead = inputStream.read(buffer, maxLength: bufferSize)
+ if bytesRead < 0 {
+ throw inputStream.streamError ?? UploadError.streamReadFailed
+ }
+ if bytesRead == 0 { break }
+
+ var totalWritten = 0
+ while totalWritten < bytesRead {
+ let written = outputStream.write(buffer.advanced(by: totalWritten), maxLength: bytesRead - totalWritten)
+ if written < 0 {
+ throw outputStream.streamError ?? UploadError.streamWriteFailed
+ }
+ totalWritten += written
+ }
+ }
+ }
+
+}
+
+// MARK: - Errors
+
+/// Errors from the native media upload pipeline.
+enum UploadError: Error, LocalizedError {
+ case noUploader
+ case streamReadFailed
+ case streamWriteFailed
+
+ var errorDescription: String? {
+ switch self {
+ case .noUploader: "No upload delegate or default uploader configured"
+ case .streamReadFailed: "Failed to read upload stream"
+ case .streamWriteFailed: "Failed to write upload to disk"
+ }
+ }
+}
+
+// MARK: - Upload Context
+
+/// Container for the upload delegate and default uploader, captured by the
+/// HTTPServer handler closure and re-read on each request.
+///
+/// The delegate is held **weakly**. `EditorViewController.mediaUploadDelegate` is
+/// declared `weak` — the host owns the delegate's lifetime. Capturing it strongly
+/// here would silently defeat that contract and, worse, risk a retain cycle
+/// (`EditorViewController → uploadServer → HTTPServer → handler → UploadContext →
+/// delegate → EditorViewController`) that would keep the view controller — and
+/// therefore the server — alive forever, so `deinit` would never stop it.
+///
+/// `@unchecked Sendable`: `uploadDelegate` is assigned once at init and only read
+/// afterwards; weak-reference reads are thread-safe at runtime.
+private final class UploadContext: @unchecked Sendable {
+ weak var uploadDelegate: (any MediaUploadDelegate)?
+ let defaultUploader: DefaultMediaUploader?
+
+ init(uploadDelegate: (any MediaUploadDelegate)?, defaultUploader: DefaultMediaUploader?) {
+ self.uploadDelegate = uploadDelegate
+ self.defaultUploader = defaultUploader
+ }
+}
+
+// MARK: - Default Media Uploader
+
+/// Uploads files to the WordPress REST API using site credentials from EditorConfiguration.
+class DefaultMediaUploader: @unchecked Sendable {
+ private let httpClient: EditorHTTPClientProtocol
+ private let siteApiRoot: URL
+ private let siteApiNamespace: String?
+
+ init(httpClient: EditorHTTPClientProtocol, siteApiRoot: URL, siteApiNamespace: [String] = []) {
+ self.httpClient = httpClient
+ self.siteApiRoot = siteApiRoot
+ self.siteApiNamespace = siteApiNamespace.first
+ }
+
+ /// The WordPress media endpoint URL, built through the shared
+ /// ``WordPressRESTURL`` namespacing (so it matches every other REST URL) and
+ /// carrying the original request query (e.g. `?_embed`) through to WordPress.
+ private func mediaEndpointURL(query: String) -> URL {
+ let base = WordPressRESTURL.namespaced(apiRoot: siteApiRoot, path: "/wp/v2/media", namespace: siteApiNamespace)
+ guard !query.isEmpty else { return base }
+ // `query` is the raw request query in wire form (leading "?"). Set it via
+ // `percentEncodedQuery` so a value that isn't URL-safe can't make
+ // `URL(string:)` return nil and silently drop the query.
+ var components = URLComponents(url: base, resolvingAgainstBaseURL: false)
+ components?.percentEncodedQuery = String(query.dropFirst())
+ return components?.url ?? base
+ }
+
+ func upload(fileURL: URL, mimeType: String, filename: String, extraParts: [MultipartPart], query: String) async throws -> MediaUploadResponse {
+ let boundary = UUID().uuidString
+
+ // Read the (small, text) non-file parts up front so the body builder
+ // stays synchronous — the file itself is still streamed from disk.
+ var extraFields: [(name: String, value: Data)] = []
+ for part in extraParts {
+ extraFields.append((part.name, try await part.body.data))
+ }
+
+ let (bodyStream, contentLength) = try Self.multipartBodyStream(
+ fileURL: fileURL, boundary: boundary, filename: filename, mimeType: mimeType, extraFields: extraFields
+ )
+
+ var request = URLRequest(url: mediaEndpointURL(query: query))
+ request.httpMethod = "POST"
+ request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+ request.setValue("\(contentLength)", forHTTPHeaderField: "Content-Length")
+ request.httpBodyStream = bodyStream
+
+ return try await performUpload(request)
+ }
+
+ /// Forwards the original request body to WordPress without re-encoding.
+ ///
+ /// Used when the delegate's `processFile` returned the file unchanged —
+ /// the incoming multipart body is already valid for WordPress.
+ func passthroughUpload(body: RequestBody, contentType: String, query: String) async throws -> MediaUploadResponse {
+ var request = URLRequest(url: mediaEndpointURL(query: query))
+ request.httpMethod = "POST"
+ request.setValue(contentType, forHTTPHeaderField: "Content-Type")
+ request.setValue("\(body.count)", forHTTPHeaderField: "Content-Length")
+ request.httpBodyStream = try body.makeInputStream()
+
+ return try await performUpload(request)
+ }
+
+ private func performUpload(_ request: URLRequest) async throws -> MediaUploadResponse {
+ // Relay WordPress's response verbatim — including non-2xx statuses — so
+ // the editor sees WordPress's real status and error body, exactly as a
+ // direct upload would. `performRaw` does not throw on non-2xx.
+ let (data, response) = try await httpClient.performRaw(request)
+ return MediaUploadResponse(statusCode: response.statusCode, body: data)
+ }
+
+ // MARK: - Streaming Multipart Body
+
+ /// Builds a multipart/form-data body as an `InputStream` that streams the
+ /// file from disk without loading it into memory.
+ ///
+ /// Uses a bound stream pair with a background writer thread — the same
+ /// pattern as `RequestBody.makePipedFileSliceStream`.
+ ///
+ /// - Returns: A tuple of the input stream and the total content length.
+ static func multipartBodyStream(
+ fileURL: URL,
+ boundary: String,
+ filename: String,
+ mimeType: String,
+ extraFields: [(name: String, value: Data)]
+ ) throws -> (InputStream, Int) {
+ // Serialize the non-file parts (post, additionalData) into the preamble
+ // ahead of the streamed file. They are small, so keeping them in memory is
+ // fine; `contentLength` counts them via `preamble.count`. Field values are
+ // appended as raw bytes (not through String) so a non-UTF-8 value is
+ // forwarded verbatim rather than coerced to empty.
+ var preamble = Data()
+ for field in extraFields {
+ preamble.append(Data("--\(boundary)\r\n".utf8))
+ preamble.append(Data("Content-Disposition: form-data; name=\"\(field.name)\"\r\n\r\n".utf8))
+ preamble.append(field.value)
+ preamble.append(Data("\r\n".utf8))
+ }
+ preamble.append(Data("--\(boundary)\r\n".utf8))
+ preamble.append(Data("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".utf8))
+ preamble.append(Data("Content-Type: \(mimeType)\r\n\r\n".utf8))
+ let epilogue = Data("\r\n--\(boundary)--\r\n".utf8)
+
+ guard let fileSize = try FileManager.default.attributesOfItem(atPath: fileURL.path(percentEncoded: false))[.size] as? Int else {
+ throw UploadError.streamReadFailed
+ }
+ let contentLength = preamble.count + fileSize + epilogue.count
+
+ let fileHandle = try FileHandle(forReadingFrom: fileURL)
+
+ var readStream: InputStream?
+ var writeStream: OutputStream?
+ Stream.getBoundStreams(withBufferSize: 65_536, inputStream: &readStream, outputStream: &writeStream)
+
+ guard let inputStream = readStream, let outputStream = writeStream else {
+ try? fileHandle.close()
+ throw UploadError.streamReadFailed
+ }
+
+ outputStream.open()
+
+ // OutputStream is not Sendable but is safely transferred to the
+ // writer thread — only the thread accesses it after this point.
+ nonisolated(unsafe) let output = outputStream
+
+ Thread.detachNewThread {
+ defer {
+ output.close()
+ try? fileHandle.close()
+ }
+
+ // Write preamble (multipart headers).
+ guard Self.writeAll(preamble, to: output) else { return }
+
+ // Stream file content in chunks.
+ var remaining = fileSize
+ while remaining > 0 {
+ let chunkSize = min(65_536, remaining)
+ guard let chunk = try? fileHandle.read(upToCount: chunkSize),
+ !chunk.isEmpty else {
+ break
+ }
+ guard Self.writeAll(chunk, to: output) else { return }
+ remaining -= chunk.count
+ }
+
+ // Write epilogue (closing boundary).
+ _ = Self.writeAll(epilogue, to: output)
+ }
+
+ return (inputStream, contentLength)
+ }
+
+ /// Writes all bytes of `data` to the output stream, handling partial writes.
+ private static func writeAll(_ data: Data, to output: OutputStream) -> Bool {
+ data.withUnsafeBytes { buffer in
+ guard let base = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return false }
+ var written = 0
+ while written < data.count {
+ let result = output.write(base.advanced(by: written), maxLength: data.count - written)
+ if result <= 0 { return false }
+ written += result
+ }
+ return true
+ }
+ }
+}
+
diff --git a/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift b/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift
index 131ea1db6..7cf266c90 100644
--- a/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift
+++ b/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift
@@ -79,9 +79,15 @@ public struct GBKitGlobal: Sendable, Codable {
/// Whether to log network requests in the JavaScript console.
let enableNetworkLogging: Bool
-
+
+ /// Port the local HTTP server is listening on for native media uploads.
+ let nativeUploadPort: Int?
+
+ /// Per-session auth token for requests to the local upload server.
+ let nativeUploadToken: String?
+
let editorSettings: JSON?
-
+
let preloadData: JSON?
/// Pre-fetched editor assets (scripts, styles, allowed block types) for plugin loading.
@@ -92,9 +98,13 @@ public struct GBKitGlobal: Sendable, Codable {
/// - Parameters:
/// - configuration: The editor configuration.
/// - dependencies: The pre-fetched editor dependencies (unused but reserved for future use).
+ /// - nativeUploadPort: Port of the local upload server, or nil if not running.
+ /// - nativeUploadToken: Auth token for the local upload server, or nil if not running.
public init(
configuration: EditorConfiguration,
- dependencies: EditorDependencies
+ dependencies: EditorDependencies,
+ nativeUploadPort: Int? = nil,
+ nativeUploadToken: String? = nil
) throws {
self.siteURL = configuration.isOfflineModeEnabled ? nil : configuration.siteURL
self.siteApiRoot = configuration.isOfflineModeEnabled ? nil : configuration.siteApiRoot
@@ -117,6 +127,8 @@ public struct GBKitGlobal: Sendable, Codable {
)
self.logLevel = configuration.logLevel.rawValue
self.enableNetworkLogging = configuration.enableNetworkLogging
+ self.nativeUploadPort = nativeUploadPort
+ self.nativeUploadToken = nativeUploadToken
self.editorSettings = dependencies.editorSettings.jsonValue
self.preloadData = try dependencies.preloadList?.build()
self.editorAssets = Self.buildEditorAssets(from: dependencies.assetBundle)
diff --git a/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift b/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift
index 91b53fd56..616c5053d 100644
--- a/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift
+++ b/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift
@@ -34,62 +34,30 @@ public struct RESTAPIRepository: Sendable {
if let customEndpoint = configuration.editorSettingsEndpoint {
self.editorSettingsUrl = customEndpoint
} else {
- self.editorSettingsUrl = Self.buildNamespacedURL(
+ self.editorSettingsUrl = WordPressRESTURL.namespaced(
apiRoot: apiRoot,
path: Constants.API.editorSettingsPath,
namespace: configuration.siteApiNamespace.first
)
}
- self.activeThemeUrl = Self.buildNamespacedURL(
+ self.activeThemeUrl = WordPressRESTURL.namespaced(
apiRoot: apiRoot,
path: Constants.API.activeThemePath,
namespace: configuration.siteApiNamespace.first
)
- self.siteSettingsUrl = Self.buildNamespacedURL(
+ self.siteSettingsUrl = WordPressRESTURL.namespaced(
apiRoot: apiRoot,
path: Constants.API.siteSettingsPath,
namespace: configuration.siteApiNamespace.first
)
- self.postTypesUrl = Self.buildNamespacedURL(
+ self.postTypesUrl = WordPressRESTURL.namespaced(
apiRoot: apiRoot,
path: Constants.API.postTypesPath,
namespace: configuration.siteApiNamespace.first
)
}
- /// Builds a URL by inserting the namespace after the version segment of the path.
- /// For example: `/wp/v2/posts` with namespace `sites/123/` becomes `/wp/v2/sites/123/posts`
- private static func buildNamespacedURL(apiRoot: URL, path: String, namespace: String?) -> URL {
- guard let rawNamespace = namespace else {
- return apiRoot.appending(rawPath: path)
- }
-
- let namespace = rawNamespace.hasSuffix("/") ? rawNamespace : rawNamespace + "/"
-
- // Parse the path to find where to insert the namespace
- // Path format is typically: /prefix/version/endpoint (e.g., /wp/v2/posts or /wp-block-editor/v1/settings)
- let components = path.split(separator: "/", omittingEmptySubsequences: true)
- guard components.count >= 2 else {
- return apiRoot.appending(rawPath: path)
- }
-
- // Insert namespace after the version segment (second component)
- // e.g., /wp-block-editor/v1/settings -> /wp-block-editor/v1/sites/123/settings
- let prefix = components[0]
- let version = components[1]
- let remainder = components.dropFirst(2).joined(separator: "/")
-
- let namespacedPath: String
- if remainder.isEmpty {
- namespacedPath = "/\(prefix)/\(version)/\(namespace)"
- } else {
- namespacedPath = "/\(prefix)/\(version)/\(namespace)\(remainder)"
- }
-
- return apiRoot.appending(rawPath: namespacedPath)
- }
-
/// Clears all cached API responses.
public func purge() throws {
try self.cache.clear()
@@ -110,7 +78,7 @@ public struct RESTAPIRepository: Sendable {
private func buildPostUrl(id: Int) -> URL {
let restNamespace = configuration.postType.restNamespace
let restBase = configuration.postType.restBase
- return Self.buildNamespacedURL(
+ return WordPressRESTURL.namespaced(
apiRoot: configuration.siteApiRoot,
path: "/\(restNamespace)/\(restBase)/\(id)",
namespace: configuration.siteApiNamespace.first
@@ -155,7 +123,7 @@ public struct RESTAPIRepository: Sendable {
}
private func buildPostTypeUrl(type: String) -> URL {
- Self.buildNamespacedURL(
+ WordPressRESTURL.namespaced(
apiRoot: configuration.siteApiRoot,
path: "/wp/v2/types/\(type)",
namespace: configuration.siteApiNamespace.first
diff --git a/ios/Sources/GutenbergKit/Sources/WordPressRESTURL.swift b/ios/Sources/GutenbergKit/Sources/WordPressRESTURL.swift
new file mode 100644
index 000000000..60b55a998
--- /dev/null
+++ b/ios/Sources/GutenbergKit/Sources/WordPressRESTURL.swift
@@ -0,0 +1,38 @@
+import Foundation
+
+/// Single source of truth for building namespaced WordPress REST API URLs, so the
+/// media endpoint and every ``RESTAPIRepository`` endpoint normalize the site API
+/// root and namespace identically (no drift).
+enum WordPressRESTURL {
+ /// Builds a URL by inserting the site API namespace after the version segment
+ /// of the path. For example, `/wp/v2/posts` with namespace `sites/123` becomes
+ /// `/wp/v2/sites/123/posts`. A `nil` namespace appends the path unchanged.
+ ///
+ /// Trailing slashes on the root and namespace are normalized, so an unslashed
+ /// `apiRoot` or `namespace` still joins cleanly.
+ static func namespaced(apiRoot: URL, path: String, namespace: String?) -> URL {
+ guard let rawNamespace = namespace else {
+ return apiRoot.appending(rawPath: path)
+ }
+
+ let namespace = rawNamespace.hasSuffix("/") ? rawNamespace : rawNamespace + "/"
+
+ // Path format is typically /prefix/version/endpoint
+ // (e.g. /wp/v2/posts or /wp-block-editor/v1/settings).
+ let components = path.split(separator: "/", omittingEmptySubsequences: true)
+ guard components.count >= 2 else {
+ return apiRoot.appending(rawPath: path)
+ }
+
+ // Insert the namespace after the version segment (second component).
+ let prefix = components[0]
+ let version = components[1]
+ let remainder = components.dropFirst(2).joined(separator: "/")
+
+ let namespacedPath = remainder.isEmpty
+ ? "/\(prefix)/\(version)/\(namespace)"
+ : "/\(prefix)/\(version)/\(namespace)\(remainder)"
+
+ return apiRoot.appending(rawPath: namespacedPath)
+ }
+}
diff --git a/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift b/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift
new file mode 100644
index 000000000..db48652cf
--- /dev/null
+++ b/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift
@@ -0,0 +1,41 @@
+import Foundation
+
+/// CORS behavior for an ``HTTPServer``.
+public enum CORSPolicy: Sendable {
+ /// No CORS headers are added (the default).
+ case none
+
+ /// Permissive CORS for a loopback-only server serving a WebView: allows any
+ /// origin and the methods/headers this library's clients use. The server
+ /// answers OPTIONS preflight requests itself and stamps these headers on
+ /// every response — including ones it generates internally (timeouts, parse
+ /// errors) that never reach the handler.
+ case permissive
+
+ /// Headers added to every response under this policy.
+ var responseHeaders: [(String, String)] {
+ switch self {
+ case .none:
+ []
+ case .permissive:
+ [
+ ("Access-Control-Allow-Origin", "*"),
+ ("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"),
+ ("Access-Control-Allow-Headers", "Authorization, Relay-Authorization, Content-Type"),
+ ("Access-Control-Max-Age", "86400"),
+ ]
+ }
+ }
+}
+
+extension HTTPResponse {
+ /// Returns a copy with `newHeaders` appended, skipping any whose name
+ /// (case-insensitive) is already present.
+ func addingHeadersIfAbsent(_ newHeaders: [(String, String)]) -> HTTPResponse {
+ guard !newHeaders.isEmpty else { return self }
+ let existing = Set(headers.map { $0.0.lowercased() })
+ let toAdd = newHeaders.filter { !existing.contains($0.0.lowercased()) }
+ guard !toAdd.isEmpty else { return self }
+ return HTTPResponse(status: status, statusText: statusText, headers: headers + toAdd, body: body)
+ }
+}
diff --git a/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift b/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift
index 293c92773..ddfc46c2c 100644
--- a/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift
+++ b/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift
@@ -27,6 +27,10 @@ public final class HTTPRequestParser: @unchecked Sendable {
case needsMoreData
/// Headers have been fully received but the body is still incomplete.
case headersComplete
+ /// The request body exceeds the maximum allowed size and is being
+ /// drained (read and discarded) so the server can send a clean 413
+ /// response. No body bytes are buffered in this state.
+ case draining
/// All data has been received (headers and body).
case complete
}
@@ -46,7 +50,7 @@ public final class HTTPRequestParser: @unchecked Sendable {
private var buffer: Buffer
private let maxBodySize: Int64
private let inMemoryBodyThreshold: Int
- private var bytesWritten: Int = 0
+ private var bytesWritten: Int64 = 0
private var _state: State = .needsMoreData
// Lightweight scan results (populated by append)
@@ -103,6 +107,15 @@ public final class HTTPRequestParser: @unchecked Sendable {
lock.withLock { _state }
}
+ /// The parse error detected during buffering, if any.
+ ///
+ /// Non-fatal errors like ``HTTPRequestParseError/payloadTooLarge`` are
+ /// exposed here instead of being thrown by ``parseRequest()``, allowing
+ /// the caller to still access the parsed headers.
+ public var parseError: HTTPRequestParseError? {
+ lock.withLock { _parseError }
+ }
+
/// The expected body length from `Content-Length`, available once headers have been received.
public var expectedBodyLength: Int64? {
lock.withLock {
@@ -124,12 +137,16 @@ public final class HTTPRequestParser: @unchecked Sendable {
try lock.withLock {
guard _state.hasHeaders else { return nil }
- if let error = _parseError {
+ // Recoverable errors (e.g. payloadTooLarge — valid headers, rejected
+ // body) are surfaced to the caller so the handler can build a
+ // response. Fatal errors indicate genuinely malformed requests and
+ // are thrown, closing the connection before the handler runs.
+ if let error = _parseError, error.disposition == .fatal {
throw error
}
if _parsedHeaders == nil {
- let headerData = try buffer.read(from: 0, maxLength: min(bytesWritten, Self.maxHeaderSize))
+ let headerData = try buffer.read(from: 0, maxLength: Int(min(bytesWritten, Int64(Self.maxHeaderSize))))
switch HTTPRequestSerializer.parseHeaders(from: headerData) {
case .parsed(let headers):
_parsedHeaders = headers
@@ -143,7 +160,11 @@ public final class HTTPRequestParser: @unchecked Sendable {
guard let headers = _parsedHeaders else { return nil }
- guard _state.isComplete else {
+ // Return partial (headers only) when the body was rejected or
+ // hasn't fully arrived yet. The payloadTooLarge case goes through
+ // drain mode which discards body bytes without buffering them, so
+ // there is no body to extract even though the state is .complete.
+ guard _state.isComplete, _parseError == nil else {
return .partial(
method: headers.method,
target: headers.target,
@@ -179,6 +200,17 @@ public final class HTTPRequestParser: @unchecked Sendable {
lock.withLock {
guard !_state.isComplete else { return }
+ // In drain mode, discard bytes without buffering and check
+ // whether the full Content-Length has been consumed.
+ if case .draining = _state {
+ bytesWritten += Int64(data.count)
+ if let offset = headerEndOffset,
+ bytesWritten - Int64(offset) >= expectedContentLength {
+ _state = .complete
+ }
+ return
+ }
+
let accepted: Bool
do {
accepted = try buffer.append(data)
@@ -192,12 +224,12 @@ public final class HTTPRequestParser: @unchecked Sendable {
_state = .complete
return
}
- bytesWritten += data.count
+ bytesWritten += Int64(data.count)
if headerEndOffset == nil {
let buffered: Data
do {
- buffered = try buffer.read(from: 0, maxLength: min(bytesWritten, Self.maxHeaderSize))
+ buffered = try buffer.read(from: 0, maxLength: Int(min(bytesWritten, Int64(Self.maxHeaderSize))))
} catch {
_parseError = .bufferIOError
_state = .complete
@@ -215,7 +247,7 @@ public final class HTTPRequestParser: @unchecked Sendable {
let effectiveData = buffered[scanStart...]
guard let separatorRange = effectiveData.range(of: separator) else {
- if bytesWritten > Self.maxHeaderSize {
+ if bytesWritten > Int64(Self.maxHeaderSize) {
_parseError = .headersTooLarge
_state = .complete
} else {
@@ -236,15 +268,23 @@ public final class HTTPRequestParser: @unchecked Sendable {
if expectedContentLength > maxBodySize {
_parseError = .payloadTooLarge
- _state = .complete
+ // Check if the body bytes already received in this
+ // chunk satisfy the drain — small requests may arrive
+ // as a single read.
+ if let offset = headerEndOffset,
+ bytesWritten - Int64(offset) >= expectedContentLength {
+ _state = .complete
+ } else {
+ _state = .draining
+ }
return
}
}
guard let offset = headerEndOffset else { return }
- let bodyBytesAvailable = bytesWritten - offset
+ let bodyBytesAvailable = bytesWritten - Int64(offset)
- if Int64(bodyBytesAvailable) >= expectedContentLength {
+ if bodyBytesAvailable >= expectedContentLength {
_state = .complete
} else {
_state = .headersComplete
@@ -403,14 +443,16 @@ extension HTTPRequestParser.State {
/// Whether all data has been received (headers and body).
public var isComplete: Bool {
- if case .complete = self { return true }
- return false
+ switch self {
+ case .complete: return true
+ case .needsMoreData, .headersComplete, .draining: return false
+ }
}
- /// Whether headers have been fully received (true for both `.headersComplete` and `.complete`).
+ /// Whether headers have been fully received (true for `.headersComplete`, `.draining`, and `.complete`).
public var hasHeaders: Bool {
switch self {
- case .headersComplete, .complete: return true
+ case .headersComplete, .draining, .complete: return true
case .needsMoreData: return false
}
}
diff --git a/ios/Sources/GutenbergKitHTTP/HTTPRequestSerializer.swift b/ios/Sources/GutenbergKitHTTP/HTTPRequestSerializer.swift
index 2e90b7c89..086b89f98 100644
--- a/ios/Sources/GutenbergKitHTTP/HTTPRequestSerializer.swift
+++ b/ios/Sources/GutenbergKitHTTP/HTTPRequestSerializer.swift
@@ -1,7 +1,7 @@
import Foundation
/// Errors thrown when parsing an HTTP/1.1 request fails due to RFC 7230/9112 violations.
-public enum HTTPRequestParseError: Error, Sendable, Equatable, LocalizedError {
+public enum HTTPRequestParseError: Error, Sendable, Equatable, LocalizedError, CaseIterable {
/// The header section before `\r\n\r\n` is empty (e.g., `\r\n\r\n` with no request line).
case emptyHeaderSection
/// The request line does not contain at least a method and target (RFC 9112 §3).
@@ -37,6 +37,34 @@ public enum HTTPRequestParseError: Error, Sendable, Equatable, LocalizedError {
/// An I/O error occurred while buffering the request (e.g. disk full).
case bufferIOError
+ /// How the parser disposes of a parse error.
+ public enum Disposition: Sendable {
+ /// Abort the connection; the malformed request never reaches the handler.
+ case fatal
+ /// Surface to the handler via ``HTTPRequestParser/parseError`` so it can
+ /// build a response.
+ case recoverable
+ }
+
+ /// Whether this error aborts the connection (``Disposition/fatal``) or is
+ /// surfaced to the handler (``Disposition/recoverable``).
+ ///
+ /// Only genuinely recoverable errors — where the request line and headers are
+ /// well-formed — may be recoverable; anything smuggling-relevant (framing,
+ /// Content-Length) must stay fatal so the request never reaches the handler.
+ public var disposition: Disposition {
+ switch self {
+ case .payloadTooLarge:
+ return .recoverable
+ case .emptyHeaderSection, .malformedRequestLine, .obsFoldDetected,
+ .whitespaceBeforeColon, .invalidContentLength, .conflictingContentLength,
+ .unsupportedTransferEncoding, .invalidHTTPVersion, .invalidFieldName,
+ .invalidFieldValue, .missingHostHeader, .multipleHostHeaders,
+ .headersTooLarge, .tooManyHeaders, .invalidEncoding, .bufferIOError:
+ return .fatal
+ }
+ }
+
/// The HTTP status code that should be sent for this error.
public var httpStatus: Int {
switch self {
diff --git a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift
index 485cc9b79..0860da1d4 100644
--- a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift
+++ b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift
@@ -75,6 +75,16 @@ public final class HTTPServer: Sendable {
public let parsed: ParsedHTTPRequest
/// Time spent receiving and parsing the request.
public let parseDuration: Duration
+ /// A server-detected error that occurred after headers were parsed
+ /// (e.g., payload too large). When set, the handler is responsible
+ /// for building an appropriate error response.
+ public let serverError: HTTPRequestParseError?
+
+ init(parsed: ParsedHTTPRequest, parseDuration: Duration, serverError: HTTPRequestParseError? = nil) {
+ self.parsed = parsed
+ self.parseDuration = parseDuration
+ self.serverError = serverError
+ }
}
public typealias Response = HTTPResponse
@@ -92,12 +102,17 @@ public final class HTTPServer: Sendable {
private let queue: DispatchQueue
private let connectionTasks: ConnectionTasks
- private init(listener: NWListener, port: UInt16, queue: DispatchQueue, token: String, connectionTasks: ConnectionTasks) {
+ /// Sweeps crash-orphaned temp files off the caller's startup path.
+ /// Exposed so tests can await completion.
+ let cleanupTask: Task
+
+ private init(listener: NWListener, port: UInt16, queue: DispatchQueue, token: String, connectionTasks: ConnectionTasks, cleanupTask: Task) {
self.listener = listener
self.port = port
self.queue = queue
self.token = token
self.connectionTasks = connectionTasks
+ self.cleanupTask = cleanupTask
}
/// The default maximum number of concurrent connections.
@@ -110,6 +125,12 @@ public final class HTTPServer: Sendable {
/// If no data arrives within this interval, the connection is closed with a 408 response.
public static let defaultIdleTimeout: Duration = .seconds(5)
+ /// The default maximum time to wait for the listener to become ready (5 seconds).
+ /// Binding to loopback normally completes in milliseconds; the bound exists so a
+ /// listener stuck in the `.waiting` state (which emits no further updates)
+ /// cannot suspend its caller indefinitely.
+ public static let defaultStartTimeout: Duration = .seconds(5)
+
/// The maximum number of bytes to read from the network in a single receive call.
private static let readChunkSize: Int = 65536
@@ -134,10 +155,14 @@ public final class HTTPServer: Sendable {
/// the connection. Defaults to 30 seconds.
/// - idleTimeout: The maximum time to wait between consecutive reads before closing
/// the connection. Prevents slow-loris attacks. Defaults to 5 seconds.
+ /// - startTimeout: The maximum time to wait for the listener to become ready
+ /// before giving up with ``HTTPServerError/failedToStart``. Bounds a listener
+ /// stuck in the `.waiting` state. Defaults to 5 seconds.
/// - handler: A closure invoked for each fully-parsed request. Return an ``HTTPResponse``
/// to send back to the client.
/// - Returns: A running ``HTTPServer`` instance.
- /// - Throws: ``HTTPServerError/failedToStart`` if the listener cannot bind to the port.
+ /// - Throws: ``HTTPServerError/failedToStart`` if the listener cannot bind to the port
+ /// or does not become ready within `startTimeout`.
public static func start(
name: String,
port: UInt16? = nil,
@@ -147,6 +172,8 @@ public final class HTTPServer: Sendable {
maxConnections: Int = HTTPServer.defaultMaxConnections,
readTimeout: Duration = HTTPServer.defaultReadTimeout,
idleTimeout: Duration = HTTPServer.defaultIdleTimeout,
+ startTimeout: Duration = HTTPServer.defaultStartTimeout,
+ cors: CORSPolicy = .none,
handler: @escaping @Sendable (HTTPServer.Request) async -> HTTPResponse
) async throws -> HTTPServer {
// Sanitize to prevent path traversal — only allow safe filename characters.
@@ -158,10 +185,14 @@ public final class HTTPServer: Sendable {
let tempDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent("GutenbergKitHTTP-\(safeName)")
- // Clean up temp files left behind by previous runs (e.g., crash or process kill).
- // Swift's ARC guarantees deterministic cleanup during normal operation, but a
- // crash can leave orphaned files in the system temp directory.
- cleanOrphanedTempFiles(in: tempDirectory)
+ // Clean up temp files left behind by previous runs (e.g., crash or process
+ // kill), off the caller's startup path. Swift's ARC guarantees deterministic
+ // cleanup during normal operation, but a crash can leave orphaned files in
+ // the system temp directory. The sweep's one-hour age threshold means it
+ // cannot race temp files written by this (or any live) server instance.
+ let cleanupTask = Task.detached(priority: .utility) {
+ cleanOrphanedTempFiles(in: tempDirectory)
+ }
try? FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
let parameters = NWParameters.tcp
@@ -186,41 +217,83 @@ public final class HTTPServer: Sendable {
connection, queue: queue, token: token,
requiresAuthentication: requiresAuth,
maxRequestBodySize: maxRequestBodySize, readTimeout: readTimeout,
- idleTimeout: idleTimeout, tempDirectory: tempDirectory,
+ idleTimeout: idleTimeout, cors: cors, tempDirectory: tempDirectory,
connectionCounter: connectionCounter, connectionTasks: connectionTasks, handler: handler
)
}
// Bridge listener state callbacks to an AsyncStream so we can await readiness.
// The listener is started synchronously — only the wait is async.
- let states = AsyncStream { continuation in
- listener.stateUpdateHandler = { state in
- continuation.yield(state)
- }
+ let (states, statesContinuation) = AsyncStream.makeStream(of: NWListener.State.self)
+ listener.stateUpdateHandler = { state in
+ statesContinuation.yield(state)
}
listener.start(queue: queue)
- for await state in states {
- switch state {
- case .ready:
- listener.stateUpdateHandler = nil
- guard let p = listener.port else {
- throw HTTPServerError.failedToStart
- }
- let server = HTTPServer(listener: listener, port: p.rawValue, queue: queue, token: token, connectionTasks: connectionTasks)
- Logger.httpServer.info("HTTP server started on port \(p.rawValue)")
- return server
- case .failed(let error):
- Logger.httpServer.error("Listener failed: \(error)")
- throw HTTPServerError.failedToStart
- case .cancelled:
+ guard let terminalState = await firstTerminalState(in: states, timeout: startTimeout) else {
+ // No terminal state within the timeout: the listener is stuck in
+ // `.setup` or `.waiting` (which emit no further state updates), or
+ // the surrounding task was cancelled. Cancel the half-started
+ // listener so it doesn't leak.
+ listener.stateUpdateHandler = nil
+ listener.cancel()
+ Logger.httpServer.error("Listener not ready within \(startTimeout); giving up")
+ throw HTTPServerError.failedToStart
+ }
+
+ listener.stateUpdateHandler = nil
+ switch terminalState {
+ case .ready:
+ guard let p = listener.port else {
+ listener.cancel()
throw HTTPServerError.failedToStart
- default:
- continue
}
+ let server = HTTPServer(listener: listener, port: p.rawValue, queue: queue, token: token, connectionTasks: connectionTasks, cleanupTask: cleanupTask)
+ Logger.httpServer.info("HTTP server started on port \(p.rawValue)")
+ return server
+ case .failed(let error):
+ Logger.httpServer.error("Listener failed: \(error)")
+ listener.cancel()
+ throw HTTPServerError.failedToStart
+ default: // .cancelled
+ throw HTTPServerError.failedToStart
}
+ }
- throw HTTPServerError.failedToStart
+ /// Awaits the first terminal listener state (`.ready`, `.failed`, or
+ /// `.cancelled`) on `states`, skipping non-terminal states (`.setup`,
+ /// `.waiting`), or returns nil once `timeout` elapses without one.
+ ///
+ /// Internal for testability: a listener stuck in `.waiting` cannot be
+ /// reproduced deterministically with a real `NWListener`, but the wait's
+ /// behavior can be verified by feeding this a hand-built state stream.
+ static func firstTerminalState(
+ in states: AsyncStream,
+ timeout: Duration
+ ) async -> NWListener.State? {
+ await withTaskGroup(of: NWListener.State?.self) { group in
+ group.addTask {
+ for await state in states {
+ switch state {
+ case .ready, .failed, .cancelled:
+ return state
+ default:
+ continue
+ }
+ }
+ // The stream finished (or the task was cancelled) without a
+ // terminal state.
+ return nil
+ }
+ group.addTask {
+ try? await Task.sleep(for: timeout)
+ return nil
+ }
+ // First child to finish wins: a terminal state, or nil on timeout.
+ let first = await group.next() ?? nil
+ group.cancelAll()
+ return first
+ }
}
/// Stops the server and releases resources.
@@ -248,6 +321,7 @@ public final class HTTPServer: Sendable {
maxRequestBodySize: Int64,
readTimeout: Duration,
idleTimeout: Duration,
+ cors: CORSPolicy,
tempDirectory: URL,
connectionCounter: ConnectionCounter,
connectionTasks: ConnectionTasks,
@@ -275,8 +349,11 @@ public final class HTTPServer: Sendable {
throw HTTPServerError.connectionClosed
}
- // Check auth before consuming body to avoid buffering
- // up to maxRequestBodySize for unauthenticated clients.
+ // Check auth on headers alone, before draining or
+ // consuming any body bytes — an unauthenticated client
+ // must not be able to make the server read (and
+ // discard) an arbitrarily large body, and the handler
+ // must never see an unauthenticated request.
// OPTIONS is exempt because CORS preflight requests
// never include credentials (Fetch spec §3.3.5).
if requiresAuthentication && partial.method.uppercased() != "OPTIONS" {
@@ -285,6 +362,20 @@ public final class HTTPServer: Sendable {
}
}
+ // Drain the oversized body before responding so the
+ // (authenticated) client receives the 413 instead of
+ // a connection reset (RFC 9110 §15.5.14).
+ if parser.state == .draining {
+ try await Self.receiveUntil(\.isComplete, parser: parser, on: connection, idleTimeout: idleTimeout)
+ }
+
+ // If the parser detected a non-fatal error (e.g.,
+ // payload too large after drain), return the partial
+ // request so the handler can build the response.
+ if parser.parseError != nil {
+ return partial
+ }
+
// Reject body-bearing methods without Content-Length.
// We don't support Transfer-Encoding: chunked, so
// Content-Length is the only way to determine body size.
@@ -313,21 +404,28 @@ public final class HTTPServer: Sendable {
}
}
- let response = await handler(Request(parsed: request, parseDuration: duration))
- await send(response, on: connection)
+ // Under a permissive CORS policy the library answers the OPTIONS
+ // preflight itself; the send layer stamps the CORS headers.
+ let response: HTTPResponse
+ if cors == .permissive, request.method.uppercased() == "OPTIONS" {
+ response = HTTPResponse(status: 204)
+ } else {
+ response = await handler(Request(parsed: request, parseDuration: duration, serverError: parser.parseError))
+ }
+ await send(response, on: connection, cors: cors)
let (sec, atto) = duration.components
let ms = Double(sec) * 1000.0 + Double(atto) / 1_000_000_000_000_000.0
Logger.httpServer.debug("\(request.method) \(request.target) → \(response.status) (\(String(format: "%.1f", ms))ms)")
} catch HTTPServerError.authenticationFailed {
- await send(HTTPResponse(status: 407, headers: [("Content-Type", "text/plain"), ("Proxy-Authenticate", "Bearer")]), on: connection)
+ await send(HTTPResponse(status: 407, headers: [("Content-Type", "text/plain"), ("Proxy-Authenticate", "Bearer")]), on: connection, cors: cors)
} catch HTTPServerError.lengthRequired {
- await send(HTTPResponse(status: 411, statusText: "Length Required", body: Data("Length Required".utf8)), on: connection)
+ await send(HTTPResponse(status: 411, statusText: "Length Required", body: Data("Length Required".utf8)), on: connection, cors: cors)
} catch is CancellationError {
Logger.httpServer.debug("Connection cancelled during shutdown")
connection.cancel()
} catch HTTPServerError.readTimeout {
Logger.httpServer.warning("Read timeout, closing connection")
- await send(HTTPResponse(status: 408, statusText: "Request Timeout", body: Data("Request Timeout".utf8)), on: connection)
+ await send(HTTPResponse(status: 408, statusText: "Request Timeout", body: Data("Request Timeout".utf8)), on: connection, cors: cors)
} catch let error as HTTPRequestParseError {
Logger.httpServer.error("Parse error: \(error)")
let statusText = String(error.httpStatusText)
@@ -336,10 +434,10 @@ public final class HTTPServer: Sendable {
statusText: statusText,
body: Data(statusText.utf8)
)
- await send(response, on: connection)
+ await send(response, on: connection, cors: cors)
} catch {
Logger.httpServer.error("Unexpected error: \(error)")
- await send(HTTPResponse(status: 400, statusText: "Bad Request", body: Data("Malformed HTTP request".utf8)), on: connection)
+ await send(HTTPResponse(status: 400, statusText: "Bad Request", body: Data("Malformed HTTP request".utf8)), on: connection, cors: cors)
}
}
connectionTasks.track(taskID, task)
@@ -454,9 +552,10 @@ public final class HTTPServer: Sendable {
}
/// Sends a response on the connection and then closes it.
- private static func send(_ response: HTTPResponse, on connection: NWConnection) async {
+ private static func send(_ response: HTTPResponse, on connection: NWConnection, cors: CORSPolicy) async {
+ let decorated = response.addingHeadersIfAbsent(cors.responseHeaders)
await withCheckedContinuation { (continuation: CheckedContinuation) in
- connection.send(content: response.serialized(), completion: .contentProcessed { _ in
+ connection.send(content: decorated.serialized(), completion: .contentProcessed { _ in
connection.cancel()
continuation.resume()
})
@@ -546,19 +645,24 @@ public final class HTTPServer: Sendable {
/// to a single server instance and will not affect files belonging to other
/// servers running concurrently.
///
- /// **Important:** Two server instances with the same `name` must not run
- /// concurrently. On startup, this method deletes **all** files in the
- /// server's temp subdirectory. If another instance with the same name is
- /// still handling requests, its in-flight temp files will be removed,
- /// causing `bufferIOError` failures. Callers must ensure each running
- /// server uses a unique name, or that the previous instance is fully
- /// stopped before starting a new one.
+ /// Only files older than one hour are deleted. Fresh files are preserved so
+ /// the sweep — which runs detached from `start()` — cannot race in-flight
+ /// temp files, whether they belong to this instance or another live server
+ /// sharing the same `name`.
private static func cleanOrphanedTempFiles(in directory: URL) {
+ // Only delete files past the age threshold. Fresh files may belong to a
+ // live server instance — the sweep runs detached from start(), so
+ // without the threshold it could race and delete an in-flight
+ // request's temp buffer.
+ let cutoff = Date(timeIntervalSinceNow: -3600) // 1 hour ago
guard let contents = try? FileManager.default.contentsOfDirectory(
- at: directory, includingPropertiesForKeys: nil
+ at: directory, includingPropertiesForKeys: [.contentModificationDateKey]
) else { return }
for url in contents {
- try? FileManager.default.removeItem(at: url)
+ let modified = (try? url.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate
+ if let modified, modified < cutoff {
+ try? FileManager.default.removeItem(at: url)
+ }
}
}
}
diff --git a/ios/Sources/GutenbergKitHTTP/ParsedHTTPRequest.swift b/ios/Sources/GutenbergKitHTTP/ParsedHTTPRequest.swift
index d68774921..95222d1c0 100644
--- a/ios/Sources/GutenbergKitHTTP/ParsedHTTPRequest.swift
+++ b/ios/Sources/GutenbergKitHTTP/ParsedHTTPRequest.swift
@@ -32,6 +32,29 @@ extension ParsedHTTPRequest {
}
}
+ /// The path portion of ``target``, without the query component
+ /// (e.g., "/wp/v2/posts" for "/wp/v2/posts?per_page=10").
+ ///
+ /// Use this for routing — matching against ``target`` fails as soon as a
+ /// client appends a query string.
+ public var path: String {
+ let target = target
+ guard let separator = target.firstIndex(of: "?") else { return target }
+ return String(target.prefix(upTo: separator))
+ }
+
+ /// The query component of ``target``, including the leading "?"
+ /// (e.g., "?per_page=10"), or an empty string when there is no query.
+ ///
+ /// A bare trailing "?" carries no parameters and yields an empty string, so
+ /// the value can be appended to an upstream URL unconditionally.
+ public var query: String {
+ let target = target
+ guard let separator = target.firstIndex(of: "?") else { return "" }
+ let value = String(target[target.index(after: separator)...])
+ return value.isEmpty ? "" : "?\(value)"
+ }
+
/// The HTTP-version from the request line (e.g., "HTTP/1.1"), per RFC 9112 §2.3.
public var httpVersion: String {
switch self {
diff --git a/ios/Tests/GutenbergKitHTTPTests/FixtureTests.swift b/ios/Tests/GutenbergKitHTTPTests/FixtureTests.swift
index f361ccdb7..0acc1726c 100644
--- a/ios/Tests/GutenbergKitHTTPTests/FixtureTests.swift
+++ b/ios/Tests/GutenbergKitHTTPTests/FixtureTests.swift
@@ -255,10 +255,18 @@ struct RequestParsingFixtureTests {
let expectedError = testCase.expected.error
do {
_ = try parser.parseRequest()
- Issue.record("Expected error \(expectedError) but parsing succeeded — \(testCase.description)")
+ // A recoverable error is surfaced via `parseError` instead of thrown.
+ if let parseError = parser.parseError {
+ let errorName = String(describing: parseError)
+ #expect(errorName == expectedError, "\(testCase.description): expected \(expectedError) but got \(errorName)")
+ #expect(parseError.disposition == .recoverable, "\(testCase.description): \(errorName) surfaced via parseError but is not recoverable")
+ } else {
+ Issue.record("Expected error \(expectedError) but parsing succeeded — \(testCase.description)")
+ }
} catch {
let errorName = String(describing: error)
#expect(errorName == expectedError, "\(testCase.description): expected \(expectedError) but got \(errorName)")
+ #expect((error as? HTTPRequestParseError)?.disposition == .fatal, "\(testCase.description): \(errorName) was thrown but is not fatal")
}
}
diff --git a/ios/Tests/GutenbergKitHTTPTests/HTTPRequestParserTests.swift b/ios/Tests/GutenbergKitHTTPTests/HTTPRequestParserTests.swift
index f4df6bd08..43b255352 100644
--- a/ios/Tests/GutenbergKitHTTPTests/HTTPRequestParserTests.swift
+++ b/ios/Tests/GutenbergKitHTTPTests/HTTPRequestParserTests.swift
@@ -5,6 +5,17 @@ import Testing
@Suite("HTTPRequestParser")
struct HTTPRequestParserTests {
+ // MARK: - Error Disposition
+
+ /// Locks the fatal/recoverable classification so a refactor can't silently
+ /// make a smuggling-relevant error recoverable — which would let a malformed
+ /// request reach the handler before auth.
+ @Test("only payloadTooLarge is recoverable")
+ func onlyPayloadTooLargeIsRecoverable() {
+ let recoverable = HTTPRequestParseError.allCases.filter { $0.disposition == .recoverable }
+ #expect(recoverable == [.payloadTooLarge])
+ }
+
// MARK: - Basic Request Parsing
@Test("parses a simple GET request")
@@ -389,15 +400,24 @@ struct HTTPRequestParserTests {
// MARK: - Max Body Size
- @Test("rejects request when Content-Length exceeds maxBodySize")
- func rejectsOversizedContentLength() {
+ @Test("drains oversized body and returns partial with parseError")
+ func rejectsOversizedContentLength() throws {
let parser = HTTPRequestParser(maxBodySize: 100)
parser.append(Data("POST /upload HTTP/1.1\r\nHost: localhost\r\nContent-Length: 101\r\n\r\n".utf8))
+ // Parser enters drain mode — not yet complete.
+ #expect(parser.state == .draining)
+
+ // Feed the remaining body bytes to complete the drain.
+ parser.append(Data(repeating: 0x41, count: 101))
#expect(parser.state.isComplete)
- #expect(throws: HTTPRequestParseError.payloadTooLarge) {
- try parser.parseRequest()
- }
+
+ // parseRequest() returns partial headers instead of throwing.
+ let request = try #require(try parser.parseRequest())
+ #expect(request.method == "POST")
+ #expect(request.target == "/upload")
+ #expect(!request.isComplete)
+ #expect(parser.parseError == .payloadTooLarge)
}
@Test("accepts request when Content-Length equals maxBodySize")
@@ -424,16 +444,48 @@ struct HTTPRequestParserTests {
#expect(try readAll(requestBody) == Data(body.utf8))
}
- @Test("rejects oversized Content-Length even when body data hasn't arrived")
- func rejectsOversizedBeforeBodyArrives() {
+ @Test("enters drain mode for oversized Content-Length even when body hasn't arrived")
+ func rejectsOversizedBeforeBodyArrives() throws {
let parser = HTTPRequestParser(maxBodySize: 50)
parser.append(Data("POST /upload HTTP/1.1\r\nHost: localhost\r\nContent-Length: 999999\r\n\r\n".utf8))
- // Parser should mark complete immediately without waiting for body bytes
- #expect(parser.state.isComplete)
- #expect(throws: HTTPRequestParseError.payloadTooLarge) {
- try parser.parseRequest()
+ // Parser enters drain mode — headers are available but not yet complete.
+ #expect(parser.state == .draining)
+ #expect(parser.state.hasHeaders)
+ #expect(!parser.state.isComplete)
+
+ // Feed body bytes in chunks to complete the drain.
+ let chunkSize = 8192
+ var remaining = 999999
+ while remaining > 0 {
+ let size = min(chunkSize, remaining)
+ parser.append(Data(repeating: 0x42, count: size))
+ remaining -= size
}
+
+ #expect(parser.state.isComplete)
+ let request = try #require(try parser.parseRequest())
+ #expect(request.method == "POST")
+ #expect(!request.isComplete)
+ #expect(parser.parseError == .payloadTooLarge)
+ }
+
+ @Test("drain mode does not buffer body bytes")
+ func drainDoesNotBuffer() throws {
+ let parser = HTTPRequestParser(maxBodySize: 10)
+ let headers = "POST /upload HTTP/1.1\r\nHost: localhost\r\nContent-Length: 1000\r\n\r\n"
+ parser.append(Data(headers.utf8))
+ #expect(parser.state == .draining)
+
+ // Feed 1000 bytes of body data.
+ parser.append(Data(repeating: 0x43, count: 1000))
+ #expect(parser.state.isComplete)
+
+ // parseRequest() returns headers; error is on parseError.
+ let request = try #require(try parser.parseRequest())
+ #expect(request.method == "POST")
+ #expect(!request.isComplete)
+ #expect(parser.parseError == .payloadTooLarge)
}
@Test("rejects headers that exceed maxHeaderSize without terminator")
diff --git a/ios/Tests/GutenbergKitHTTPTests/HTTPServerStartTests.swift b/ios/Tests/GutenbergKitHTTPTests/HTTPServerStartTests.swift
new file mode 100644
index 000000000..7c90f0ed0
--- /dev/null
+++ b/ios/Tests/GutenbergKitHTTPTests/HTTPServerStartTests.swift
@@ -0,0 +1,75 @@
+#if canImport(Network)
+
+import Foundation
+import Network
+import Testing
+@testable import GutenbergKitHTTP
+
+@Suite("HTTPServer Start")
+struct HTTPServerStartTests {
+
+ @Test("readiness wait returns nil after the timeout when only non-terminal states arrive")
+ func readinessWaitTimesOut() async {
+ // A listener stuck in `.setup`/`.waiting` emits no further state
+ // updates. Without the bound, the wait — and whatever startup path
+ // awaits `HTTPServer.start` — would suspend forever.
+ let (states, continuation) = AsyncStream.makeStream(of: NWListener.State.self)
+ continuation.yield(.setup)
+ continuation.yield(.waiting(.posix(.EADDRINUSE)))
+
+ let clock = ContinuousClock()
+ let started = clock.now
+ let result = await HTTPServer.firstTerminalState(in: states, timeout: .milliseconds(200))
+ let elapsed = clock.now - started
+ continuation.finish()
+
+ #expect(result == nil)
+ // Waited out the timeout, then returned promptly instead of hanging.
+ #expect(elapsed >= .milliseconds(150))
+ #expect(elapsed < .seconds(5))
+ }
+
+ @Test("readiness wait skips non-terminal states and returns the first terminal one")
+ func readinessWaitReturnsTerminalState() async {
+ let (states, continuation) = AsyncStream.makeStream(of: NWListener.State.self)
+ continuation.yield(.setup)
+ continuation.yield(.waiting(.posix(.EADDRINUSE)))
+ continuation.yield(.ready)
+
+ let result = await HTTPServer.firstTerminalState(in: states, timeout: .seconds(5))
+ continuation.finish()
+
+ guard case .ready = result else {
+ Issue.record("Expected .ready, got \(String(describing: result))")
+ return
+ }
+ }
+
+ @Test("start fails promptly when the port is already taken (no hang)")
+ func startFailsPromptlyOnPortConflict() async throws {
+ let first = try await HTTPServer.start(name: "start-conflict-test-a") { _ in
+ HTTPResponse(status: 200)
+ }
+ defer { first.stop() }
+
+ let clock = ContinuousClock()
+ let started = clock.now
+ await #expect(throws: HTTPServerError.self) {
+ _ = try await HTTPServer.start(
+ name: "start-conflict-test-b",
+ port: first.port,
+ startTimeout: .milliseconds(500)
+ ) { _ in
+ HTTPResponse(status: 200)
+ }
+ }
+ let elapsed = clock.now - started
+
+ // Whether the conflict surfaces as an immediate `.failed` or parks the
+ // listener in `.waiting`, start() must give up within the bounded wait
+ // rather than suspending its caller indefinitely.
+ #expect(elapsed < .seconds(5))
+ }
+}
+
+#endif
diff --git a/ios/Tests/GutenbergKitHTTPTests/ParsedHTTPRequestTests.swift b/ios/Tests/GutenbergKitHTTPTests/ParsedHTTPRequestTests.swift
index 2d37a9abe..82e17b2da 100644
--- a/ios/Tests/GutenbergKitHTTPTests/ParsedHTTPRequestTests.swift
+++ b/ios/Tests/GutenbergKitHTTPTests/ParsedHTTPRequestTests.swift
@@ -5,6 +5,47 @@ import Testing
@Suite("ParsedHTTPRequest")
struct ParsedHTTPRequestTests {
+ // MARK: - path / query
+
+ @Test(
+ "path and query split the target",
+ arguments: [
+ ("/upload", "/upload", ""),
+ ("/upload?_embed=wp:featuredmedia", "/upload", "?_embed=wp:featuredmedia"),
+ // A bare "?" carries no parameters, so the query is empty.
+ ("/upload?", "/upload", ""),
+ ("/wp/v2/posts?per_page=10&page=2", "/wp/v2/posts", "?per_page=10&page=2"),
+ // Only the first "?" separates path from query; later ones belong to it.
+ ("/search?q=a?b", "/search", "?q=a?b"),
+ ("/", "/", ""),
+ ]
+ )
+ func pathAndQuery(target: String, expectedPath: String, expectedQuery: String) {
+ let request = ParsedHTTPRequest.complete(
+ method: "POST",
+ target: target,
+ httpVersion: "HTTP/1.1",
+ headers: [:],
+ body: nil
+ )
+
+ #expect(request.path == expectedPath)
+ #expect(request.query == expectedQuery)
+ }
+
+ @Test("path and query are available on a partial request")
+ func pathAndQueryOnPartial() {
+ let request = ParsedHTTPRequest.partial(
+ method: "POST",
+ target: "/upload?_embed=wp:featuredmedia",
+ httpVersion: "HTTP/1.1",
+ headers: [:]
+ )
+
+ #expect(request.path == "/upload")
+ #expect(request.query == "?_embed=wp:featuredmedia")
+ }
+
// MARK: - urlRequest(relativeTo:)
@Test("urlRequest resolves path against base URL")
diff --git a/ios/Tests/GutenbergKitHTTPTests/RFC9112ConformanceTests.swift b/ios/Tests/GutenbergKitHTTPTests/RFC9112ConformanceTests.swift
index 8127f8ad8..7dd55a0f3 100644
--- a/ios/Tests/GutenbergKitHTTPTests/RFC9112ConformanceTests.swift
+++ b/ios/Tests/GutenbergKitHTTPTests/RFC9112ConformanceTests.swift
@@ -568,7 +568,7 @@ struct RFC9112ConformanceTests {
// MARK: - L5: Orphaned temp file cleanup
- @Test("cleanOrphanedTempFiles removes files in the server-specific temp directory")
+ @Test("cleanOrphanedTempFiles removes stale files in the server-specific temp directory")
func orphanedTempFilesCleanedOnStart() async throws {
let serverName = "orphan-cleanup-test"
let serverTempDir = FileManager.default.temporaryDirectory
@@ -577,20 +577,37 @@ struct RFC9112ConformanceTests {
let orphan1 = serverTempDir.appendingPathComponent("GutenbergKitHTTP-\(UUID().uuidString)")
let orphan2 = serverTempDir.appendingPathComponent("GutenbergKitHTTP-\(UUID().uuidString)")
+ let fresh = serverTempDir.appendingPathComponent("GutenbergKitHTTP-\(UUID().uuidString)")
let unrelated = FileManager.default.temporaryDirectory
.appendingPathComponent("SomeOtherFile-\(UUID().uuidString)")
FileManager.default.createFile(atPath: orphan1.path, contents: Data("test".utf8))
FileManager.default.createFile(atPath: orphan2.path, contents: Data("test".utf8))
+ FileManager.default.createFile(atPath: fresh.path, contents: Data("test".utf8))
FileManager.default.createFile(atPath: unrelated.path, contents: Data("test".utf8))
- defer { try? FileManager.default.removeItem(at: unrelated) }
+ defer {
+ try? FileManager.default.removeItem(at: fresh)
+ try? FileManager.default.removeItem(at: unrelated)
+ }
+ // Backdate the orphans well past the 1-hour cutoff. The fresh file
+ // stays recent — the sweep runs detached from start(), so it must
+ // preserve files that could belong to a live server instance.
+ for orphan in [orphan1, orphan2] {
+ try FileManager.default.setAttributes(
+ [.modificationDate: Date(timeIntervalSinceNow: -7200)],
+ ofItemAtPath: orphan.path
+ )
+ }
- // Start and immediately stop a server — start() calls cleanOrphanedTempFiles()
+ // start() kicks off cleanOrphanedTempFiles() off the startup path;
+ // await it before asserting.
let server = try await HTTPServer.start(name: serverName, handler: { _ in HTTPResponse(status: 200) })
+ await server.cleanupTask.value
server.stop()
#expect(!FileManager.default.fileExists(atPath: orphan1.path))
#expect(!FileManager.default.fileExists(atPath: orphan2.path))
+ #expect(FileManager.default.fileExists(atPath: fresh.path))
#expect(FileManager.default.fileExists(atPath: unrelated.path))
}
}
diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift
new file mode 100644
index 000000000..3f8ffeb79
--- /dev/null
+++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift
@@ -0,0 +1,711 @@
+import Foundation
+import GutenbergKitHTTP
+import Testing
+@testable import GutenbergKit
+
+/// Check if HTTPServer can bind in this environment (fails in some test sandboxes).
+private let _canStartUploadServer: Bool = {
+ let result = UnsafeMutableSendablePointer(false)
+ let semaphore = DispatchSemaphore(value: 0)
+ Task {
+ do {
+ let server = try await MediaUploadServer.start()
+ server.stop()
+ result.value = true
+ } catch {
+ result.value = false
+ }
+ semaphore.signal()
+ }
+ semaphore.wait()
+ return result.value
+}()
+
+/// Sendable wrapper for a mutable value, used to communicate results out of a Task.
+private final class UnsafeMutableSendablePointer: @unchecked Sendable {
+ var value: T
+ init(_ value: T) { self.value = value }
+}
+
+// MARK: - Integration Tests (require network)
+
+@Suite("MediaUploadServer Integration", .enabled(if: _canStartUploadServer))
+struct MediaUploadServerTests {
+
+ @Test("starts and provides a port and token")
+ func startAndStop() async throws {
+ let server = try await MediaUploadServer.start()
+ #expect(server.port > 0)
+ #expect(!server.token.isEmpty)
+ server.stop()
+ }
+
+ @Test("rejects requests without auth token")
+ func rejectsUnauthenticated() async throws {
+ let server = try await MediaUploadServer.start()
+ defer { server.stop() }
+
+ let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+
+ let (_, response) = try await URLSession.shared.data(for: request)
+ let httpResponse = try #require(response as? HTTPURLResponse)
+ #expect(httpResponse.statusCode == 407)
+ }
+
+ @Test("rejects requests with wrong token")
+ func rejectsWrongToken() async throws {
+ let server = try await MediaUploadServer.start()
+ defer { server.stop() }
+
+ let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer wrong-token", forHTTPHeaderField: "Relay-Authorization")
+
+ let (_, response) = try await URLSession.shared.data(for: request)
+ let httpResponse = try #require(response as? HTTPURLResponse)
+ #expect(httpResponse.statusCode == 407)
+ }
+
+ @Test("responds to OPTIONS preflight with CORS headers")
+ func corsPreflightResponse() async throws {
+ let server = try await MediaUploadServer.start()
+ defer { server.stop() }
+
+ let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
+ var request = URLRequest(url: url)
+ request.httpMethod = "OPTIONS"
+
+ let (_, response) = try await URLSession.shared.data(for: request)
+ let httpResponse = try #require(response as? HTTPURLResponse)
+ #expect(httpResponse.statusCode == 204)
+ #expect(httpResponse.value(forHTTPHeaderField: "Access-Control-Allow-Origin") == "*")
+ #expect(httpResponse.value(forHTTPHeaderField: "Access-Control-Allow-Methods")?.contains("POST") == true)
+ }
+
+ @Test("returns 404 for unknown paths")
+ func unknownPath() async throws {
+ let server = try await MediaUploadServer.start()
+ defer { server.stop() }
+
+ let url = URL(string: "http://127.0.0.1:\(server.port)/unknown")!
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
+
+ let (_, response) = try await URLSession.shared.data(for: request)
+ let httpResponse = try #require(response as? HTTPURLResponse)
+ #expect(httpResponse.statusCode == 404)
+ }
+
+ @Test("routes /upload with a query string and relays the query")
+ func uploadWithQueryString() async throws {
+ let delegate = ProcessOnlyDelegate()
+ let mockUploader = MockDefaultUploader()
+ let server = try await MediaUploadServer.start(uploadDelegate: delegate, defaultUploader: mockUploader)
+ defer { server.stop() }
+
+ // `@wordpress/media-utils` uploads to `/wp/v2/media?_embed=wp:featuredmedia`,
+ // so the middleware forwards that query on to the native server. Routing must
+ // match on the path alone, and the query must reach WordPress unchanged.
+ let boundary = UUID().uuidString
+ let body = buildMultipartBody(boundary: boundary, filename: "photo.jpg", mimeType: "image/jpeg", data: Data("fake image data".utf8))
+
+ let url = URL(string: "http://127.0.0.1:\(server.port)/upload?_embed=wp:featuredmedia")!
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
+ request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+ request.httpBody = body
+
+ let (_, response) = try await URLSession.shared.data(for: request)
+ let httpResponse = try #require(response as? HTTPURLResponse)
+ #expect(httpResponse.statusCode == 201)
+ // The delegate returns `.original`, so this is the passthrough branch.
+ // Pin which branch ran — `lastQuery` is recorded by both, so without this
+ // the query assertion would pass even if routing collapsed onto one path.
+ #expect(mockUploader.passthroughUploadCalled)
+ #expect(!mockUploader.uploadCalled)
+ #expect(mockUploader.lastQuery == "?_embed=wp:featuredmedia")
+ }
+
+ @Test("calls delegate and returns upload result")
+ func delegateProcessAndUpload() async throws {
+ let delegate = MockUploadDelegate()
+ let server = try await MediaUploadServer.start(uploadDelegate: delegate)
+ defer { server.stop() }
+
+ let boundary = UUID().uuidString
+ let fileData = "fake image data".data(using: .utf8)!
+ let body = buildMultipartBody(boundary: boundary, filename: "photo.jpg", mimeType: "image/jpeg", data: fileData)
+
+ let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
+ request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+ request.httpBody = body
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+ let httpResponse = try #require(response as? HTTPURLResponse)
+ #expect(httpResponse.statusCode == 201)
+
+ #expect(delegate.processFileCalled)
+ #expect(delegate.uploadFileCalled)
+ #expect(delegate.lastMimeType == "image/jpeg")
+ #expect(delegate.lastFilename == "photo.jpg")
+
+ // The server relays WordPress's raw response body verbatim.
+ let object = try JSONSerialization.jsonObject(with: data)
+ let json = try #require(object as? [String: Any])
+ #expect(json["id"] as? Int == 42)
+ #expect(json["source_url"] as? String == "https://example.com/photo.jpg")
+ #expect(json["media_type"] as? String == "image")
+ }
+
+ @Test("uses passthrough when delegate does not modify file")
+ func delegatePassthrough() async throws {
+ let delegate = ProcessOnlyDelegate()
+ let mockUploader = MockDefaultUploader()
+ let server = try await MediaUploadServer.start(uploadDelegate: delegate, defaultUploader: mockUploader)
+ defer { server.stop() }
+
+ let boundary = UUID().uuidString
+ let fileData = "fake data".data(using: .utf8)!
+ let body = buildMultipartBody(boundary: boundary, filename: "doc.pdf", mimeType: "application/pdf", data: fileData)
+
+ let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
+ request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+ request.httpBody = body
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+ let httpResponse = try #require(response as? HTTPURLResponse)
+ #expect(httpResponse.statusCode == 201)
+
+ #expect(delegate.processFileCalled)
+ // Passthrough: original body forwarded directly, not re-encoded.
+ #expect(mockUploader.passthroughUploadCalled)
+ #expect(!mockUploader.uploadCalled)
+
+ // The server relays WordPress's raw response body verbatim.
+ let object = try JSONSerialization.jsonObject(with: data)
+ let json = try #require(object as? [String: Any])
+ #expect(json["id"] as? Int == 99)
+ }
+
+ @Test("forwards the delegate's processed metadata to the uploader")
+ func processedMetadataForwarded() async throws {
+ let delegate = ResizingDelegate()
+ let mockUploader = MockDefaultUploader()
+ let server = try await MediaUploadServer.start(uploadDelegate: delegate, defaultUploader: mockUploader)
+ defer { server.stop() }
+
+ let boundary = UUID().uuidString
+ let body = buildMultipartBody(boundary: boundary, filename: "clip.mov", mimeType: "video/quicktime", data: Data("movie".utf8))
+
+ let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
+ request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+ request.httpBody = body
+
+ _ = try await URLSession.shared.data(for: request)
+
+ // The delegate changed the format, so the uploader must receive the new
+ // metadata — not the original video/quicktime + clip.mov.
+ #expect(mockUploader.uploadCalled)
+ #expect(mockUploader.lastUploadMimeType == "video/mp4")
+ #expect(mockUploader.lastUploadFilename == "clip.mp4")
+ }
+
+ @Test("deletes the delegate's processed file after upload")
+ func deletesProcessedFile() async throws {
+ let delegate = ResizingDelegate()
+ let mockUploader = MockDefaultUploader()
+ let server = try await MediaUploadServer.start(uploadDelegate: delegate, defaultUploader: mockUploader)
+ defer { server.stop() }
+
+ let boundary = UUID().uuidString
+ let body = buildMultipartBody(boundary: boundary, filename: "clip.mov", mimeType: "video/quicktime", data: Data("movie".utf8))
+
+ let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
+ request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+ request.httpBody = body
+
+ _ = try await URLSession.shared.data(for: request)
+
+ // The server owns the file the delegate produced and must delete it once the
+ // upload finishes — the defer in processAndUpload covers the success and throw
+ // paths alike. A leaked processed file is a full-size temp per upload.
+ let processedURL = try #require(delegate.producedURL)
+ #expect(!FileManager.default.fileExists(atPath: processedURL.path(percentEncoded: false)))
+ }
+
+ @Test("returns 413 with CORS headers when request body exceeds max size")
+ func oversizedUploadReturns413WithCORSHeaders() async throws {
+ let server = try await MediaUploadServer.start(maxRequestBodySize: 1024)
+ defer { server.stop() }
+
+ let boundary = UUID().uuidString
+ let oversizedData = Data(repeating: 0x42, count: 2048)
+ let body = buildMultipartBody(boundary: boundary, filename: "big.bin", mimeType: "application/octet-stream", data: oversizedData)
+
+ let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
+ request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+ request.httpBody = body
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+ let httpResponse = try #require(response as? HTTPURLResponse)
+ #expect(httpResponse.statusCode == 413)
+ #expect(httpResponse.value(forHTTPHeaderField: "Access-Control-Allow-Origin") == "*")
+
+ let responseBody = String(data: data, encoding: .utf8) ?? ""
+ #expect(responseBody.contains("too large"))
+ }
+
+ @Test("unauthenticated oversized request returns 407, not 413 (auth precedes drain)")
+ func oversizedUploadWithoutTokenReturns407() async throws {
+ let server = try await MediaUploadServer.start(maxRequestBodySize: 1024)
+ defer { server.stop() }
+
+ let boundary = UUID().uuidString
+ let oversizedData = Data(repeating: 0x42, count: 2048)
+ let body = buildMultipartBody(boundary: boundary, filename: "big.bin", mimeType: "application/octet-stream", data: oversizedData)
+
+ let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ // Deliberately no Relay-Authorization header.
+ request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+ request.httpBody = body
+
+ // Auth is checked on headers alone, before the oversized body is drained
+ // or the handler runs — so the request is rejected with 407, not answered
+ // with the handler's 413. An unauthenticated client must not be able to
+ // make the server read (and discard) an arbitrarily large body.
+ let (_, response) = try await URLSession.shared.data(for: request)
+ let httpResponse = try #require(response as? HTTPURLResponse)
+ #expect(httpResponse.statusCode == 407)
+ }
+
+ @Test("startup sweep deletes stale upload temps but preserves fresh ones")
+ func cleanOrphanedUploadsAgeThreshold() async throws {
+ let dir = FileManager.default.temporaryDirectory
+ .appending(component: "GutenbergKit-uploads", directoryHint: .isDirectory)
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+
+ let stale = dir.appending(component: "stale-\(UUID().uuidString)")
+ let fresh = dir.appending(component: "fresh-\(UUID().uuidString)")
+ try Data("x".utf8).write(to: stale)
+ try Data("y".utf8).write(to: fresh)
+ defer {
+ try? FileManager.default.removeItem(at: stale)
+ try? FileManager.default.removeItem(at: fresh)
+ }
+ // Backdate the stale file well past the 1-hour cutoff.
+ try FileManager.default.setAttributes(
+ [.modificationDate: Date(timeIntervalSinceNow: -7200)],
+ ofItemAtPath: stale.path(percentEncoded: false)
+ )
+
+ // start() kicks off cleanOrphanedUploads() off the editor-startup path.
+ // The sweep must delete the aged file and keep the fresh one — a flipped
+ // comparison would do the opposite and wipe an in-flight upload.
+ let server = try await MediaUploadServer.start()
+ await server.cleanupTask.value
+ server.stop()
+
+ #expect(!FileManager.default.fileExists(atPath: stale.path(percentEncoded: false)))
+ #expect(FileManager.default.fileExists(atPath: fresh.path(percentEncoded: false)))
+ }
+
+ @Test("does not strongly retain the upload delegate (weak — preserves deinit teardown)")
+ func doesNotStronglyRetainDelegate() async throws {
+ weak var weakDelegate: MockUploadDelegate?
+ let server: MediaUploadServer
+ do {
+ let delegate = MockUploadDelegate()
+ weakDelegate = delegate
+ server = try await MediaUploadServer.start(uploadDelegate: delegate)
+ }
+ defer { server.stop() }
+
+ // UploadContext holds the delegate weakly, so releasing the host's strong
+ // reference deallocates it. A strong reference here would reintroduce the
+ // EditorViewController → uploadServer → … → delegate → EditorViewController
+ // cycle, so deinit would never fire and the server would never stop.
+ #expect(weakDelegate == nil)
+ }
+
+ private func buildMultipartBody(boundary: String, filename: String, mimeType: String, data: Data) -> Data {
+ var body = Data()
+ body.append("--\(boundary)\r\n")
+ body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n")
+ body.append("Content-Type: \(mimeType)\r\n\r\n")
+ body.append(data)
+ body.append("\r\n--\(boundary)--\r\n")
+ return body
+ }
+}
+
+// MARK: - Streaming Multipart Body Tests
+
+@Suite("DefaultMediaUploader streaming multipart body")
+struct MultipartBodyStreamTests {
+
+ @Test("streaming output matches in-memory multipart format")
+ func streamMatchesInMemory() throws {
+ let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("stream-test-\(UUID().uuidString)")
+ let fileContent = Data("hello world".utf8)
+ try fileContent.write(to: tempFile)
+ defer { try? FileManager.default.removeItem(at: tempFile) }
+
+ let boundary = "test-boundary-123"
+ let filename = "photo.jpg"
+ let mimeType = "image/jpeg"
+
+ // Build expected output using the old in-memory approach.
+ var expected = Data()
+ expected.append(Data("--\(boundary)\r\n".utf8))
+ expected.append(Data("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".utf8))
+ expected.append(Data("Content-Type: \(mimeType)\r\n\r\n".utf8))
+ expected.append(fileContent)
+ expected.append(Data("\r\n--\(boundary)--\r\n".utf8))
+
+ // Build streaming output.
+ let (stream, contentLength) = try DefaultMediaUploader.multipartBodyStream(
+ fileURL: tempFile, boundary: boundary, filename: filename, mimeType: mimeType, extraFields: []
+ )
+ #expect(contentLength == expected.count)
+
+ let result = readAllFromStream(stream)
+ #expect(result == expected)
+ }
+
+ @Test("includes non-file parts (e.g. post) ahead of the file")
+ func multipartBodyIncludesExtraParts() throws {
+ let boundary = "boundary"
+ let filename = "photo.jpg"
+ let mimeType = "image/jpeg"
+ let fileContent = Data("image bytes".utf8)
+ let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("stream-extra-\(UUID().uuidString)")
+ try fileContent.write(to: tempFile)
+ defer { try? FileManager.default.removeItem(at: tempFile) }
+
+ var expected = Data()
+ expected.append(Data("--\(boundary)\r\n".utf8))
+ expected.append(Data("Content-Disposition: form-data; name=\"post\"\r\n\r\n".utf8))
+ expected.append(Data("123\r\n".utf8))
+ expected.append(Data("--\(boundary)\r\n".utf8))
+ expected.append(Data("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".utf8))
+ expected.append(Data("Content-Type: \(mimeType)\r\n\r\n".utf8))
+ expected.append(fileContent)
+ expected.append(Data("\r\n--\(boundary)--\r\n".utf8))
+
+ let (stream, contentLength) = try DefaultMediaUploader.multipartBodyStream(
+ fileURL: tempFile, boundary: boundary, filename: filename, mimeType: mimeType,
+ extraFields: [("post", Data("123".utf8))]
+ )
+ #expect(contentLength == expected.count)
+ #expect(readAllFromStream(stream) == expected)
+ }
+
+ @Test("forwards a non-UTF-8 field value verbatim")
+ func multipartBodyPreservesNonUTF8FieldValue() throws {
+ let boundary = "boundary"
+ let filename = "photo.jpg"
+ let mimeType = "image/jpeg"
+ let fileContent = Data("image bytes".utf8)
+ let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("stream-binary-\(UUID().uuidString)")
+ try fileContent.write(to: tempFile)
+ defer { try? FileManager.default.removeItem(at: tempFile) }
+
+ // A field value that is not valid UTF-8 (a lone 0xFF byte between ASCII bytes).
+ let binaryValue = Data([0x61, 0xFF, 0x62])
+
+ var expected = Data()
+ expected.append(Data("--\(boundary)\r\n".utf8))
+ expected.append(Data("Content-Disposition: form-data; name=\"blob\"\r\n\r\n".utf8))
+ expected.append(binaryValue)
+ expected.append(Data("\r\n".utf8))
+ expected.append(Data("--\(boundary)\r\n".utf8))
+ expected.append(Data("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".utf8))
+ expected.append(Data("Content-Type: \(mimeType)\r\n\r\n".utf8))
+ expected.append(fileContent)
+ expected.append(Data("\r\n--\(boundary)--\r\n".utf8))
+
+ let (stream, contentLength) = try DefaultMediaUploader.multipartBodyStream(
+ fileURL: tempFile, boundary: boundary, filename: filename, mimeType: mimeType,
+ extraFields: [("blob", binaryValue)]
+ )
+ #expect(contentLength == expected.count)
+ // The raw 0xFF byte survives — it was not coerced through String.
+ #expect(readAllFromStream(stream) == expected)
+ }
+
+ @Test("content length matches actual stream output for larger files")
+ func contentLengthAccurate() throws {
+ let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("stream-test-\(UUID().uuidString)")
+ let fileContent = Data(repeating: 0x42, count: 100_000)
+ try fileContent.write(to: tempFile)
+ defer { try? FileManager.default.removeItem(at: tempFile) }
+
+ let (stream, contentLength) = try DefaultMediaUploader.multipartBodyStream(
+ fileURL: tempFile, boundary: "boundary", filename: "big.bin", mimeType: "application/octet-stream", extraFields: []
+ )
+
+ let result = readAllFromStream(stream)
+ #expect(result.count == contentLength)
+ }
+}
+
+// MARK: - DefaultMediaUploader Relay Tests
+
+@Suite("DefaultMediaUploader relay")
+struct DefaultMediaUploaderRelayTests {
+
+ @Test("relays a non-2xx WordPress response instead of throwing")
+ func relaysErrorResponseVerbatim() async throws {
+ // A WordPress REST error body, returned with a non-2xx status.
+ let errorBody = Data(#"{"code":"rest_cannot_create","message":"Sorry, you are not allowed to upload this file type."}"#.utf8)
+ let client = RelayStubHTTPClient(statusCode: 403, body: errorBody)
+ let uploader = DefaultMediaUploader(httpClient: client, siteApiRoot: URL(string: "https://example.com/wp-json/")!)
+
+ let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("relay-\(UUID().uuidString).jpg")
+ try Data("fake image".utf8).write(to: tempFile)
+ defer { try? FileManager.default.removeItem(at: tempFile) }
+
+ // performUpload must route through performRaw, which does NOT validate status,
+ // so WordPress's 403 + body flow through verbatim. A revert to perform() would
+ // throw on the non-2xx (RelayStubHTTPClient.perform mirrors that), failing here.
+ let response = try await uploader.upload(
+ fileURL: tempFile, mimeType: "image/jpeg", filename: "photo.jpg", extraParts: [], query: ""
+ )
+
+ #expect(response.statusCode == 403)
+ #expect(response.body == errorBody)
+ }
+
+ @Test("carries the namespace and request query through to the media endpoint")
+ func forwardsNamespaceAndQuery() async throws {
+ let client = URLCapturingHTTPClient()
+ let uploader = DefaultMediaUploader(
+ httpClient: client,
+ siteApiRoot: URL(string: "https://example.com/wp-json")!,
+ siteApiNamespace: ["sites/123"]
+ )
+ let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("query-\(UUID().uuidString).jpg")
+ try Data("img".utf8).write(to: tempFile)
+ defer { try? FileManager.default.removeItem(at: tempFile) }
+
+ _ = try await uploader.upload(
+ fileURL: tempFile, mimeType: "image/jpeg", filename: "photo.jpg",
+ extraParts: [], query: "?_embed=wp:featuredmedia"
+ )
+
+ // Namespace inserted via the shared builder, and the query preserved verbatim —
+ // including the `:`, which would make URL(string:) return nil and drop it (#6).
+ let url = try #require(client.lastURL)
+ #expect(url.absoluteString == "https://example.com/wp-json/wp/v2/sites/123/media?_embed=wp:featuredmedia")
+ }
+}
+
+/// An HTTP client whose `performRaw` relays a canned response without validating
+/// status, while `perform` throws on a non-2xx — mirroring the real
+/// `EditorHTTPClient`. Lets a test prove `DefaultMediaUploader` routes uploads
+/// through `performRaw` (relay) rather than `perform` (throw).
+private struct RelayStubHTTPClient: EditorHTTPClientProtocol {
+ let statusCode: Int
+ let body: Data
+
+ func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) {
+ let response = HTTPURLResponse(url: urlRequest.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)!
+ guard (200...299).contains(statusCode) else {
+ throw NSError(domain: "RelayStubHTTPClient", code: statusCode)
+ }
+ return (body, response)
+ }
+
+ func performRaw(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) {
+ let response = HTTPURLResponse(url: urlRequest.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)!
+ return (body, response)
+ }
+
+ func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse) {
+ let response = HTTPURLResponse(url: urlRequest.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)!
+ return (FileManager.default.temporaryDirectory, response)
+ }
+}
+
+/// Captures the URL of the last request so a test can assert the media endpoint
+/// URL (namespace + query) the uploader built.
+private final class URLCapturingHTTPClient: EditorHTTPClientProtocol, @unchecked Sendable {
+ private let lock = NSLock()
+ private var _lastURL: URL?
+ var lastURL: URL? { lock.withLock { _lastURL } }
+
+ private func ok(_ urlRequest: URLRequest) -> (Data, HTTPURLResponse) {
+ lock.withLock { _lastURL = urlRequest.url }
+ return (Data("{}".utf8), HTTPURLResponse(url: urlRequest.url!, statusCode: 201, httpVersion: nil, headerFields: nil)!)
+ }
+
+ func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) { ok(urlRequest) }
+ func performRaw(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) { ok(urlRequest) }
+ func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse) {
+ let (_, response) = ok(urlRequest)
+ return (FileManager.default.temporaryDirectory, response)
+ }
+}
+
+// MARK: - Helpers
+
+/// Reads all bytes from an InputStream using `read()` return value as
+/// the sole termination signal (not `hasBytesAvailable`, which is
+/// unreliable for piped/bound streams).
+private func readAllFromStream(_ stream: InputStream) -> Data {
+ stream.open()
+ defer { stream.close() }
+
+ var data = Data()
+ let bufferSize = 8192
+ let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize)
+ defer { buffer.deallocate() }
+ while true {
+ let read = stream.read(buffer, maxLength: bufferSize)
+ if read <= 0 { break }
+ data.append(buffer, count: read)
+ }
+ return data
+}
+
+// MARK: - Mocks
+
+private final class MockUploadDelegate: MediaUploadDelegate, @unchecked Sendable {
+ private let lock = NSLock()
+ private var _processFileCalled = false
+ private var _uploadFileCalled = false
+ private var _lastMimeType: String?
+ private var _lastFilename: String?
+
+ var processFileCalled: Bool { lock.withLock { _processFileCalled } }
+ var uploadFileCalled: Bool { lock.withLock { _uploadFileCalled } }
+ var lastMimeType: String? { lock.withLock { _lastMimeType } }
+ var lastFilename: String? { lock.withLock { _lastFilename } }
+
+ func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile {
+ lock.withLock {
+ _processFileCalled = true
+ _lastMimeType = mimeType
+ }
+ return .original
+ }
+
+ func uploadFile(at url: URL, mimeType: String, filename: String) async throws -> MediaUploadResponse? {
+ lock.withLock {
+ _uploadFileCalled = true
+ _lastFilename = filename
+ }
+ let json = #"{"id":42,"source_url":"https://example.com/photo.jpg","media_type":"image"}"#
+ return MediaUploadResponse(statusCode: 201, body: Data(json.utf8))
+ }
+}
+
+private final class ProcessOnlyDelegate: MediaUploadDelegate, @unchecked Sendable {
+ private let lock = NSLock()
+ private var _processFileCalled = false
+
+ var processFileCalled: Bool { lock.withLock { _processFileCalled } }
+
+ func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile {
+ lock.withLock { _processFileCalled = true }
+ return .original
+ }
+}
+
+/// A delegate that produces a new file with changed metadata (e.g. a transcode).
+private final class ResizingDelegate: MediaUploadDelegate, @unchecked Sendable {
+ private let lock = NSLock()
+ private var _producedURL: URL?
+
+ /// The URL of the processed file this delegate wrote, for cleanup assertions.
+ var producedURL: URL? { lock.withLock { _producedURL } }
+
+ func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile {
+ let newURL = url.deletingLastPathComponent().appending(component: "processed-\(UUID().uuidString)")
+ try Data("processed".utf8).write(to: newURL)
+ lock.withLock { _producedURL = newURL }
+ return .processed(newURL, mimeType: "video/mp4", filename: "clip.mp4")
+ }
+}
+
+private final class MockDefaultUploader: DefaultMediaUploader, @unchecked Sendable {
+ private let lock = NSLock()
+ private var _uploadCalled = false
+ private var _passthroughUploadCalled = false
+ private var _lastUploadMimeType: String?
+ private var _lastUploadFilename: String?
+ private var _lastQuery: String?
+
+ var uploadCalled: Bool { lock.withLock { _uploadCalled } }
+ var passthroughUploadCalled: Bool { lock.withLock { _passthroughUploadCalled } }
+ var lastUploadMimeType: String? { lock.withLock { _lastUploadMimeType } }
+ var lastUploadFilename: String? { lock.withLock { _lastUploadFilename } }
+ var lastQuery: String? { lock.withLock { _lastQuery } }
+
+ init() {
+ super.init(httpClient: MockHTTPClient(), siteApiRoot: URL(string: "https://example.com/wp-json/")!)
+ }
+
+ override func upload(fileURL: URL, mimeType: String, filename: String, extraParts: [MultipartPart], query: String) async throws -> MediaUploadResponse {
+ lock.withLock {
+ _uploadCalled = true
+ _lastUploadMimeType = mimeType
+ _lastUploadFilename = filename
+ _lastQuery = query
+ }
+ return mockResponse()
+ }
+
+ override func passthroughUpload(body: RequestBody, contentType: String, query: String) async throws -> MediaUploadResponse {
+ lock.withLock {
+ _passthroughUploadCalled = true
+ _lastQuery = query
+ }
+ return mockResponse()
+ }
+
+ private func mockResponse() -> MediaUploadResponse {
+ let json = #"{"id":99,"source_url":"https://example.com/doc.pdf","media_type":"file"}"#
+ return MediaUploadResponse(statusCode: 201, body: Data(json.utf8))
+ }
+}
+
+private struct MockHTTPClient: EditorHTTPClientProtocol {
+ func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) {
+ let response = HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
+ return (Data(), response)
+ }
+
+ func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse) {
+ let response = HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
+ return (FileManager.default.temporaryDirectory, response)
+ }
+}
+
+private extension Data {
+ mutating func append(_ string: String) {
+ append(string.data(using: .utf8)!)
+ }
+}
diff --git a/ios/Tests/GutenbergKitTests/WordPressRESTURLTests.swift b/ios/Tests/GutenbergKitTests/WordPressRESTURLTests.swift
new file mode 100644
index 000000000..290d90a8a
--- /dev/null
+++ b/ios/Tests/GutenbergKitTests/WordPressRESTURLTests.swift
@@ -0,0 +1,57 @@
+import Foundation
+import Testing
+@testable import GutenbergKit
+
+@Suite("WordPressRESTURL")
+struct WordPressRESTURLTests {
+
+ @Test("appends the path when no namespace is configured")
+ func noNamespace() {
+ let url = WordPressRESTURL.namespaced(
+ apiRoot: URL(string: "https://example.com/wp-json")!,
+ path: "/wp/v2/media",
+ namespace: nil
+ )
+ #expect(url.absoluteString == "https://example.com/wp-json/wp/v2/media")
+ }
+
+ @Test("inserts the namespace after the version segment")
+ func insertsNamespace() {
+ let url = WordPressRESTURL.namespaced(
+ apiRoot: URL(string: "https://example.com/wp-json")!,
+ path: "/wp/v2/media",
+ namespace: "sites/123/"
+ )
+ #expect(url.absoluteString == "https://example.com/wp-json/wp/v2/sites/123/media")
+ }
+
+ @Test("normalizes an unslashed root and namespace")
+ func normalizesUnslashed() {
+ let url = WordPressRESTURL.namespaced(
+ apiRoot: URL(string: "https://example.com/wp-json")!, // no trailing slash
+ path: "/wp/v2/media",
+ namespace: "sites/123" // no trailing slash
+ )
+ #expect(url.absoluteString == "https://example.com/wp-json/wp/v2/sites/123/media")
+ }
+
+ @Test("does not double the slash when the root already ends in one")
+ func trailingSlashRoot() {
+ let url = WordPressRESTURL.namespaced(
+ apiRoot: URL(string: "https://example.com/wp-json/")!, // trailing slash
+ path: "/wp/v2/media",
+ namespace: "sites/123"
+ )
+ #expect(url.absoluteString == "https://example.com/wp-json/wp/v2/sites/123/media")
+ }
+
+ @Test("inserts the namespace after a non-wp/v2 version segment")
+ func otherVersionSegment() {
+ let url = WordPressRESTURL.namespaced(
+ apiRoot: URL(string: "https://example.com/wp-json")!,
+ path: "/wp-block-editor/v1/settings",
+ namespace: "sites/123"
+ )
+ #expect(url.absoluteString == "https://example.com/wp-json/wp-block-editor/v1/sites/123/settings")
+ }
+}
diff --git a/src/utils/api-fetch-upload-middleware.test.js b/src/utils/api-fetch-upload-middleware.test.js
new file mode 100644
index 000000000..2bdae9c13
--- /dev/null
+++ b/src/utils/api-fetch-upload-middleware.test.js
@@ -0,0 +1,520 @@
+/**
+ * External dependencies
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+/**
+ * Internal dependencies
+ */
+import { nativeMediaUploadMiddleware } from './api-fetch';
+
+// Mock dependencies
+vi.mock( './bridge', () => ( {
+ getGBKit: vi.fn( () => ( {} ) ),
+} ) );
+
+vi.mock( './logger', () => ( {
+ info: vi.fn(),
+ error: vi.fn(),
+} ) );
+
+import { getGBKit } from './bridge';
+
+function makeNext() {
+ return vi.fn( () => Promise.resolve( { passthrough: true } ) );
+}
+
+function makePostMediaOptions( file ) {
+ const body = new FormData();
+ if ( file ) {
+ body.append( 'file', file, file.name );
+ }
+ return {
+ method: 'POST',
+ path: '/wp/v2/media',
+ body,
+ };
+}
+
+function makeFile( name = 'photo.jpg', type = 'image/jpeg' ) {
+ return new File( [ 'fake data' ], name, { type } );
+}
+
+describe( 'nativeMediaUploadMiddleware', () => {
+ beforeEach( () => {
+ vi.restoreAllMocks();
+ global.fetch = vi.fn();
+ } );
+
+ // MARK: - Passthrough cases
+
+ it( 'passes through when nativeUploadPort is not configured', () => {
+ getGBKit.mockReturnValue( {} );
+ const next = makeNext();
+
+ nativeMediaUploadMiddleware( makePostMediaOptions( makeFile() ), next );
+
+ expect( next ).toHaveBeenCalled();
+ expect( global.fetch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'passes through for non-POST requests', () => {
+ getGBKit.mockReturnValue( {
+ nativeUploadPort: 8080,
+ nativeUploadToken: 'token',
+ } );
+ const next = makeNext();
+
+ nativeMediaUploadMiddleware(
+ { method: 'GET', path: '/wp/v2/media', body: new FormData() },
+ next
+ );
+
+ expect( next ).toHaveBeenCalled();
+ expect( global.fetch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'passes through for non-media paths', () => {
+ getGBKit.mockReturnValue( {
+ nativeUploadPort: 8080,
+ nativeUploadToken: 'token',
+ } );
+ const next = makeNext();
+
+ nativeMediaUploadMiddleware(
+ { method: 'POST', path: '/wp/v2/posts', body: new FormData() },
+ next
+ );
+
+ expect( next ).toHaveBeenCalled();
+ expect( global.fetch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'passes through for media sub-paths like /wp/v2/media/123', () => {
+ getGBKit.mockReturnValue( {
+ nativeUploadPort: 8080,
+ nativeUploadToken: 'token',
+ } );
+ const next = makeNext();
+ const body = new FormData();
+ body.append( 'file', makeFile(), 'photo.jpg' );
+
+ nativeMediaUploadMiddleware(
+ { method: 'POST', path: '/wp/v2/media/123', body },
+ next
+ );
+
+ expect( next ).toHaveBeenCalled();
+ expect( global.fetch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'passes through for similarly-prefixed paths like /wp/v2/media-categories', () => {
+ getGBKit.mockReturnValue( {
+ nativeUploadPort: 8080,
+ nativeUploadToken: 'token',
+ } );
+ const next = makeNext();
+ const body = new FormData();
+ body.append( 'file', makeFile(), 'photo.jpg' );
+
+ nativeMediaUploadMiddleware(
+ { method: 'POST', path: '/wp/v2/media-categories', body },
+ next
+ );
+
+ expect( next ).toHaveBeenCalled();
+ expect( global.fetch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'passes through when body is not FormData', () => {
+ getGBKit.mockReturnValue( {
+ nativeUploadPort: 8080,
+ nativeUploadToken: 'token',
+ } );
+ const next = makeNext();
+
+ nativeMediaUploadMiddleware(
+ { method: 'POST', path: '/wp/v2/media', body: '{}' },
+ next
+ );
+
+ expect( next ).toHaveBeenCalled();
+ expect( global.fetch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'passes through when FormData has no file field', () => {
+ getGBKit.mockReturnValue( {
+ nativeUploadPort: 8080,
+ nativeUploadToken: 'token',
+ } );
+ const next = makeNext();
+ const body = new FormData();
+ body.append( 'title', 'no file here' );
+
+ nativeMediaUploadMiddleware(
+ { method: 'POST', path: '/wp/v2/media', body },
+ next
+ );
+
+ expect( next ).toHaveBeenCalled();
+ expect( global.fetch ).not.toHaveBeenCalled();
+ } );
+
+ // MARK: - Interception
+
+ it( 'intercepts POST /wp/v2/media with file and fetches to local server', async () => {
+ getGBKit.mockReturnValue( {
+ nativeUploadPort: 12345,
+ nativeUploadToken: 'test-token',
+ } );
+ const next = makeNext();
+
+ global.fetch = vi.fn( () =>
+ Promise.resolve( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ id: 42,
+ url: 'https://example.com/photo.jpg',
+ alt: '',
+ caption: '',
+ title: 'photo',
+ mime: 'image/jpeg',
+ type: 'image',
+ } ),
+ } )
+ );
+
+ await nativeMediaUploadMiddleware(
+ makePostMediaOptions( makeFile() ),
+ next
+ );
+
+ expect( next ).not.toHaveBeenCalled();
+ expect( global.fetch ).toHaveBeenCalledOnce();
+
+ const [ url, options ] = global.fetch.mock.calls[ 0 ];
+ expect( url ).toBe( 'http://localhost:12345/upload' );
+ expect( options.method ).toBe( 'POST' );
+ expect( options.headers[ 'Relay-Authorization' ] ).toBe(
+ 'Bearer test-token'
+ );
+ expect( options.body ).toBeInstanceOf( FormData );
+ } );
+
+ it( 'forwards the original body and query to the native server', async () => {
+ getGBKit.mockReturnValue( {
+ nativeUploadPort: 12345,
+ nativeUploadToken: 'test-token',
+ } );
+
+ global.fetch = vi.fn( () =>
+ Promise.resolve( {
+ ok: true,
+ json: () => Promise.resolve( { id: 1 } ),
+ } )
+ );
+
+ const body = new FormData();
+ body.append( 'file', makeFile(), 'photo.jpg' );
+ body.append( 'post', '123' );
+
+ await nativeMediaUploadMiddleware(
+ {
+ method: 'POST',
+ path: '/wp/v2/media?_embed=wp:featuredmedia',
+ body,
+ },
+ makeNext()
+ );
+
+ const [ url, fetchOptions ] = global.fetch.mock.calls[ 0 ];
+ // The original query is appended so ?_embed reaches WordPress.
+ expect( url ).toBe(
+ 'http://localhost:12345/upload?_embed=wp:featuredmedia'
+ );
+ // The original body is forwarded verbatim, so `post` survives.
+ expect( fetchOptions.body ).toBe( body );
+ expect( fetchOptions.body.get( 'post' ) ).toBe( '123' );
+ } );
+
+ it( 'passes through when nativeUploadToken is not configured', () => {
+ getGBKit.mockReturnValue( { nativeUploadPort: 8080 } );
+ const next = makeNext();
+
+ nativeMediaUploadMiddleware( makePostMediaOptions( makeFile() ), next );
+
+ expect( next ).toHaveBeenCalled();
+ expect( global.fetch ).not.toHaveBeenCalled();
+ } );
+
+ it.each( [
+ // A bare trailing `?` carries no parameters. The native `query`
+ // accessors normalize it away, so this side must too — otherwise the
+ // upload server receives a `?` the two platforms agree cannot exist.
+ [ '/wp/v2/media?', 'http://localhost:12345/upload' ],
+ [ '/wp/v2/media', 'http://localhost:12345/upload' ],
+ ] )( 'normalizes the query of %s to %s', async ( path, expectedUrl ) => {
+ getGBKit.mockReturnValue( {
+ nativeUploadPort: 12345,
+ nativeUploadToken: 'test-token',
+ } );
+
+ global.fetch = vi.fn( () =>
+ Promise.resolve( {
+ ok: true,
+ json: () => Promise.resolve( { id: 1 } ),
+ } )
+ );
+
+ const body = new FormData();
+ body.append( 'file', makeFile(), 'photo.jpg' );
+
+ await nativeMediaUploadMiddleware(
+ { method: 'POST', path, body },
+ makeNext()
+ );
+
+ const [ url ] = global.fetch.mock.calls[ 0 ];
+ expect( url ).toBe( expectedUrl );
+ } );
+
+ it( 'returns the relayed WordPress attachment unchanged', async () => {
+ getGBKit.mockReturnValue( {
+ nativeUploadPort: 8080,
+ nativeUploadToken: 'token',
+ } );
+
+ // The native server relays WordPress's raw attachment response; the
+ // middleware must return it verbatim, not reshape it — so consumers get
+ // the real media_details.sizes, link, and raw/rendered fields.
+ const attachment = {
+ id: 77,
+ source_url: 'https://example.com/image.jpg',
+ alt_text: 'alt text',
+ caption: { raw: 'a caption', rendered: 'a caption' },
+ title: { raw: 'image', rendered: 'image' },
+ mime_type: 'image/jpeg',
+ media_type: 'image',
+ media_details: {
+ width: 4032,
+ height: 3024,
+ sizes: {
+ large: {
+ source_url: 'https://example.com/image-1024x768.jpg',
+ },
+ },
+ },
+ link: 'https://example.com/image/',
+ };
+
+ global.fetch = vi.fn( () =>
+ Promise.resolve( {
+ ok: true,
+ json: () => Promise.resolve( attachment ),
+ } )
+ );
+
+ const result = await nativeMediaUploadMiddleware(
+ makePostMediaOptions( makeFile() ),
+ makeNext()
+ );
+
+ expect( result ).toEqual( attachment );
+ } );
+
+ // MARK: - Error handling
+
+ it( 'rejects with the WordPress error body on a non-ok response', async () => {
+ getGBKit.mockReturnValue( {
+ nativeUploadPort: 8080,
+ nativeUploadToken: 'token',
+ } );
+
+ // The native server relays WordPress's error status + JSON body; the
+ // middleware rejects with that body as-is (like @wordpress/api-fetch) so
+ // media-utils surfaces WordPress's real message.
+ global.fetch = vi.fn( () =>
+ Promise.resolve( {
+ ok: false,
+ status: 403,
+ json: () =>
+ Promise.resolve( {
+ code: 'rest_cannot_create',
+ message:
+ 'Sorry, you are not allowed to upload this file type.',
+ } ),
+ } )
+ );
+
+ await expect(
+ nativeMediaUploadMiddleware(
+ makePostMediaOptions( makeFile() ),
+ makeNext()
+ )
+ ).rejects.toMatchObject( {
+ code: 'rest_cannot_create',
+ message: expect.stringContaining( 'not allowed' ),
+ } );
+ } );
+
+ it( 'rejects with invalid_json when the error body is not JSON', async () => {
+ getGBKit.mockReturnValue( {
+ nativeUploadPort: 8080,
+ nativeUploadToken: 'token',
+ } );
+
+ global.fetch = vi.fn( () =>
+ Promise.resolve( {
+ ok: false,
+ status: 502,
+ json: () =>
+ Promise.reject( new SyntaxError( 'Unexpected token' ) ),
+ } )
+ );
+
+ await expect(
+ nativeMediaUploadMiddleware(
+ makePostMediaOptions( makeFile() ),
+ makeNext()
+ )
+ ).rejects.toMatchObject( { code: 'invalid_json' } );
+ } );
+
+ it( 'rejects with invalid_json when a 2xx response body is not JSON', async () => {
+ getGBKit.mockReturnValue( {
+ nativeUploadPort: 8080,
+ nativeUploadToken: 'token',
+ } );
+
+ // A successful status but a non-JSON body (e.g. an HTML error page from
+ // an intermediary). json() rejects; the middleware must normalize it, not
+ // surface a raw SyntaxError.
+ global.fetch = vi.fn( () =>
+ Promise.resolve( {
+ ok: true,
+ json: () =>
+ Promise.reject( new SyntaxError( 'Unexpected token' ) ),
+ } )
+ );
+
+ await expect(
+ nativeMediaUploadMiddleware(
+ makePostMediaOptions( makeFile() ),
+ makeNext()
+ )
+ ).rejects.toMatchObject( { code: 'invalid_json' } );
+ } );
+
+ it( 'surfaces a transport failure instead of retrying (no silent duplicate)', async () => {
+ getGBKit.mockReturnValue( {
+ nativeUploadPort: 8080,
+ nativeUploadToken: 'token',
+ } );
+ const next = makeNext();
+
+ // A connection-level failure — the loopback server died out-of-band after
+ // a valid start (reachability is otherwise gated proactively upstream, so
+ // an unreachable server never advertises a port to fetch).
+ const connectionError = new TypeError( 'Failed to fetch' );
+ global.fetch = vi.fn( () => Promise.reject( connectionError ) );
+
+ await expect(
+ nativeMediaUploadMiddleware(
+ makePostMediaOptions( makeFile() ),
+ next
+ )
+ ).rejects.toBe( connectionError );
+
+ // No silent fallback to a direct re-upload — retrying a non-idempotent
+ // POST could duplicate the attachment.
+ expect( next ).not.toHaveBeenCalled();
+ } );
+
+ it( 'propagates an abort instead of falling back', async () => {
+ getGBKit.mockReturnValue( {
+ nativeUploadPort: 8080,
+ nativeUploadToken: 'token',
+ } );
+ const next = makeNext();
+
+ // A real aborted signal — `fetch` rejects with the signal's reason.
+ const controller = new AbortController();
+ controller.abort();
+ const options = {
+ ...makePostMediaOptions( makeFile() ),
+ signal: controller.signal,
+ };
+ global.fetch = vi.fn( () =>
+ Promise.reject( controller.signal.reason )
+ );
+
+ // The middleware rethrows the signal's canonical reason (not the fetch
+ // rejection) and does not retry.
+ await expect(
+ nativeMediaUploadMiddleware( options, next )
+ ).rejects.toBe( controller.signal.reason );
+
+ // An explicit cancellation must not be retried via the default path.
+ expect( next ).not.toHaveBeenCalled();
+ } );
+
+ it( 'propagates a timeout cancellation (aborted signal, non-AbortError) instead of falling back', async () => {
+ getGBKit.mockReturnValue( {
+ nativeUploadPort: 8080,
+ nativeUploadToken: 'token',
+ } );
+ const next = makeNext();
+
+ // `AbortSignal.timeout()` aborts its signal and rejects with a
+ // TimeoutError (not an AbortError). A `name === 'AbortError'` check would
+ // miss it and wrongly fall back; keying off `signal.aborted` catches it.
+ const timeoutError = new Error( 'The operation timed out.' );
+ timeoutError.name = 'TimeoutError';
+ const options = {
+ ...makePostMediaOptions( makeFile() ),
+ signal: { aborted: true, reason: timeoutError },
+ };
+ global.fetch = vi.fn( () => Promise.reject( timeoutError ) );
+
+ await expect(
+ nativeMediaUploadMiddleware( options, next )
+ ).rejects.toBe( timeoutError );
+
+ // A timeout is a cancellation, not an unreachable server — do not retry.
+ expect( next ).not.toHaveBeenCalled();
+ } );
+
+ // MARK: - Signal forwarding
+
+ it( 'forwards abort signal to fetch', async () => {
+ getGBKit.mockReturnValue( {
+ nativeUploadPort: 8080,
+ nativeUploadToken: 'token',
+ } );
+
+ global.fetch = vi.fn( () =>
+ Promise.resolve( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ id: 1,
+ url: '',
+ title: '',
+ mime: '',
+ type: '',
+ } ),
+ } )
+ );
+
+ const controller = new AbortController();
+ const options = makePostMediaOptions( makeFile() );
+ options.signal = controller.signal;
+
+ await nativeMediaUploadMiddleware( options, makeNext() );
+
+ expect( global.fetch.mock.calls[ 0 ][ 1 ].signal ).toBe(
+ controller.signal
+ );
+ } );
+} );
diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js
index 5373b3b13..3a5e4d079 100644
--- a/src/utils/api-fetch.js
+++ b/src/utils/api-fetch.js
@@ -8,11 +8,15 @@ import { getQueryArg } from '@wordpress/url';
* Internal dependencies
*/
import { getGBKit, POST_FALLBACKS } from './bridge';
+import { info, error as logError } from './logger';
/**
* @typedef {import('@wordpress/api-fetch').APIFetchMiddleware} APIFetchMiddleware
*/
+/** Matches `POST /wp/v2/media` but not sub-paths like `/wp/v2/media/123`. */
+const MEDIA_UPLOAD_PATH = /^\/wp\/v2\/media(\?|$)/;
+
/**
* Initializes the API fetch configuration and middleware.
*
@@ -26,6 +30,7 @@ export function configureApiFetch() {
apiFetch.use( apiPathModifierMiddleware );
apiFetch.use( tokenAuthMiddleware );
apiFetch.use( filterEndpointsMiddleware );
+ apiFetch.use( nativeMediaUploadMiddleware );
apiFetch.use( mediaUploadMiddleware );
apiFetch.use( transformOEmbedApiResponse );
apiFetch.use(
@@ -146,6 +151,169 @@ function filterEndpointsMiddleware( options, next ) {
return next( options );
}
+/**
+ * Middleware that routes media uploads through the native host's local HTTP
+ * server for processing (e.g. image resizing) before uploading to WordPress.
+ *
+ * Exported for testing only.
+ *
+ * When `nativeUploadPort` is configured in GBKit, this middleware intercepts
+ * `POST /wp/v2/media` requests, forwards the file to the native server, and
+ * returns the response in WordPress REST API attachment format so the existing
+ * Gutenberg upload pipeline (blob previews, save locking, entity caching)
+ * works unchanged.
+ *
+ * When the native server is not configured, requests pass through unmodified.
+ *
+ * Note: Ideally, media uploads would be handled via the `mediaUpload` editor
+ * setting (see the Gutenberg Framework guides), but GutenbergKit uses
+ * Gutenberg's `EditorProvider` which overwrites that setting internally:
+ * https://github.com/WordPress/gutenberg/blob/29914e1d09a344edce58d938fa4992e1ec248e41/packages/editor/src/components/provider/use-block-editor-settings.js#L340
+ *
+ * Until GutenbergKit is refactored to use `BlockEditorProvider` and aligns
+ * with the Gutenberg Framework guides (https://wordpress.org/gutenberg-framework/docs/intro/),
+ * this api-fetch middleware approach is necessary. For context, see:
+ * - https://github.com/wordpress-mobile/GutenbergKit/pull/24
+ * - https://github.com/wordpress-mobile/GutenbergKit/pull/50
+ * - https://github.com/wordpress-mobile/GutenbergKit/pull/108
+ *
+ * @type {APIFetchMiddleware}
+ */
+export function nativeMediaUploadMiddleware( options, next ) {
+ const { nativeUploadPort, nativeUploadToken } = getGBKit();
+
+ if (
+ ! nativeUploadPort ||
+ ! nativeUploadToken ||
+ ! options.method ||
+ options.method.toUpperCase() !== 'POST' ||
+ ! options.path ||
+ ! MEDIA_UPLOAD_PATH.test( options.path ) ||
+ ! ( options.body instanceof FormData )
+ ) {
+ return next( options );
+ }
+
+ const file = options.body.get( 'file' );
+ if ( ! file ) {
+ return next( options );
+ }
+
+ info(
+ `Routing upload of ${ file.name } through native server on port ${ nativeUploadPort }`
+ );
+
+ // Forward the original request body — the file plus every sibling field
+ // (`post`, additionalData) — and the original query string (e.g. `?_embed`)
+ // so the native server can relay them to WordPress unchanged. Rebuilding the
+ // body with only `file` would drop the post association and additionalData.
+ const query = requestQuery( options.path );
+
+ // Use the two-argument form of `.then()` so the rejection handler catches
+ // *only* a connection-level failure of the `fetch()` itself — not errors
+ // thrown while handling a response (those must surface as real failures).
+ return fetch( `http://localhost:${ nativeUploadPort }/upload${ query }`, {
+ method: 'POST',
+ headers: {
+ 'Relay-Authorization': `Bearer ${ nativeUploadToken }`,
+ },
+ body: options.body,
+ signal: options.signal,
+ } ).then(
+ ( response ) => {
+ // The native server relays WordPress's response verbatim. On a
+ // non-2xx, mirror @wordpress/api-fetch: reject with the parsed WP
+ // error body ({ code, message, data }) so @wordpress/media-utils
+ // surfaces WordPress's real message. On success, return WordPress's
+ // attachment object unchanged so every consumer behaves exactly as
+ // it would for a non-native upload.
+ if ( ! response.ok ) {
+ return response
+ .json()
+ .catch( invalidUploadResponseError )
+ .then( ( body ) => {
+ logError( 'Native upload failed', body );
+ throw body;
+ } );
+ }
+ // A 2xx with a non-JSON body (e.g. an HTML error page injected by an
+ // intermediary) rejects json(); normalize it the same way as the
+ // non-ok path rather than surfacing a raw SyntaxError.
+ return response.json().catch( () => {
+ const error = invalidUploadResponseError();
+ logError( 'Native upload returned an invalid response', error );
+ throw error;
+ } );
+ },
+ ( connectionError ) => {
+ // A caller-initiated cancellation must propagate as the cancellation,
+ // never be retried. Detect it via `signal.aborted` — the cancellation
+ // *state* — rather than `connectionError.name === 'AbortError'`: the
+ // state check also catches `AbortSignal.timeout()` (which rejects with
+ // a TimeoutError, not an AbortError) and custom abort reasons, which a
+ // name match would miss and wrongly fall back on. Rethrow the signal's
+ // `reason` (the canonical abort error), not `connectionError`: if a
+ // network failure and the abort race, `fetch` can reject with a network
+ // TypeError even though the signal aborted, and rethrowing that would
+ // make upstream treat a cancelled upload as a real failure — surfacing
+ // a spurious error notice instead of a silent cancel.
+ if ( options.signal?.aborted ) {
+ throw options.signal.reason;
+ }
+ // Otherwise the loopback upload server is unreachable at the transport
+ // layer. We deliberately do NOT fall back to a direct re-upload:
+ // reachability is gated proactively upstream — this middleware's guard
+ // skips the native path when no port is advertised, and the native side
+ // only advertises a port the WebView can actually reach (server running
+ // + cleartext-to-localhost permitted, cleared on stop). So reaching here
+ // means the server died out-of-band after a valid start; retrying a
+ // non-idempotent POST /wp/v2/media could duplicate the attachment if the
+ // native server had already relayed it to WordPress.
+ logError(
+ 'Native upload failed at the transport layer',
+ connectionError
+ );
+ throw connectionError;
+ }
+ );
+}
+
+/**
+ * The query component of a request path, including the leading `?`, or an empty
+ * string when there is no query.
+ *
+ * Mirrors the `query` accessors on the native request types (`HttpRequest` on
+ * Android, `ParsedHTTPRequest` on iOS): the split is on the first `?`, and a
+ * bare trailing `?` carries no parameters so it yields an empty string. Keeping
+ * the three in agreement means the value can be appended to an upstream URL
+ * unconditionally, whichever side derived it.
+ *
+ * @param {string} path The request path, e.g. `/wp/v2/media?_embed`.
+ * @return {string} The query, e.g. `?_embed`, or `''`.
+ */
+function requestQuery( path ) {
+ const separator = path.indexOf( '?' );
+ if ( separator === -1 ) {
+ return '';
+ }
+ const value = path.slice( separator + 1 );
+ return value ? `?${ value }` : '';
+}
+
+/**
+ * The error rejected when the upload server's response body can't be parsed as
+ * JSON. Shaped like a WordPress REST error so `@wordpress/media-utils` surfaces
+ * it the same way as a real one, on both the non-2xx and 2xx paths.
+ *
+ * @return {{ code: string, message: string }} The normalized error.
+ */
+function invalidUploadResponseError() {
+ return {
+ code: 'invalid_json',
+ message: 'The upload server returned an invalid response.',
+ };
+}
+
/**
* Middleware to modify media upload requests.
*
@@ -157,7 +325,7 @@ function filterEndpointsMiddleware( options, next ) {
function mediaUploadMiddleware( options, next ) {
if (
options.path &&
- options.path.startsWith( '/wp/v2/media' ) &&
+ MEDIA_UPLOAD_PATH.test( options.path ) &&
options.method === 'POST' &&
options.body instanceof FormData &&
options.body.get( 'post' ) === '-1'
diff --git a/src/utils/bridge.js b/src/utils/bridge.js
index e7ad4c4e9..f04b50830 100644
--- a/src/utils/bridge.js
+++ b/src/utils/bridge.js
@@ -232,6 +232,8 @@ export function onNetworkRequest( requestData ) {
* @property {string} [hideTitle] Whether to hide the title.
* @property {Post} [post] The post data.
* @property {boolean} [enableNetworkLogging] Enables logging of all network requests/responses to the native host via onNetworkRequest bridge method.
+ * @property {number} [nativeUploadPort] Port the local HTTP server is listening on. If absent, the native upload override is not activated.
+ * @property {string} [nativeUploadToken] Per-session auth token for requests to the local upload server.
*/
/**