diff --git a/Package.resolved b/Package.resolved index 72ad0425b..2ff636af3 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e4f07fb846fe4e484ddbb0d1c5783d0a62ffee053e850e8a2abe0b8e0323bbd4", + "originHash" : "c6b2e9abe520d144490a97766c0d11f9249bd0d35e0e6f16609262f5e91acea0", "pins" : [ { "identity" : "svgview", @@ -18,15 +18,6 @@ "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 f3ebb2219..0e9e17968 100644 --- a/android/Gutenberg/build.gradle.kts +++ b/android/Gutenberg/build.gradle.kts @@ -77,6 +77,7 @@ dependencies { implementation(libs.jsoup) implementation(libs.okhttp) + testImplementation(libs.json) testImplementation(libs.junit) testImplementation(kotlin("test")) testImplementation(libs.kotlinx.coroutines.test) 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 86f4b2a36..0c69a115f 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 @@ -150,7 +150,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 5184c26c0..978cdf7f6 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -109,6 +109,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 @@ -571,7 +590,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; @@ -582,6 +606,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; @@ -1003,6 +1047,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. @@ -1075,6 +1121,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 4ec3ff1e2..f4d26278f 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 }) @@ -121,6 +129,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 0cd33fef4..9302e77e1 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, enableNativeMediaUpload: Boolean = true, accountId: ULong? = null, 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, 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, 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, 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, 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 fb2c2e6be..1f74e48fd 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -63,6 +63,7 @@ class EditorActivity : ComponentActivity() { companion object { const val EXTRA_DEPENDENCIES_PATH = "dependencies_path" + const val EXTRA_ENABLE_NATIVE_MEDIA_UPLOAD = "enable_native_media_upload" const val EXTRA_ACCOUNT_ID = "account_id" } @@ -104,6 +105,8 @@ class EditorActivity : ComponentActivity() { val dependenciesPath = intent.getStringExtra(EXTRA_DEPENDENCIES_PATH) val dependencies = dependenciesPath?.let { EditorDependenciesSerializer.readFromDisk(it) } + val enableNativeMediaUpload = intent.getBooleanExtra(EXTRA_ENABLE_NATIVE_MEDIA_UPLOAD, true) + // Optional account ID for REST API persistence (set when launched from PostsListActivity) val accountId = intent.getLongExtra(EXTRA_ACCOUNT_ID, -1L).takeIf { it >= 0 }?.toULong() @@ -112,6 +115,7 @@ class EditorActivity : ComponentActivity() { EditorScreen( configuration = configuration, dependencies = dependencies, + enableNativeMediaUpload = enableNativeMediaUpload, accountId = accountId, coroutineScope = this.lifecycleScope, onClose = { finish() }, @@ -136,6 +140,7 @@ class EditorActivity : ComponentActivity() { fun EditorScreen( configuration: EditorConfiguration, dependencies: EditorDependencies? = null, + enableNativeMediaUpload: Boolean = true, accountId: ULong? = null, coroutineScope: CoroutineScope, onClose: () -> Unit, @@ -335,6 +340,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 a66f9326a..d29763b18 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) @@ -299,6 +301,8 @@ private fun LoadedView( FeatureConfigurationCard( enableNativeInserter = uiState.enableNativeInserter, onEnableNativeInserterChange = viewModel::setEnableNativeInserter, + enableNativeMediaUpload = uiState.enableNativeMediaUpload, + onEnableNativeMediaUploadChange = viewModel::setEnableNativeMediaUpload, enableNetworkLogging = uiState.enableNetworkLogging, onEnableNetworkLoggingChange = viewModel::setEnableNetworkLogging, postTypes = uiState.postTypes, @@ -383,6 +387,8 @@ private fun DependenciesStatusCard(hasDependencies: Boolean) { private fun FeatureConfigurationCard( enableNativeInserter: Boolean, onEnableNativeInserterChange: (Boolean) -> Unit, + enableNativeMediaUpload: Boolean, + onEnableNativeMediaUploadChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, postTypes: List, @@ -414,6 +420,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 30bfb961e..3b04a1458 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt @@ -20,6 +20,7 @@ import uniffi.wp_api.PostType as WpPostType data class SitePreparationUiState( val enableNativeInserter: Boolean = true, + val enableNativeMediaUpload: Boolean = true, val enableNetworkLogging: Boolean = false, /** All viewable post types fetched from the site, or empty while loading. */ val postTypes: List = emptyList(), @@ -85,6 +86,10 @@ class SitePreparationViewModel( _uiState.update { it.copy(enableNativeInserter = 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 ed8aedc4f..775fe840c 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -22,6 +22,7 @@ activityCompose = "1.9.3" jsoup = "1.18.1" okhttp = "4.12.0" detekt = "1.23.8" +json = "20240303" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -51,6 +52,7 @@ androidx-activity-compose = { group = "androidx.activity", name = "activity-comp jsoup = { group = "org.jsoup", name = "jsoup", version.ref = "jsoup" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" } +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..b6d772cfb 100644 --- a/ios/Demo-iOS/Sources/ConfigurationItem.swift +++ b/ios/Demo-iOS/Sources/ConfigurationItem.swift @@ -34,11 +34,13 @@ enum ConfigurationItem: Identifiable, Equatable, Hashable { struct RunnableEditor: Equatable, Hashable { let configuration: EditorConfiguration let dependencies: EditorDependencies? + var enableNativeMediaUpload: Bool = true let apiClient: WordPressAPI? - init(configuration: EditorConfiguration, dependencies: EditorDependencies?, apiClient: WordPressAPI? = nil) { + init(configuration: EditorConfiguration, dependencies: EditorDependencies?, enableNativeMediaUpload: Bool = true, apiClient: WordPressAPI? = nil) { self.configuration = configuration self.dependencies = dependencies + self.enableNativeMediaUpload = enableNativeMediaUpload self.apiClient = apiClient } @@ -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..527bc20ff 100644 --- a/ios/Demo-iOS/Sources/GutenbergApp.swift +++ b/ios/Demo-iOS/Sources/GutenbergApp.swift @@ -59,6 +59,7 @@ struct GutenbergApp: App { EditorView( configuration: editor.configuration, dependencies: editor.dependencies, + enableNativeMediaUpload: editor.enableNativeMediaUpload, apiClient: editor.apiClient ) } diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index ca1aaed8a..5b2f20da2 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,18 +10,24 @@ 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 enableNativeMediaUpload: Bool private let apiClient: WordPressAPI? @State private var viewModel = EditorViewModel() @Environment(\.dismiss) var dismiss - init(configuration: EditorConfiguration, dependencies: EditorDependencies? = nil, apiClient: WordPressAPI? = nil) { + init(configuration: EditorConfiguration, dependencies: EditorDependencies? = nil, enableNativeMediaUpload: Bool = true, apiClient: WordPressAPI? = nil) { self.configuration = configuration self.dependencies = dependencies + self.enableNativeMediaUpload = enableNativeMediaUpload self.apiClient = apiClient } @@ -26,6 +35,7 @@ struct EditorView: View { _EditorView( configuration: configuration, dependencies: dependencies, + enableNativeMediaUpload: enableNativeMediaUpload, apiClient: apiClient, viewModel: viewModel ) @@ -100,17 +110,20 @@ struct EditorView: View { private struct _EditorView: UIViewControllerRepresentable { private let configuration: EditorConfiguration private let dependencies: EditorDependencies? + private let enableNativeMediaUpload: Bool private let apiClient: WordPressAPI? private let viewModel: EditorViewModel init( configuration: EditorConfiguration, dependencies: EditorDependencies? = nil, + enableNativeMediaUpload: Bool = true, apiClient: WordPressAPI? = nil, viewModel: EditorViewModel ) { self.configuration = configuration self.dependencies = dependencies + self.enableNativeMediaUpload = enableNativeMediaUpload self.apiClient = apiClient 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 dbe9fe522..914068a72 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -104,11 +104,16 @@ 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 var uploadServer: MediaUploadServer? // MARK: - Private Properties (UI) @@ -164,6 +169,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro self.configuration = configuration self.dependencies = dependencies + self.httpClient = httpClient self.editorService = EditorService( configuration: configuration, httpClient: httpClient @@ -233,10 +239,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 @@ -259,6 +267,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. @@ -279,7 +288,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 @@ -296,12 +305,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) @@ -334,7 +346,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 = """ @@ -346,6 +363,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 fbc2b8005..880bd8fc6 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -213,6 +213,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. */ /**