diff --git a/Package.resolved b/Package.resolved
index 62e7aa381..aacd18e8c 100644
--- a/Package.resolved
+++ b/Package.resolved
@@ -1,5 +1,5 @@
{
- "originHash" : "e4f07fb846fe4e484ddbb0d1c5783d0a62ffee053e850e8a2abe0b8e0323bbd4",
+ "originHash" : "6db3023106dfc39818a2a045dfbd8be56ad662c039842cf91e1f21a9bd7ce81f",
"pins" : [
{
"identity" : "svgview",
@@ -18,6 +18,15 @@
"revision" : "aa85ee96017a730031bafe411cde24a08a17a9c9",
"version" : "2.8.8"
}
+ },
+ {
+ "identity" : "wordpress-rs",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/Automattic/wordpress-rs",
+ "state" : {
+ "branch" : "alpha-20260313",
+ "revision" : "cde2fda82257f4ac7b81543d5b831bb267d4e52c"
+ }
}
],
"version" : 3
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..b3a558bda 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,7 +151,18 @@ 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}"
+ )
+ } else {
+ fail("$description: expected error $expectedError but parsing succeeded")
+ }
} catch (e: HTTPRequestParseException) {
assertEquals(
expectedError,
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..9756c943f 100644
--- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt
+++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt
@@ -111,6 +111,25 @@ 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()
+ }
+ }
+
+ private var uploadServer: MediaUploadServer? = null
+ private val uploadHttpClient: okhttp3.OkHttpClient by lazy { okhttp3.OkHttpClient() }
+
private var onFileChooserRequested: ((Intent, Int) -> Unit)? = null
private var contentChangeListener: ContentChangeListener? = null
private var historyChangeListener: HistoryChangeListener? = null
@@ -579,7 +598,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;
@@ -590,6 +614,26 @@ class GutenbergView : FrameLayout {
}
+ private fun startUploadServer() {
+ if (configuration.siteApiRoot.isEmpty() || configuration.authHeader.isEmpty()) 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
+ )
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to start upload server", e)
+ }
+ }
+
fun clearConfig() {
val jsCode = """
delete window.GBKit;
@@ -1018,6 +1062,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,6 +1137,8 @@ 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")
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..190f4cde7 100644
--- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/HttpServer.kt
+++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/HttpServer.kt
@@ -39,7 +39,11 @@ 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
) {
/**
* Returns the value of the first header matching the given name (case-insensitive).
@@ -277,13 +281,12 @@ 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 }
+
+ // Drain oversized body before throwing so the
+ // client receives the 413 (RFC 9110 §15.5.14).
+ if (parser.state == HTTPRequestParser.State.DRAINING) {
+ readUntil(parser, input, buffer, deadlineNanos) { it.isComplete }
}
// Validate headers (triggers full RFC validation).
@@ -312,6 +315,31 @@ class HttpServer(
return
}
+ // 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
+ }
+
// Check auth before consuming body to avoid buffering up to
// maxBodySize for unauthenticated clients.
// OPTIONS is exempt because CORS preflight requests
@@ -341,14 +369,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 {
@@ -406,6 +427,24 @@ class HttpServer(
}
}
+ /** 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))
+ }
+ }
+
private fun sendResponse(socket: Socket, response: HttpResponse) {
val output = socket.getOutputStream()
output.write(serializeResponse(response))
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..c264644bc
--- /dev/null
+++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt
@@ -0,0 +1,398 @@
+package org.wordpress.gutenberg
+
+import android.util.Log
+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 okio.source
+
+/**
+ * Result of a successful media upload to the remote WordPress server.
+ *
+ * Matches the format expected by Gutenberg's `onFileChange` callback.
+ */
+data class MediaUploadResult(
+ val id: Int,
+ val url: String,
+ val alt: String = "",
+ val caption: String = "",
+ val title: String,
+ val mime: String,
+ val type: String,
+ val width: Int? = null,
+ val height: Int? = null
+)
+
+/**
+ * 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 the path of the processed file, or the original path for passthrough.
+ */
+ suspend fun processFile(file: File, mimeType: String): File = file
+
+ /**
+ * Upload a processed file to the remote WordPress site.
+ * Return the Gutenberg-compatible media result, or null to use the default uploader.
+ */
+ suspend fun uploadFile(file: File, mimeType: String, filename: String): MediaUploadResult? = 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
+) {
+ /** 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
+
+ init {
+ server = HttpServer(
+ name = "media-upload",
+ externallyAccessible = false,
+ requiresAuthentication = true,
+ cacheDir = cacheDir,
+ handler = { request -> handleRequest(request) }
+ )
+ server.start()
+ }
+
+ /** Stops the server and releases resources. */
+ fun stop() {
+ server.stop()
+ }
+
+ // 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)
+ }
+
+ // CORS preflight — the library exempts OPTIONS from auth, so this is
+ // reached without a token.
+ if (request.method.uppercase() == "OPTIONS") {
+ return corsPreflightResponse()
+ }
+
+ // Route: only POST /upload is handled.
+ if (request.method.uppercase() != "POST" || request.target != "/upload") {
+ return errorResponse(404, "Not found")
+ }
+
+ return handleUpload(request)
+ }
+
+ private suspend fun handleUpload(request: HttpRequest): HttpResponse {
+ val filePart = parseFilePart(request)
+ ?: return errorResponse(400, "Expected multipart/form-data with a file")
+
+ val tempFile = writePartToTempFile(filePart)
+ ?: return errorResponse(500, "Failed to save file")
+
+ return processAndRespond(request, tempFile, filePart)
+ }
+
+ private fun parseFilePart(request: HttpRequest): MultipartPart? {
+ val contentType = request.header("Content-Type") ?: return null
+ val boundary = HeaderValue.extractParameter("boundary", contentType) ?: return null
+ val body = request.body ?: return null
+
+ val parts = 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)
+ return null
+ }
+
+ return parts.firstOrNull { it.filename != null }
+ }
+
+ private fun writePartToTempFile(filePart: MultipartPart): File? {
+ val filename = sanitizeFilename(filePart.filename ?: "upload")
+ val tempDir = File(System.getProperty("java.io.tmpdir"), "gutenbergkit-uploads").apply { mkdirs() }
+ val tempFile = File(tempDir, "${UUID.randomUUID()}-$filename")
+
+ return try {
+ filePart.body.inputStream().use { input ->
+ tempFile.outputStream().use { output ->
+ input.copyTo(output)
+ }
+ }
+ tempFile
+ } catch (e: IOException) {
+ Log.e(TAG, "Failed to write upload to disk", e)
+ null
+ }
+ }
+
+ private suspend fun processAndRespond(
+ request: HttpRequest, tempFile: File, filePart: MultipartPart
+ ): HttpResponse {
+ var processedFile: File? = null
+ try {
+ val uploadResult = processAndUpload(
+ tempFile, filePart.contentType, filePart.filename ?: "upload"
+ )
+ val media = when (uploadResult) {
+ is UploadResult.Uploaded -> {
+ processedFile = uploadResult.processedFile
+ Log.d(TAG, "Uploading processed file to WordPress")
+ uploadResult.result
+ }
+ 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)
+ }
+ }
+ return successResponse(media)
+ } catch (e: MediaUploadException) {
+ Log.e(TAG, "Upload processing failed", e)
+ return errorResponse(500, e.message ?: "Upload failed")
+ } finally {
+ tempFile.delete()
+ processedFile?.let { if (it != tempFile) it.delete() }
+ }
+ }
+
+ // MARK: - Delegate Pipeline
+
+ private sealed class UploadResult {
+ data class Uploaded(val result: MediaUploadResult, val processedFile: File) : UploadResult()
+ data object Passthrough : UploadResult()
+ }
+
+ private suspend fun performPassthroughUpload(request: HttpRequest): MediaUploadResult {
+ 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)
+ }
+
+ private suspend fun processAndUpload(
+ file: File, mimeType: String, filename: String
+ ): UploadResult {
+ val processedFile = uploadDelegate?.processFile(file, mimeType) ?: file
+
+ // If the delegate provided its own upload, use that.
+ uploadDelegate?.uploadFile(processedFile, mimeType, filename)?.let {
+ return UploadResult.Uploaded(it, processedFile)
+ }
+
+ // If the delegate didn't modify the file, the original request
+ // body can be forwarded directly — skip multipart re-encoding.
+ if (processedFile == file) {
+ return UploadResult.Passthrough
+ }
+
+ val result = defaultUploader?.upload(processedFile, mimeType, filename)
+ ?: error("No upload delegate or default uploader configured")
+ return UploadResult.Uploaded(result, processedFile)
+ }
+
+ // MARK: - Response Building
+
+ private val corsHeaders: Map = mapOf(
+ "Access-Control-Allow-Origin" to "*",
+ "Access-Control-Allow-Headers" to "Relay-Authorization, Content-Type"
+ )
+
+ private fun corsPreflightResponse(): HttpResponse = HttpResponse(
+ status = 204,
+ headers = corsHeaders + mapOf(
+ "Access-Control-Allow-Methods" to "POST, OPTIONS",
+ "Access-Control-Max-Age" to "86400"
+ ),
+ body = ByteArray(0)
+ )
+
+ private fun successResponse(media: MediaUploadResult): HttpResponse {
+ val json = org.json.JSONObject().apply {
+ put("id", media.id)
+ put("url", media.url)
+ put("alt", media.alt)
+ put("caption", media.caption)
+ put("title", media.title)
+ put("mime", media.mime)
+ put("type", media.type)
+ media.width?.let { put("width", it) }
+ media.height?.let { put("height", it) }
+ }.toString()
+
+ return HttpResponse(
+ status = 200,
+ headers = corsHeaders + mapOf("Content-Type" to "application/json"),
+ body = json.toByteArray()
+ )
+ }
+
+ private fun errorResponse(status: Int, body: String): HttpResponse = HttpResponse(
+ status = status,
+ headers = corsHeaders + mapOf("Content-Type" to "text/plain"),
+ body = body.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, accounting for site API namespaces. */
+ private val mediaEndpointUrl: String
+ get() {
+ val namespace = siteApiNamespace.firstOrNull() ?: ""
+ return "${siteApiRoot}wp/v2/${namespace}media"
+ }
+
+ open suspend fun upload(file: File, mimeType: String, filename: String): MediaUploadResult {
+ val mediaType = mimeType.toMediaType()
+ val requestBody = okhttp3.MultipartBody.Builder()
+ .setType(okhttp3.MultipartBody.FORM)
+ .addFormDataPart("file", filename, file.asRequestBody(mediaType))
+ .build()
+
+ val request = okhttp3.Request.Builder()
+ .url(mediaEndpointUrl)
+ .addHeader("Authorization", authHeader)
+ .post(requestBody)
+ .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
+ ): MediaUploadResult {
+ 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)
+ .addHeader("Authorization", authHeader)
+ .post(streamBody)
+ .build()
+
+ return performUpload(request)
+ }
+
+ private fun performUpload(request: okhttp3.Request): MediaUploadResult {
+ val response = httpClient.newCall(request).execute()
+ val body = response.body?.string()
+
+ if (!response.isSuccessful) {
+ // Try to extract the human-readable message from a WordPress error
+ // response ({"code":"...","message":"..."}) before falling back to
+ // the raw body.
+ val errorMessage = body?.let {
+ try { org.json.JSONObject(it).optString("message", null) } catch (_: org.json.JSONException) { null }
+ } ?: body ?: response.message
+ throw MediaUploadException(errorMessage)
+ }
+
+ if (body == null) {
+ throw MediaUploadException("Empty response body from server")
+ }
+
+ return parseMediaResponse(body)
+ }
+
+ private fun parseMediaResponse(body: String): MediaUploadResult {
+ val json = try {
+ org.json.JSONObject(body)
+ } catch (e: org.json.JSONException) {
+ throw MediaUploadException("Unexpected response: ${body.take(500)}", e)
+ }
+ val mediaDetails = json.optJSONObject("media_details")
+ return MediaUploadResult(
+ id = json.getInt("id"),
+ url = json.getString("source_url"),
+ alt = json.optString("alt_text", ""),
+ caption = json.optJSONObject("caption")?.optString("rendered", "") ?: "",
+ title = json.getJSONObject("title").getString("rendered"),
+ mime = json.getString("mime_type"),
+ type = json.getString("media_type"),
+ width = mediaDetails?.optInt("width"),
+ height = mediaDetails?.optInt("height")
+ )
+ }
+}
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..0157386e4 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,11 @@ class HTTPRequestParser(
fun parseRequest(): ParsedHTTPRequest? = synchronized(lock) {
if (!_state.hasHeaders) return null
- parseError?.let { throw HTTPRequestParseException(it) }
+ // Payload-too-large means "valid headers, rejected body" — let
+ // the caller access the parsed headers so the handler can build
+ // a response (e.g., with CORS headers). Other parse errors
+ // indicate genuinely malformed requests and are still thrown.
+ parseError?.let { if (it != HTTPRequestParseError.PAYLOAD_TOO_LARGE) throw HTTPRequestParseException(it) }
if (parsedHeaders == null) {
val headerData = buffer.read(0, minOf(bytesWritten, MAX_HEADER_SIZE.toLong()).toInt())
@@ -210,7 +249,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/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/MediaUploadServerTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt
new file mode 100644
index 000000000..86ad0c51d
--- /dev/null
+++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt
@@ -0,0 +1,416 @@
+package org.wordpress.gutenberg
+
+import com.google.gson.JsonParser
+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"))
+ }
+
+ // 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 200 but got: ${response.statusLine}", response.statusLine.contains("200"))
+ assertTrue(delegate.processFileCalled)
+ assertTrue(delegate.uploadFileCalled)
+ assertEquals("image/jpeg", delegate.lastMimeType)
+ assertEquals("photo.jpg", delegate.lastFilename)
+
+ val json = JsonParser.parseString(response.body).asJsonObject
+ assertEquals(42, json.get("id").asInt)
+ assertEquals("https://example.com/photo.jpg", json.get("url").asString)
+ assertEquals("image", json.get("type").asString)
+ }
+
+ // 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 200 but got: ${response.statusLine}", response.statusLine.contains("200"))
+ 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 sends correct request to WP REST API`() {
+ val mockWpServer = MockWebServer()
+ // DefaultMediaUploader uses org.json.JSONObject internally which is
+ // stubbed in JVM unit tests — so we only verify the outgoing request
+ // format, not the response parsing.
+ mockWpServer.enqueue(
+ MockResponse()
+ .setResponseCode(200)
+ .setHeader("Content-Type", "application/json")
+ .setBody(
+ """{"id":1,"source_url":"u","alt_text":"",""" +
+ """"caption":{"rendered":""},"title":{"rendered":"t"},""" +
+ """"mime_type":"image/jpeg","media_type":"image"}"""
+ )
+ )
+ 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())
+
+ // The upload call will fail at org.json parsing in JVM tests, but we
+ // can still verify the request was sent correctly.
+ try {
+ runBlocking { uploader.upload(file, "image/jpeg", "image.jpg") }
+ } catch (_: Exception) {
+ // Expected — org.json stubs return defaults in JVM tests
+ }
+
+ 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 throws on server error`() {
+ 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())
+
+ try {
+ runBlocking { uploader.upload(file, "image/jpeg", "fail.jpg") }
+ throw AssertionError("Expected exception")
+ } catch (e: MediaUploadException) {
+ assertTrue(e.message!!.contains("Internal error"))
+ }
+
+ 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): File {
+ processFileCalled = true
+ lastMimeType = mimeType
+ return file
+ }
+
+ override suspend fun uploadFile(file: File, mimeType: String, filename: String): MediaUploadResult? {
+ uploadFileCalled = true
+ lastFilename = filename
+ return MediaUploadResult(
+ id = 42,
+ url = "https://example.com/photo.jpg",
+ title = "photo",
+ mime = "image/jpeg",
+ type = "image"
+ )
+ }
+ }
+
+ private class ProcessOnlyDelegate : MediaUploadDelegate {
+ @Volatile var processFileCalled = false
+
+ override suspend fun processFile(file: File, mimeType: String): File {
+ processFileCalled = true
+ return file
+ }
+ }
+
+ private class MockDefaultUploader : DefaultMediaUploader(
+ httpClient = okhttp3.OkHttpClient(),
+ siteApiRoot = "https://example.com/wp-json/",
+ authHeader = "Bearer mock"
+ ) {
+ @Volatile var uploadCalled = false
+ @Volatile var passthroughUploadCalled = false
+
+ override suspend fun upload(file: File, mimeType: String, filename: String): MediaUploadResult {
+ uploadCalled = true
+ return mockResult()
+ }
+
+ override suspend fun passthroughUpload(
+ body: org.wordpress.gutenberg.http.RequestBody,
+ contentType: String
+ ): MediaUploadResult {
+ passthroughUploadCalled = true
+ return mockResult()
+ }
+
+ private fun mockResult() = MediaUploadResult(
+ id = 99,
+ url = "https://example.com/doc.pdf",
+ title = "doc",
+ mime = "application/pdf",
+ type = "file"
+ )
+ }
+
+}
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..012ba42a3 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,7 +149,18 @@ 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}"
+ )
+ } else {
+ fail("$description: expected error $expectedError but parsing succeeded")
+ }
} catch (e: HTTPRequestParseException) {
assertEquals(
expectedError,
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..abdc9c2e1 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
@@ -97,6 +97,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..7f390546a
--- /dev/null
+++ b/android/app/src/main/java/com/example/gutenbergkit/DemoMediaUploadDelegate.kt
@@ -0,0 +1,64 @@
+package com.example.gutenbergkit
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.util.Log
+import org.wordpress.gutenberg.MediaUploadDelegate
+import java.io.File
+
+/**
+ * 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): File {
+ if (!mimeType.startsWith("image/") || mimeType == "image/gif") {
+ return file
+ }
+
+ 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 file
+
+ val longestSide = maxOf(width, height)
+ if (longestSide <= maxDimension) return file
+
+ // 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 file
+
+ // 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()
+
+ val outputFile = File(file.parent, "resized-${file.name}")
+ val format = if (mimeType == "image/png") Bitmap.CompressFormat.PNG
+ else Bitmap.CompressFormat.JPEG
+
+ outputFile.outputStream().use { out ->
+ scaled.compress(format, 85, out)
+ }
+ scaled.recycle()
+
+ Log.d(TAG, "Resized image from ${width}×${height} to ${targetWidth}×${targetHeight}")
+ return outputFile
+ }
+}
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..d40fb1aaa 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,61 @@ 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) async throws -> URL {
+ guard mimeType.hasPrefix("image/"), mimeType != "image/gif" else {
+ return url
+ }
+
+ 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 url
+ }
+
+ let longestSide = max(width, height)
+ guard longestSide > maxDimension else {
+ return url
+ }
+
+ let options: [CFString: Any] = [
+ kCGImageSourceThumbnailMaxPixelSize: maxDimension,
+ kCGImageSourceCreateThumbnailFromImageAlways: true,
+ kCGImageSourceCreateThumbnailWithTransform: true
+ ]
+
+ guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
+ return url
+ }
+
+ 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 url
+ }
+
+ CGImageDestinationAddImage(destination, thumbnail, nil)
+ guard CGImageDestinationFinalize(destination) else {
+ return url
+ }
+
+ Logger.demo.info("Resized image from \(Int(width))x\(Int(height)) to fit \(Int(maxDimension))px")
+ return outputURL
+ }
+
}
}
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 431a03828..eba61c3cb 100644
--- a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift
+++ b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift
@@ -32,13 +32,21 @@ struct WPError: Decodable {
public actor EditorHTTPClient: EditorHTTPClientProtocol {
/// Errors that can occur during HTTP requests.
- enum ClientError: Error {
+ enum ClientError: Error, LocalizedError {
/// The server returned a WordPress-formatted error response.
case wpError(WPError)
/// A file download failed with the given HTTP status code.
case downloadFailed(statusCode: Int)
/// An unexpected error occurred with the given response data and status code.
case unknown(response: Data, statusCode: Int)
+
+ 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.
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 e3c53e336..5b4c20544 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
@@ -238,10 +244,12 @@ 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.error = error
+ self.dependencyTaskHandle = Task(priority: .userInitiated) { [weak self] in
+ do {
+ try await self?.loadEditor(dependencies: dependencies)
+ } catch {
+ self?.error = error
+ }
}
} else {
// ASYNC FLOW: No dependencies - fetch them asynchronously
@@ -264,6 +272,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
public override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
self.dependencyTaskHandle?.cancel()
+ self.uploadServer?.stop()
}
/// Fetches all required dependencies and then loads the editor.
@@ -284,7 +293,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 {
// Display error view - this sets self.error which triggers displayError()
self.error = error
@@ -301,12 +310,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)
@@ -339,7 +351,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 = """
@@ -351,6 +368,32 @@ 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
+ }
+
+ 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..08947ca22
--- /dev/null
+++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift
@@ -0,0 +1,54 @@
+import Foundation
+
+/// Result of a successful media upload to the remote WordPress server.
+///
+/// This structure matches the format expected by Gutenberg's `onFileChange` callback.
+public struct MediaUploadResult: Codable, Sendable {
+ public let id: Int
+ public let url: String
+ public let alt: String
+ public let caption: String
+ public let title: String
+ public let mime: String
+ public let type: String
+ public let width: Int?
+ public let height: Int?
+
+ public init(id: Int, url: String, alt: String = "", caption: String = "", title: String, mime: String, type: String, width: Int? = nil, height: Int? = nil) {
+ self.id = id
+ self.url = url
+ self.alt = alt
+ self.caption = caption
+ self.title = title
+ self.mime = mime
+ self.type = type
+ self.width = width
+ self.height = height
+ }
+}
+
+/// 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 the URL of the processed file, or the original URL for passthrough.
+ func processFile(at url: URL, mimeType: String) async throws -> URL
+
+ /// Upload a processed file to the remote WordPress site.
+ /// Return the Gutenberg-compatible media result, or `nil` to use the default uploader.
+ func uploadFile(at url: URL, mimeType: String, filename: String) async throws -> MediaUploadResult?
+}
+
+/// Default implementations.
+extension MediaUploadDelegate {
+ public func processFile(at url: URL, mimeType: String) async throws -> URL {
+ url
+ }
+
+ public func uploadFile(at url: URL, mimeType: String, filename: String) async throws -> MediaUploadResult? {
+ 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..c06a0fa79
--- /dev/null
+++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift
@@ -0,0 +1,536 @@
+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
+
+ /// 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 {
+ let context = UploadContext(uploadDelegate: uploadDelegate, defaultUploader: defaultUploader)
+
+ let server = try await HTTPServer.start(
+ name: "media-upload",
+ requiresAuthentication: true,
+ maxRequestBodySize: maxRequestBodySize,
+ handler: { request in
+ await Self.handleRequest(request, context: context)
+ }
+ )
+
+ return MediaUploadServer(server: server)
+ }
+
+ private init(server: HTTPServer) {
+ self.server = server
+ self.port = server.port
+ self.token = server.token
+ }
+
+ /// 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, body: message)
+ }
+
+ // CORS preflight — the library exempts OPTIONS from auth, so this is
+ // reached without a token.
+ if parsed.method.uppercased() == "OPTIONS" {
+ return corsPreflightResponse()
+ }
+
+ // Route: only POST /upload is handled.
+ guard parsed.method.uppercased() == "POST", parsed.target == "/upload" else {
+ return errorResponse(status: 404, body: "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, body: "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, body: "No file found in request")
+ }
+
+ // 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 = FileManager.default.temporaryDirectory
+ .appending(component: "GutenbergKit-uploads", directoryHint: .isDirectory)
+ 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 {
+ Logger.uploadServer.error("Failed to write upload to disk: \(error)")
+ return errorResponse(status: 500, body: "Failed to save file")
+ }
+
+ // Process and upload through the delegate pipeline.
+ let result: Result
+ var processedURL: URL?
+ do {
+ let uploadResult = try await processAndUpload(
+ fileURL: fileURL, mimeType: mimeType, filename: filePart.filename ?? "upload", context: context
+ )
+ switch uploadResult {
+ case .uploaded(let media, let processed):
+ processedURL = processed
+ Logger.uploadServer.debug("Uploading processed file to WordPress")
+ result = .success(media)
+ 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 {
+ result = .failure(UploadError.noUploader)
+ break
+ }
+ let media = try await defaultUploader.passthroughUpload(body: body, contentType: contentType)
+ result = .success(media)
+ }
+ } catch {
+ result = .failure(error)
+ }
+
+ // Clean up temp files (success or failure).
+ try? FileManager.default.removeItem(at: fileURL)
+ if let processedURL, processedURL != fileURL {
+ try? FileManager.default.removeItem(at: processedURL)
+ }
+
+ switch result {
+ case .success(let media):
+ do {
+ let json = try JSONEncoder().encode(media)
+ return HTTPResponse(
+ status: 200,
+ headers: corsHeaders + [("Content-Type", "application/json")],
+ body: json
+ )
+ } catch {
+ return errorResponse(status: 500, body: "Failed to encode response")
+ }
+ case .failure(let error):
+ Logger.uploadServer.error("Upload processing failed: \(error)")
+ return errorResponse(status: 500, body: error.localizedDescription)
+ }
+ }
+
+ // MARK: - Delegate Pipeline
+
+ /// Result of the delegate processing + upload pipeline.
+ private enum UploadResult {
+ /// The delegate (or default uploader) completed the upload.
+ case uploaded(MediaUploadResult, processedURL: URL)
+ /// 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, context: UploadContext
+ ) async throws -> UploadResult {
+ // Step 1: Process (resize, transcode, etc.)
+ let processedURL: URL
+ if let delegate = context.uploadDelegate {
+ processedURL = try await delegate.processFile(at: fileURL, mimeType: mimeType)
+ } else {
+ processedURL = fileURL
+ }
+
+ // Step 2: Upload to remote WordPress
+ if let delegate = context.uploadDelegate,
+ let result = try await delegate.uploadFile(at: processedURL, mimeType: mimeType, filename: filename) {
+ return .uploaded(result, processedURL: processedURL)
+ } else if let defaultUploader = context.defaultUploader {
+ // If the delegate didn't modify the file, the original request
+ // body can be forwarded directly — skip multipart re-encoding.
+ if processedURL == fileURL {
+ return .passthrough
+ }
+ let result = try await defaultUploader.upload(fileURL: processedURL, mimeType: mimeType, filename: filename)
+ return .uploaded(result, processedURL: processedURL)
+ } else {
+ throw UploadError.noUploader
+ }
+ }
+
+ // MARK: - CORS
+
+ private static let corsHeaders: [(String, String)] = [
+ ("Access-Control-Allow-Origin", "*"),
+ ("Access-Control-Allow-Headers", "Relay-Authorization, Content-Type"),
+ ]
+
+ private static func corsPreflightResponse() -> HTTPResponse {
+ HTTPResponse(
+ status: 204,
+ headers: corsHeaders + [
+ ("Access-Control-Allow-Methods", "POST, OPTIONS"),
+ ("Access-Control-Max-Age", "86400"),
+ ],
+ body: Data()
+ )
+ }
+
+ private static func errorResponse(status: Int, body: String) -> HTTPResponse {
+ HTTPResponse(
+ status: status,
+ headers: corsHeaders + [("Content-Type", "text/plain")],
+ body: Data(body.utf8)
+ )
+ }
+
+ // MARK: - Helpers
+
+ /// 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() }
+
+ let outputStream = OutputStream(url: url, append: false)!
+ 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
+
+ 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
+
+/// Thread-safe container for the upload delegate and default uploader,
+/// captured by the HTTPServer handler closure.
+private struct UploadContext: Sendable {
+ let uploadDelegate: (any MediaUploadDelegate)?
+ let defaultUploader: DefaultMediaUploader?
+}
+
+// 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, accounting for site API namespaces.
+ private var mediaEndpointURL: URL {
+ let mediaPath = if let siteApiNamespace {
+ "wp/v2/\(siteApiNamespace)media"
+ } else {
+ "wp/v2/media"
+ }
+ return siteApiRoot.appending(path: mediaPath)
+ }
+
+ func upload(fileURL: URL, mimeType: String, filename: String) async throws -> MediaUploadResult {
+ let boundary = UUID().uuidString
+
+ let (bodyStream, contentLength) = try Self.multipartBodyStream(
+ fileURL: fileURL, boundary: boundary, filename: filename, mimeType: mimeType
+ )
+
+ var request = URLRequest(url: mediaEndpointURL)
+ 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) async throws -> MediaUploadResult {
+ var request = URLRequest(url: mediaEndpointURL)
+ 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 -> MediaUploadResult {
+ let (data, response) = try await httpClient.perform(request)
+
+ guard (200...299).contains(response.statusCode) else {
+ let preview = String(data: data.prefix(500), encoding: .utf8) ?? ""
+ throw MediaUploadError.uploadFailed(statusCode: response.statusCode, preview: preview)
+ }
+
+ // Parse the WordPress media response into our result type
+ let wpMedia: WPMediaResponse
+ do {
+ wpMedia = try JSONDecoder().decode(WPMediaResponse.self, from: data)
+ } catch {
+ let preview = String(data: data.prefix(500), encoding: .utf8) ?? ""
+ throw MediaUploadError.unexpectedResponse(preview: preview, underlyingError: error)
+ }
+
+ return MediaUploadResult(
+ id: wpMedia.id,
+ url: wpMedia.source_url,
+ alt: wpMedia.alt_text ?? "",
+ caption: wpMedia.caption?.rendered ?? "",
+ title: wpMedia.title.rendered,
+ mime: wpMedia.mime_type,
+ type: wpMedia.media_type,
+ width: wpMedia.media_details?.width,
+ height: wpMedia.media_details?.height
+ )
+ }
+
+ // 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
+ ) throws -> (InputStream, Int) {
+ let preamble = Data(
+ ("--\(boundary)\r\n"
+ + "Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n"
+ + "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 MediaUploadError.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 MediaUploadError.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
+ }
+ }
+}
+
+/// WordPress REST API media response (subset of fields).
+private struct WPMediaResponse: Decodable {
+ let id: Int
+ let source_url: String
+ let alt_text: String?
+ let caption: RenderedField?
+ let title: RenderedField
+ let mime_type: String
+ let media_type: String
+ let media_details: MediaDetails?
+
+ struct RenderedField: Decodable {
+ let rendered: String
+ }
+
+ struct MediaDetails: Decodable {
+ let width: Int?
+ let height: Int?
+ }
+}
+
+/// Errors specific to the native media upload pipeline.
+enum MediaUploadError: Error, LocalizedError {
+ /// The WordPress REST API returned a non-success HTTP status code.
+ case uploadFailed(statusCode: Int, preview: String)
+
+ /// The WordPress REST API returned a non-JSON response (e.g. HTML error page).
+ case unexpectedResponse(preview: String, underlyingError: Error)
+
+ /// Failed to read the file for streaming upload.
+ case streamReadFailed
+
+ var errorDescription: String? {
+ switch self {
+ case .uploadFailed(let statusCode, let preview):
+ return "Upload failed (\(statusCode)): \(preview)"
+ case .unexpectedResponse(let preview, _):
+ return "WordPress returned an unexpected response: \(preview)"
+ case .streamReadFailed:
+ return "Failed to read file for upload"
+ }
+ }
+}
+
+// MARK: - Helpers
+
+private extension Data {
+ mutating func append(_ string: String) {
+ append(Data(string.utf8))
+ }
+}
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/GutenbergKitHTTP/HTTPRequestParser.swift b/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift
index 293c92773..07f188ea3 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 {
+ // Payload-too-large means "valid headers, rejected body" — let
+ // the caller access the parsed headers so the handler can build
+ // a response (e.g., with CORS headers). Other parse errors
+ // indicate genuinely malformed requests and are still thrown.
+ if let error = _parseError, error != .payloadTooLarge {
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/HTTPServer.swift b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift
index 485cc9b79..83bf016a2 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
@@ -270,11 +280,24 @@ public final class HTTPServer: Sendable {
// Phase 1: receive headers only.
try await Self.receiveUntil(\.hasHeaders, parser: parser, on: connection, idleTimeout: idleTimeout)
+ // Drain oversized body before throwing so the
+ // client receives the 413 (RFC 9110 §15.5.14).
+ if parser.state == .draining {
+ try await Self.receiveUntil(\.isComplete, parser: parser, on: connection, idleTimeout: idleTimeout)
+ }
+
// Validate headers (triggers full RFC validation).
guard let partial = try parser.parseRequest() else {
throw HTTPServerError.connectionClosed
}
+ // 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
+ }
+
// Check auth before consuming body to avoid buffering
// up to maxRequestBodySize for unauthenticated clients.
// OPTIONS is exempt because CORS preflight requests
@@ -313,7 +336,7 @@ public final class HTTPServer: Sendable {
}
}
- let response = await handler(Request(parsed: request, parseDuration: duration))
+ let response = await handler(Request(parsed: request, parseDuration: duration, serverError: parser.parseError))
await send(response, on: connection)
let (sec, atto) = duration.components
let ms = Double(sec) * 1000.0 + Double(atto) / 1_000_000_000_000_000.0
diff --git a/ios/Tests/GutenbergKitHTTPTests/FixtureTests.swift b/ios/Tests/GutenbergKitHTTPTests/FixtureTests.swift
index f361ccdb7..22feadbd0 100644
--- a/ios/Tests/GutenbergKitHTTPTests/FixtureTests.swift
+++ b/ios/Tests/GutenbergKitHTTPTests/FixtureTests.swift
@@ -255,7 +255,14 @@ struct RequestParsingFixtureTests {
let expectedError = testCase.expected.error
do {
_ = try parser.parseRequest()
- Issue.record("Expected error \(expectedError) but parsing succeeded — \(testCase.description)")
+ // Non-fatal errors (e.g., payloadTooLarge) are exposed via
+ // parseError instead of being thrown.
+ if let parseError = parser.parseError {
+ let errorName = String(describing: parseError)
+ #expect(errorName == expectedError, "\(testCase.description): expected \(expectedError) but got \(errorName)")
+ } 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)")
diff --git a/ios/Tests/GutenbergKitHTTPTests/HTTPRequestParserTests.swift b/ios/Tests/GutenbergKitHTTPTests/HTTPRequestParserTests.swift
index f4df6bd08..c4dc5366c 100644
--- a/ios/Tests/GutenbergKitHTTPTests/HTTPRequestParserTests.swift
+++ b/ios/Tests/GutenbergKitHTTPTests/HTTPRequestParserTests.swift
@@ -389,15 +389,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 +433,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/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift
new file mode 100644
index 000000000..02f2f93e7
--- /dev/null
+++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift
@@ -0,0 +1,371 @@
+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("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 == 200)
+
+ #expect(delegate.processFileCalled)
+ #expect(delegate.uploadFileCalled)
+ #expect(delegate.lastMimeType == "image/jpeg")
+ #expect(delegate.lastFilename == "photo.jpg")
+
+ let result = try JSONDecoder().decode(MediaUploadResult.self, from: data)
+ #expect(result.id == 42)
+ #expect(result.url == "https://example.com/photo.jpg")
+ #expect(result.type == "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 == 200)
+
+ #expect(delegate.processFileCalled)
+ // Passthrough: original body forwarded directly, not re-encoded.
+ #expect(mockUploader.passthroughUploadCalled)
+ #expect(!mockUploader.uploadCalled)
+
+ let result = try JSONDecoder().decode(MediaUploadResult.self, from: data)
+ #expect(result.id == 99)
+ }
+
+ @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"))
+ }
+
+ 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
+ )
+ #expect(contentLength == expected.count)
+
+ let result = readAllFromStream(stream)
+ #expect(result == 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"
+ )
+
+ let result = readAllFromStream(stream)
+ #expect(result.count == contentLength)
+ }
+}
+
+// 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) async throws -> URL {
+ lock.withLock {
+ _processFileCalled = true
+ _lastMimeType = mimeType
+ }
+ return url
+ }
+
+ func uploadFile(at url: URL, mimeType: String, filename: String) async throws -> MediaUploadResult? {
+ lock.withLock {
+ _uploadFileCalled = true
+ _lastFilename = filename
+ }
+ return MediaUploadResult(
+ id: 42,
+ url: "https://example.com/photo.jpg",
+ title: "photo",
+ mime: "image/jpeg",
+ type: "image"
+ )
+ }
+}
+
+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) async throws -> URL {
+ lock.withLock { _processFileCalled = true }
+ return url
+ }
+}
+
+private final class MockDefaultUploader: DefaultMediaUploader, @unchecked Sendable {
+ private let lock = NSLock()
+ private var _uploadCalled = false
+ private var _passthroughUploadCalled = false
+
+ var uploadCalled: Bool { lock.withLock { _uploadCalled } }
+ var passthroughUploadCalled: Bool { lock.withLock { _passthroughUploadCalled } }
+
+ init() {
+ super.init(httpClient: MockHTTPClient(), siteApiRoot: URL(string: "https://example.com/wp-json/")!)
+ }
+
+ override func upload(fileURL: URL, mimeType: String, filename: String) async throws -> MediaUploadResult {
+ lock.withLock { _uploadCalled = true }
+ return mockResult()
+ }
+
+ override func passthroughUpload(body: RequestBody, contentType: String) async throws -> MediaUploadResult {
+ lock.withLock { _passthroughUploadCalled = true }
+ return mockResult()
+ }
+
+ private func mockResult() -> MediaUploadResult {
+ MediaUploadResult(
+ id: 99,
+ url: "https://example.com/doc.pdf",
+ title: "doc",
+ mime: "application/pdf",
+ type: "file"
+ )
+ }
+}
+
+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/src/utils/api-fetch-upload-middleware.test.js b/src/utils/api-fetch-upload-middleware.test.js
new file mode 100644
index 000000000..952a5baa8
--- /dev/null
+++ b/src/utils/api-fetch-upload-middleware.test.js
@@ -0,0 +1,352 @@
+/**
+ * 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( 'transforms native response to WordPress REST API shape', async () => {
+ getGBKit.mockReturnValue( {
+ nativeUploadPort: 8080,
+ nativeUploadToken: 'token',
+ } );
+
+ global.fetch = vi.fn( () =>
+ Promise.resolve( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ id: 77,
+ url: 'https://example.com/image.jpg',
+ alt: 'alt text',
+ caption: 'a caption',
+ title: 'image',
+ mime: 'image/jpeg',
+ type: 'image',
+ } ),
+ } )
+ );
+
+ const result = await nativeMediaUploadMiddleware(
+ makePostMediaOptions( makeFile() ),
+ makeNext()
+ );
+
+ expect( result ).toEqual( {
+ 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: 0, height: 0 },
+ link: 'https://example.com/image.jpg',
+ } );
+ } );
+
+ it( 'handles missing optional fields in native response', async () => {
+ getGBKit.mockReturnValue( {
+ nativeUploadPort: 8080,
+ nativeUploadToken: 'token',
+ } );
+
+ global.fetch = vi.fn( () =>
+ Promise.resolve( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ id: 1,
+ url: 'https://example.com/file.pdf',
+ title: 'file',
+ mime: 'application/pdf',
+ type: 'application',
+ } ),
+ } )
+ );
+
+ const result = await nativeMediaUploadMiddleware(
+ makePostMediaOptions( makeFile( 'file.pdf', 'application/pdf' ) ),
+ makeNext()
+ );
+
+ expect( result.alt_text ).toBe( '' );
+ expect( result.caption ).toEqual( { raw: '', rendered: '' } );
+ } );
+
+ // MARK: - Error handling
+
+ it( 'throws on non-ok response from local server', async () => {
+ getGBKit.mockReturnValue( {
+ nativeUploadPort: 8080,
+ nativeUploadToken: 'token',
+ } );
+
+ global.fetch = vi.fn( () =>
+ Promise.resolve( {
+ ok: false,
+ status: 500,
+ statusText: 'Internal Server Error',
+ text: () => Promise.resolve( 'Server crashed' ),
+ } )
+ );
+
+ await expect(
+ nativeMediaUploadMiddleware(
+ makePostMediaOptions( makeFile() ),
+ makeNext()
+ )
+ ).rejects.toMatchObject( {
+ code: 'upload_failed',
+ message: expect.stringContaining( '500' ),
+ } );
+ } );
+
+ it( 'throws on fetch network error', async () => {
+ getGBKit.mockReturnValue( {
+ nativeUploadPort: 8080,
+ nativeUploadToken: 'token',
+ } );
+
+ global.fetch = vi.fn( () =>
+ Promise.reject( new Error( 'Failed to fetch' ) )
+ );
+
+ await expect(
+ nativeMediaUploadMiddleware(
+ makePostMediaOptions( makeFile() ),
+ makeNext()
+ )
+ ).rejects.toBeDefined();
+ } );
+
+ // 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..395bb9541 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,112 @@ 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 ||
+ ! 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 }`
+ );
+
+ const formData = new FormData();
+ formData.append( 'file', file, file.name );
+
+ return fetch( `http://localhost:${ nativeUploadPort }/upload`, {
+ method: 'POST',
+ headers: {
+ 'Relay-Authorization': `Bearer ${ nativeUploadToken }`,
+ },
+ body: formData,
+ signal: options.signal,
+ } )
+ .then( ( response ) => {
+ if ( ! response.ok ) {
+ return response.text().then( ( body ) => {
+ const error = new Error(
+ `Upload failed (${ response.status }): ${
+ body || response.statusText
+ }`
+ );
+ error.code = 'upload_failed';
+ throw error;
+ } );
+ }
+ return response.json();
+ } )
+ .then( ( result ) => {
+ // Transform native server response into WordPress REST API
+ // attachment shape expected by @wordpress/media-utils.
+ return {
+ id: result.id,
+ source_url: result.url,
+ alt_text: result.alt || '',
+ caption: {
+ raw: result.caption || '',
+ rendered: result.caption || '',
+ },
+ title: {
+ raw: result.title || '',
+ rendered: result.title || '',
+ },
+ mime_type: result.mime,
+ media_type: result.type,
+ media_details: {
+ width: result.width || 0,
+ height: result.height || 0,
+ },
+ link: result.url,
+ };
+ } )
+ .catch( ( err ) => {
+ logError( 'Native upload failed', err );
+ throw err;
+ } );
+}
+
/**
* Middleware to modify media upload requests.
*
@@ -157,7 +268,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.
*/
/**