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