diff --git a/Package.resolved b/Package.resolved index 62c421983..aacd18e8c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b5958ced5a4c7d544f45cfa6cdc8cd0441f5e176874baac30922b53e6cc5aefc", + "originHash" : "6db3023106dfc39818a2a045dfbd8be56ad662c039842cf91e1f21a9bd7ce81f", "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 f3ebb2219..0e9e17968 100644 --- a/android/Gutenberg/build.gradle.kts +++ b/android/Gutenberg/build.gradle.kts @@ -77,6 +77,7 @@ dependencies { implementation(libs.jsoup) implementation(libs.okhttp) + testImplementation(libs.json) testImplementation(libs.junit) testImplementation(kotlin("test")) testImplementation(libs.kotlinx.coroutines.test) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 88bd94fb4..bac7e053c 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -105,6 +105,25 @@ class GutenbergView : WebView { var requestInterceptor: GutenbergRequestInterceptor = DefaultGutenbergRequestInterceptor() + /** Optional delegate for customizing media upload behavior (resize, transcode, custom upload). */ + var mediaUploadDelegate: MediaUploadDelegate? = null + set(value) { + if (field === value) return + field = value + // Stop any previously running server before starting a new one. + uploadServer?.stop() + uploadServer = null + // (Re)start the upload server so it captures the delegate. + // This handles the common case where the delegate is set after + // construction but before the editor finishes loading. + if (value != null) { + startUploadServer() + } + } + + private var uploadServer: MediaUploadServer? = null + private val uploadHttpClient: okhttp3.OkHttpClient by lazy { okhttp3.OkHttpClient() } + private var onFileChooserRequested: ((Intent, Int) -> Unit)? = null private var contentChangeListener: ContentChangeListener? = null private var historyChangeListener: HistoryChangeListener? = null @@ -441,7 +460,12 @@ class GutenbergView : WebView { } 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; @@ -452,6 +476,26 @@ class GutenbergView : WebView { } + private fun startUploadServer() { + if (configuration.siteApiRoot.isEmpty() || configuration.authHeader.isEmpty()) return + + try { + val defaultUploader = DefaultMediaUploader( + httpClient = uploadHttpClient, + siteApiRoot = configuration.siteApiRoot, + authHeader = configuration.authHeader, + siteApiNamespace = configuration.siteApiNamespace.toList() + ) + uploadServer = MediaUploadServer( + uploadDelegate = mediaUploadDelegate, + defaultUploader = defaultUploader, + cacheDir = context.cacheDir + ) + } catch (e: Exception) { + Log.w(TAG, "Failed to start upload server", e) + } + } + fun clearConfig() { val jsCode = """ delete window.GBKit; @@ -879,6 +923,8 @@ class GutenbergView : WebView { override fun onDetachedFromWindow() { super.onDetachedFromWindow() stopNetworkMonitoring() + uploadServer?.stop() + uploadServer = null clearConfig() this.stopLoading() FileCache.clearCache(context) @@ -944,6 +990,8 @@ class GutenbergView : WebView { } companion object { + private const val TAG = "GutenbergView" + /** Hosts that are safe to serve assets over HTTP (local development only). */ private val LOCAL_HOSTS = setOf("localhost", "127.0.0.1", "10.0.2.2") diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt new file mode 100644 index 000000000..badb4d803 --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt @@ -0,0 +1,313 @@ +package org.wordpress.gutenberg + +import android.util.Log +import org.wordpress.gutenberg.http.HeaderValue +import org.wordpress.gutenberg.http.MultipartPart +import org.wordpress.gutenberg.http.MultipartParseException +import java.io.File +import java.io.IOException +import java.util.UUID +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.asRequestBody + +/** + * Result of a successful media upload to the remote WordPress server. + * + * Matches the format expected by Gutenberg's `onFileChange` callback. + */ +data class MediaUploadResult( + val id: Int, + val url: String, + val alt: String = "", + val caption: String = "", + val title: String, + val mime: String, + val type: String, + val width: Int? = null, + val height: Int? = null +) + +/** + * Interface for customizing media upload behavior. + * + * The native host app can provide an implementation to resize images, + * transcode video, or use its own upload service. + */ +interface MediaUploadDelegate { + /** + * Process a file before upload (e.g., resize image, transcode video). + * Return the path of the processed file, or the original path for passthrough. + */ + suspend fun processFile(file: File, mimeType: String): File = file + + /** + * Upload a processed file to the remote WordPress site. + * Return the Gutenberg-compatible media result, or null to use the default uploader. + */ + suspend fun uploadFile(file: File, mimeType: String, filename: String): MediaUploadResult? = null +} + +/** + * A local HTTP server that receives file uploads from the WebView and routes + * them through the native media processing pipeline. + * + * Built on [HttpServer], which handles TCP binding, HTTP parsing, bearer token + * authentication, and connection management. This class provides the upload- + * specific handler: receiving a file, delegating to the host app for + * processing/upload, and returning the result as JSON. + * + * Lifecycle is tied to [GutenbergView] — start when the editor loads, + * stop on detach. + */ +internal class MediaUploadServer( + private val uploadDelegate: MediaUploadDelegate?, + private val defaultUploader: DefaultMediaUploader?, + cacheDir: File? = null +) { + /** The port the server is listening on. */ + val port: Int get() = server.port + + /** Per-session auth token for validating incoming requests. */ + val token: String get() = server.token + + private val server: HttpServer + + init { + server = HttpServer( + name = "media-upload", + externallyAccessible = false, + requiresAuthentication = true, + cacheDir = cacheDir, + handler = { request -> handleRequest(request) } + ) + server.start() + } + + /** Stops the server and releases resources. */ + fun stop() { + server.stop() + } + + // MARK: - Request Handling + + private suspend fun handleRequest(request: HttpRequest): HttpResponse { + // CORS preflight — the library exempts OPTIONS from auth, so this is + // reached without a token. + if (request.method.uppercase() == "OPTIONS") { + return corsPreflightResponse() + } + + // Route: only POST /upload is handled. + if (request.method.uppercase() != "POST" || request.target != "/upload") { + return errorResponse(404, "Not found") + } + + return handleUpload(request) + } + + private suspend fun handleUpload(request: HttpRequest): HttpResponse { + val filePart = parseFilePart(request) + ?: return errorResponse(400, "Expected multipart/form-data with a file") + + val tempFile = writePartToTempFile(filePart) + ?: return errorResponse(500, "Failed to save file") + + return processAndRespond(tempFile, filePart) + } + + private fun parseFilePart(request: HttpRequest): MultipartPart? { + val contentType = request.header("Content-Type") ?: return null + val boundary = HeaderValue.extractParameter("boundary", contentType) ?: return null + val body = request.body ?: return null + + val parts = try { + val inMemory = body.inMemoryData + if (inMemory != null) { + MultipartPart.parse(body, inMemory, 0L, boundary) + } else { + @Suppress("UNCHECKED_CAST") + MultipartPart.parseChunked( + body as org.wordpress.gutenberg.http.RequestBody.FileBacked, + boundary + ) + } + } catch (e: MultipartParseException) { + Log.e(TAG, "Multipart parse failed", e) + return null + } + + return parts.firstOrNull { it.filename != null } + } + + private fun writePartToTempFile(filePart: MultipartPart): File? { + val filename = sanitizeFilename(filePart.filename ?: "upload") + val tempDir = File(System.getProperty("java.io.tmpdir"), "gutenbergkit-uploads").apply { mkdirs() } + val tempFile = File(tempDir, "${UUID.randomUUID()}-$filename") + + return try { + filePart.body.inputStream().use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } + tempFile + } catch (e: IOException) { + Log.e(TAG, "Failed to write upload to disk", e) + null + } + } + + private suspend fun processAndRespond(tempFile: File, filePart: MultipartPart): HttpResponse { + var processedFile: File? = null + try { + val (media, processed) = processAndUpload( + tempFile, filePart.contentType, filePart.filename ?: "upload" + ) + processedFile = processed + return successResponse(media) + } catch (e: MediaUploadException) { + Log.e(TAG, "Upload processing failed", e) + return errorResponse(500, e.message ?: "Upload failed") + } finally { + tempFile.delete() + processedFile?.let { if (it != tempFile) it.delete() } + } + } + + // MARK: - Delegate Pipeline + + private suspend fun processAndUpload( + file: File, mimeType: String, filename: String + ): Pair { + val processedFile = uploadDelegate?.processFile(file, mimeType) ?: file + + val result = uploadDelegate?.uploadFile(processedFile, mimeType, filename) + ?: defaultUploader?.upload(processedFile, mimeType, filename) + ?: error("No upload delegate or default uploader configured") + + return Pair(result, processedFile) + } + + // MARK: - Response Building + + private val corsHeaders: Map = mapOf( + "Access-Control-Allow-Origin" to "*", + "Access-Control-Allow-Headers" to "Relay-Authorization, Content-Type" + ) + + private fun corsPreflightResponse(): HttpResponse = HttpResponse( + status = 204, + headers = corsHeaders + mapOf( + "Access-Control-Allow-Methods" to "POST, OPTIONS", + "Access-Control-Max-Age" to "86400" + ), + body = ByteArray(0) + ) + + private fun successResponse(media: MediaUploadResult): HttpResponse { + val json = org.json.JSONObject().apply { + put("id", media.id) + put("url", media.url) + put("alt", media.alt) + put("caption", media.caption) + put("title", media.title) + put("mime", media.mime) + put("type", media.type) + media.width?.let { put("width", it) } + media.height?.let { put("height", it) } + }.toString() + + return HttpResponse( + status = 200, + headers = corsHeaders + mapOf("Content-Type" to "application/json"), + body = json.toByteArray() + ) + } + + private fun errorResponse(status: Int, body: String): HttpResponse = HttpResponse( + status = status, + headers = corsHeaders + mapOf("Content-Type" to "text/plain"), + body = body.toByteArray() + ) + + // MARK: - Helpers + + /** Sanitizes a filename to prevent path traversal. */ + private fun sanitizeFilename(name: String): String { + val safe = File(name).name.replace(Regex("[/\\\\]"), "") + return safe.ifEmpty { "upload" } + } + + companion object { + private const val TAG = "MediaUploadServer" + } +} + +/** Exception thrown when a media upload fails. */ +internal class MediaUploadException(message: String, cause: Throwable? = null) : Exception(message, cause) + +/** + * Uploads files to the WordPress REST API using OkHttp. + */ +internal open class DefaultMediaUploader( + private val httpClient: okhttp3.OkHttpClient, + private val siteApiRoot: String, + private val authHeader: String, + private val siteApiNamespace: List = emptyList() +) { + open suspend fun upload(file: File, mimeType: String, filename: String): MediaUploadResult { + val mediaType = mimeType.toMediaType() + val requestBody = okhttp3.MultipartBody.Builder() + .setType(okhttp3.MultipartBody.FORM) + .addFormDataPart("file", filename, file.asRequestBody(mediaType)) + .build() + + // When a site API namespace is configured (e.g. "sites/12345/"), insert + // it into the media endpoint path so the request reaches the correct site. + val namespace = siteApiNamespace.firstOrNull() ?: "" + val request = okhttp3.Request.Builder() + .url("${siteApiRoot}wp/v2/${namespace}media") + .addHeader("Authorization", authHeader) + .post(requestBody) + .build() + + val response = httpClient.newCall(request).execute() + val body = response.body?.string() + + if (!response.isSuccessful) { + // Try to extract the human-readable message from a WordPress error + // response ({"code":"...","message":"..."}) before falling back to + // the raw body. + val errorMessage = body?.let { + try { org.json.JSONObject(it).optString("message", null) } catch (_: org.json.JSONException) { null } + } ?: body ?: response.message + throw MediaUploadException(errorMessage) + } + + if (body == null) { + throw MediaUploadException("Empty response body from server") + } + + return parseMediaResponse(body) + } + + private fun parseMediaResponse(body: String): MediaUploadResult { + val json = try { + org.json.JSONObject(body) + } catch (e: org.json.JSONException) { + throw MediaUploadException("Unexpected response: ${body.take(500)}", e) + } + val mediaDetails = json.optJSONObject("media_details") + return MediaUploadResult( + id = json.getInt("id"), + url = json.getString("source_url"), + alt = json.optString("alt_text", ""), + caption = json.optJSONObject("caption")?.optString("rendered", "") ?: "", + title = json.getJSONObject("title").getString("rendered"), + mime = json.getString("mime_type"), + type = json.getString("media_type"), + width = mediaDetails?.optInt("width"), + height = mediaDetails?.optInt("height") + ) + } +} diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt index eeb9f344b..488d3629e 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. */ @@ -90,10 +94,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 { return GBKitGlobal( siteURL = configuration.siteURL.ifEmpty { null }, @@ -113,6 +121,8 @@ data class GBKitGlobal( content = configuration.content.encodeForEditor() ), enableNetworkLogging = configuration.enableNetworkLogging, + nativeUploadPort = nativeUploadPort, + nativeUploadToken = nativeUploadToken, editorSettings = dependencies?.editorSettings?.jsonValue, preloadData = dependencies?.preloadList?.build(), editorAssets = dependencies?.assetBundle?.let { bundle -> diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt new file mode 100644 index 000000000..a7f4f16d7 --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt @@ -0,0 +1,402 @@ +package org.wordpress.gutenberg + +import com.google.gson.JsonParser +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.net.Socket + +class MediaUploadServerTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var server: MediaUploadServer + + @Before + fun setUp() { + server = MediaUploadServer(uploadDelegate = null, defaultUploader = null, cacheDir = tempFolder.root) + } + + @After + fun tearDown() { + server.stop() + } + + // MARK: - Server lifecycle + + @Test + fun `starts and provides a port and token`() { + assertTrue(server.port > 0) + assertTrue(server.token.isNotEmpty()) + } + + // MARK: - Auth validation + + @Test + fun `rejects requests without auth token`() { + val response = sendRawRequest( + method = "POST", + path = "/upload", + headers = mapOf("Content-Type" to "text/plain"), + body = "hello".toByteArray() + ) + + assertTrue(response.statusLine.contains("407")) + } + + @Test + fun `rejects requests with wrong token`() { + val response = sendRawRequest( + method = "POST", + path = "/upload", + headers = mapOf( + "Relay-Authorization" to "Bearer wrong-token", + "Content-Type" to "text/plain" + ), + body = "hello".toByteArray() + ) + + assertTrue(response.statusLine.contains("407")) + } + + // MARK: - CORS preflight + + @Test + fun `responds to OPTIONS preflight with CORS headers`() { + val response = sendRawRequest( + method = "OPTIONS", + path = "/upload", + headers = emptyMap(), + body = null + ) + + assertTrue(response.statusLine.contains("204")) + assertEquals("*", response.headers["access-control-allow-origin"]) + assertTrue(response.headers["access-control-allow-methods"]?.contains("POST") == true) + assertTrue(response.headers["access-control-allow-headers"]?.contains("Relay-Authorization") == true) + } + + // MARK: - Routing + + @Test + fun `returns 404 for unknown paths`() { + val response = sendRawRequest( + method = "GET", + path = "/unknown", + headers = mapOf("Relay-Authorization" to "Bearer ${server.token}"), + body = null + ) + + assertTrue(response.statusLine.contains("404")) + } + + // MARK: - Upload with delegate + + @Test + fun `calls delegate processFile and uploadFile`() { + val delegate = MockUploadDelegate() + server.stop() + server = MediaUploadServer(uploadDelegate = delegate, defaultUploader = null, cacheDir = tempFolder.root) + + val boundary = "test-boundary-123" + val body = buildMultipartBody(boundary, "photo.jpg", "image/jpeg", "fake image data".toByteArray()) + + val response = sendRawRequest( + method = "POST", + path = "/upload", + headers = mapOf( + "Relay-Authorization" to "Bearer ${server.token}", + "Content-Type" to "multipart/form-data; boundary=$boundary" + ), + body = body + ) + + assertTrue("Expected 200 but got: ${response.statusLine}", response.statusLine.contains("200")) + assertTrue(delegate.processFileCalled) + assertTrue(delegate.uploadFileCalled) + assertEquals("image/jpeg", delegate.lastMimeType) + assertEquals("photo.jpg", delegate.lastFilename) + + val json = JsonParser.parseString(response.body).asJsonObject + assertEquals(42, json.get("id").asInt) + assertEquals("https://example.com/photo.jpg", json.get("url").asString) + assertEquals("image", json.get("type").asString) + } + + // MARK: - Fallback to default uploader + + @Test + fun `falls back to default uploader when delegate returns nil for uploadFile`() { + val delegate = ProcessOnlyDelegate() + val mockUploader = MockDefaultUploader() + + server.stop() + server = MediaUploadServer(uploadDelegate = delegate, defaultUploader = mockUploader, cacheDir = tempFolder.root) + + val boundary = "test-boundary-456" + val body = buildMultipartBody(boundary, "doc.pdf", "application/pdf", "fake pdf data".toByteArray()) + + val response = sendRawRequest( + method = "POST", + path = "/upload", + headers = mapOf( + "Relay-Authorization" to "Bearer ${server.token}", + "Content-Type" to "multipart/form-data; boundary=$boundary" + ), + body = body + ) + + assertTrue("Expected 200 but got: ${response.statusLine}", response.statusLine.contains("200")) + assertTrue(delegate.processFileCalled) + assertTrue(mockUploader.uploadCalled) + + val json = JsonParser.parseString(response.body).asJsonObject + assertEquals(99, json.get("id").asInt) + } + + // MARK: - DefaultMediaUploader + + @Test + fun `DefaultMediaUploader sends correct request to WP REST API`() { + val mockWpServer = MockWebServer() + // DefaultMediaUploader uses org.json.JSONObject internally which is + // stubbed in JVM unit tests — so we only verify the outgoing request + // format, not the response parsing. + mockWpServer.enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody( + """{"id":1,"source_url":"u","alt_text":"",""" + + """"caption":{"rendered":""},"title":{"rendered":"t"},""" + + """"mime_type":"image/jpeg","media_type":"image"}""" + ) + ) + mockWpServer.start() + + val wpBaseUrl = mockWpServer.url("/wp-json/").toString() + val uploader = DefaultMediaUploader( + httpClient = okhttp3.OkHttpClient(), + siteApiRoot = wpBaseUrl, + authHeader = "Bearer test-token" + ) + + val file = tempFolder.newFile("image.jpg") + file.writeBytes("fake image".toByteArray()) + + // The upload call will fail at org.json parsing in JVM tests, but we + // can still verify the request was sent correctly. + try { + runBlocking { uploader.upload(file, "image/jpeg", "image.jpg") } + } catch (_: Exception) { + // Expected — org.json stubs return defaults in JVM tests + } + + val request = mockWpServer.takeRequest() + assertEquals("POST", request.method) + assertTrue(request.path!!.contains("wp/v2/media")) + assertEquals("Bearer test-token", request.getHeader("Authorization")) + assertTrue(request.getHeader("Content-Type")!!.contains("multipart/form-data")) + + mockWpServer.shutdown() + } + + @Test + fun `DefaultMediaUploader throws on server error`() { + val mockWpServer = MockWebServer() + mockWpServer.enqueue(MockResponse().setResponseCode(500).setBody("Internal error")) + mockWpServer.start() + + val wpBaseUrl = mockWpServer.url("/wp-json/").toString() + val uploader = DefaultMediaUploader( + httpClient = okhttp3.OkHttpClient(), + siteApiRoot = wpBaseUrl, + authHeader = "Bearer test-token" + ) + + val file = tempFolder.newFile("fail.jpg") + file.writeBytes("data".toByteArray()) + + try { + runBlocking { uploader.upload(file, "image/jpeg", "fail.jpg") } + throw AssertionError("Expected exception") + } catch (e: MediaUploadException) { + assertTrue(e.message!!.contains("Internal error")) + } + + mockWpServer.shutdown() + } + + // MARK: - Bad request handling + + @Test + fun `rejects upload without content type`() { + val response = sendRawRequest( + method = "POST", + path = "/upload", + headers = mapOf("Relay-Authorization" to "Bearer ${server.token}"), + body = "not multipart".toByteArray() + ) + + assertTrue(response.statusLine.contains("400")) + } + + @Test + fun `rejects upload with non-multipart content type`() { + val response = sendRawRequest( + method = "POST", + path = "/upload", + headers = mapOf( + "Relay-Authorization" to "Bearer ${server.token}", + "Content-Type" to "application/json" + ), + body = """{"key": "value"}""".toByteArray() + ) + + assertTrue(response.statusLine.contains("400")) + } + + // MARK: - Helpers + + private data class RawHttpResponse( + val statusLine: String, + val headers: Map, + val body: String + ) + + private fun sendRawRequest( + method: String, + path: String, + headers: Map, + body: ByteArray? + ): RawHttpResponse { + val socket = Socket("127.0.0.1", server.port) + socket.soTimeout = 5000 + + val output = socket.getOutputStream() + val request = buildString { + append("$method $path HTTP/1.1\r\n") + append("Host: 127.0.0.1:${server.port}\r\n") + for ((key, value) in headers) { + append("$key: $value\r\n") + } + if (body != null) { + append("Content-Length: ${body.size}\r\n") + } + append("Connection: close\r\n") + append("\r\n") + } + + output.write(request.toByteArray()) + if (body != null) { + output.write(body) + } + output.flush() + + val responseBytes = socket.getInputStream().readBytes() + socket.close() + + val responseString = String(responseBytes, Charsets.UTF_8) + val headerEnd = responseString.indexOf("\r\n\r\n") + if (headerEnd < 0) { + return RawHttpResponse(responseString, emptyMap(), "") + } + + val headerSection = responseString.substring(0, headerEnd) + val responseBody = responseString.substring(headerEnd + 4) + val lines = headerSection.split("\r\n") + val statusLine = lines.first() + + val responseHeaders = mutableMapOf() + for (line in lines.drop(1)) { + val colonIndex = line.indexOf(':') + if (colonIndex > 0) { + val key = line.substring(0, colonIndex).trim().lowercase() + val value = line.substring(colonIndex + 1).trim() + responseHeaders[key] = value + } + } + + return RawHttpResponse(statusLine, responseHeaders, responseBody) + } + + private fun buildMultipartBody( + boundary: String, + filename: String, + mimeType: String, + data: ByteArray + ): ByteArray { + val out = java.io.ByteArrayOutputStream() + out.write("--$boundary\r\n".toByteArray()) + out.write("Content-Disposition: form-data; name=\"file\"; filename=\"$filename\"\r\n".toByteArray()) + out.write("Content-Type: $mimeType\r\n\r\n".toByteArray()) + out.write(data) + out.write("\r\n--$boundary--\r\n".toByteArray()) + return out.toByteArray() + } + + // MARK: - Mocks + + private class MockUploadDelegate : MediaUploadDelegate { + @Volatile var processFileCalled = false + @Volatile var uploadFileCalled = false + @Volatile var lastMimeType: String? = null + @Volatile var lastFilename: String? = null + + override suspend fun processFile(file: File, mimeType: String): File { + processFileCalled = true + lastMimeType = mimeType + return file + } + + override suspend fun uploadFile(file: File, mimeType: String, filename: String): MediaUploadResult? { + uploadFileCalled = true + lastFilename = filename + return MediaUploadResult( + id = 42, + url = "https://example.com/photo.jpg", + title = "photo", + mime = "image/jpeg", + type = "image" + ) + } + } + + private class ProcessOnlyDelegate : MediaUploadDelegate { + @Volatile var processFileCalled = false + + override suspend fun processFile(file: File, mimeType: String): File { + processFileCalled = true + return file + } + } + + private class MockDefaultUploader : DefaultMediaUploader( + httpClient = okhttp3.OkHttpClient(), + siteApiRoot = "https://example.com/wp-json/", + authHeader = "Bearer mock" + ) { + @Volatile var uploadCalled = false + + override suspend fun upload(file: File, mimeType: String, filename: String): MediaUploadResult { + uploadCalled = true + return MediaUploadResult( + id = 99, + url = "https://example.com/doc.pdf", + title = "doc", + mime = "application/pdf", + type = "file" + ) + } + } + +} diff --git a/android/app/detekt-baseline.xml b/android/app/detekt-baseline.xml index 3fb3b6665..0571fa0d4 100644 --- a/android/app/detekt-baseline.xml +++ b/android/app/detekt-baseline.xml @@ -2,11 +2,12 @@ - LongMethod:EditorActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun EditorScreen( configuration: EditorConfiguration, dependencies: EditorDependencies? = null, coroutineScope: CoroutineScope, onClose: () -> Unit, onGutenbergViewCreated: (GutenbergView) -> Unit = {} ) + LongMethod:EditorActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun EditorScreen( configuration: EditorConfiguration, dependencies: EditorDependencies? = null, enableNativeMediaUpload: Boolean = true, 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:SitePreparationActivity.kt$@Composable private fun FeatureConfigurationCard( enableNativeInserter: Boolean, onEnableNativeInserterChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, postType: String, onPostTypeChange: (String) -> Unit ) + LongMethod:SitePreparationActivity.kt$@Composable private fun FeatureConfigurationCard( enableNativeInserter: Boolean, onEnableNativeInserterChange: (Boolean) -> Unit, enableNativeMediaUpload: Boolean, onEnableNativeMediaUploadChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, postType: String, onPostTypeChange: (String) -> 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, enableNativeMediaUpload: Boolean, onEnableNativeMediaUploadChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, postType: String, onPostTypeChange: (String) -> Unit ) MaxLineLength:MediaProxyServerActivity.kt$Text("Size", fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f)) MaxLineLength:MediaProxyServerActivity.kt$Text("Throughput", fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f)) MaxLineLength:MediaProxyServerActivity.kt$Text("Time", fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f)) diff --git a/android/app/src/main/java/com/example/gutenbergkit/DemoMediaUploadDelegate.kt b/android/app/src/main/java/com/example/gutenbergkit/DemoMediaUploadDelegate.kt new file mode 100644 index 000000000..7f390546a --- /dev/null +++ b/android/app/src/main/java/com/example/gutenbergkit/DemoMediaUploadDelegate.kt @@ -0,0 +1,64 @@ +package com.example.gutenbergkit + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Log +import org.wordpress.gutenberg.MediaUploadDelegate +import java.io.File + +/** + * Demo media upload delegate that resizes images to a maximum dimension of 2000px. + * + * Only overrides [processFile] — [uploadFile] returns null so the default uploader is used. + */ +class DemoMediaUploadDelegate : MediaUploadDelegate { + companion object { + private const val TAG = "DemoMediaUploadDelegate" + } + + override suspend fun processFile(file: File, mimeType: String): File { + if (!mimeType.startsWith("image/") || mimeType == "image/gif") { + return file + } + + val maxDimension = 2000 + + val options = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + BitmapFactory.decodeFile(file.absolutePath, options) + + val width = options.outWidth + val height = options.outHeight + if (width <= 0 || height <= 0) return file + + val longestSide = maxOf(width, height) + if (longestSide <= maxDimension) return file + + // Calculate sample size for memory-efficient decoding + val sampleSize = Integer.highestOneBit(longestSide / maxDimension) + val decodeOptions = BitmapFactory.Options().apply { + inSampleSize = sampleSize + } + val sampled = BitmapFactory.decodeFile(file.absolutePath, decodeOptions) ?: return file + + // Scale to exact target dimensions + val scale = maxDimension.toFloat() / longestSide.toFloat() + val targetWidth = (width * scale).toInt() + val targetHeight = (height * scale).toInt() + val scaled = Bitmap.createScaledBitmap(sampled, targetWidth, targetHeight, true) + if (scaled !== sampled) sampled.recycle() + + val outputFile = File(file.parent, "resized-${file.name}") + val format = if (mimeType == "image/png") Bitmap.CompressFormat.PNG + else Bitmap.CompressFormat.JPEG + + outputFile.outputStream().use { out -> + scaled.compress(format, 85, out) + } + scaled.recycle() + + Log.d(TAG, "Resized image from ${width}×${height} to ${targetWidth}×${targetHeight}") + return outputFile + } +} diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 78ed9dce4..97bd8f874 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -63,6 +63,7 @@ class EditorActivity : ComponentActivity() { companion object { const val EXTRA_DEPENDENCIES_PATH = "dependencies_path" + const val EXTRA_ENABLE_NATIVE_MEDIA_UPLOAD = "enable_native_media_upload" } private var gutenbergView: GutenbergView? = null @@ -103,11 +104,14 @@ class EditorActivity : ComponentActivity() { val dependenciesPath = intent.getStringExtra(EXTRA_DEPENDENCIES_PATH) val dependencies = dependenciesPath?.let { EditorDependenciesSerializer.readFromDisk(it) } + val enableNativeMediaUpload = intent.getBooleanExtra(EXTRA_ENABLE_NATIVE_MEDIA_UPLOAD, true) + setContent { AppTheme { EditorScreen( configuration = configuration, dependencies = dependencies, + enableNativeMediaUpload = enableNativeMediaUpload, coroutineScope = this.lifecycleScope, onClose = { finish() }, onGutenbergViewCreated = { view -> @@ -145,6 +149,7 @@ enum class EditorLoadingState { fun EditorScreen( configuration: EditorConfiguration, dependencies: EditorDependencies? = null, + enableNativeMediaUpload: Boolean = true, coroutineScope: CoroutineScope, onClose: () -> Unit, onGutenbergViewCreated: (GutenbergView) -> Unit = {} @@ -351,6 +356,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 27043206b..7aaab08d0 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt @@ -145,8 +145,8 @@ class SitePreparationActivity : ComponentActivity() { SitePreparationScreen( viewModel = viewModel, onClose = { finish() }, - onStartEditor = { configuration, dependencies -> - launchEditor(configuration, dependencies) + onStartEditor = { configuration, dependencies, enableNativeMediaUpload -> + launchEditor(configuration, dependencies, enableNativeMediaUpload) } ) } @@ -155,10 +155,12 @@ class SitePreparationActivity : ComponentActivity() { private fun launchEditor( configuration: EditorConfiguration, - dependencies: org.wordpress.gutenberg.model.EditorDependencies? + dependencies: org.wordpress.gutenberg.model.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) { @@ -175,7 +177,7 @@ class SitePreparationActivity : ComponentActivity() { fun SitePreparationScreen( viewModel: SitePreparationViewModel, onClose: () -> Unit, - onStartEditor: (EditorConfiguration, org.wordpress.gutenberg.model.EditorDependencies?) -> Unit + onStartEditor: (EditorConfiguration, org.wordpress.gutenberg.model.EditorDependencies?, Boolean) -> Unit ) { val uiState by viewModel.uiState.collectAsState() @@ -201,7 +203,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) @@ -264,6 +266,8 @@ private fun LoadedView( FeatureConfigurationCard( enableNativeInserter = uiState.enableNativeInserter, onEnableNativeInserterChange = viewModel::setEnableNativeInserter, + enableNativeMediaUpload = uiState.enableNativeMediaUpload, + onEnableNativeMediaUploadChange = viewModel::setEnableNativeMediaUpload, enableNetworkLogging = uiState.enableNetworkLogging, onEnableNetworkLoggingChange = viewModel::setEnableNetworkLogging, postType = uiState.postType, @@ -345,6 +349,8 @@ private fun DependenciesStatusCard(hasDependencies: Boolean) { private fun FeatureConfigurationCard( enableNativeInserter: Boolean, onEnableNativeInserterChange: (Boolean) -> Unit, + enableNativeMediaUpload: Boolean, + onEnableNativeMediaUploadChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, postType: String, @@ -373,6 +379,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 bb1a6c81f..ec66bf8af 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt @@ -17,6 +17,7 @@ import org.wordpress.gutenberg.services.EditorService data class SitePreparationUiState( val enableNativeInserter: Boolean = true, + val enableNativeMediaUpload: Boolean = true, val enableNetworkLogging: Boolean = false, val postType: String = "post", val cacheBundleCount: Int? = null, @@ -79,6 +80,10 @@ class SitePreparationViewModel( _uiState.update { it.copy(enableNativeInserter = enabled) } } + fun setEnableNativeMediaUpload(enabled: Boolean) { + _uiState.update { it.copy(enableNativeMediaUpload = enabled) } + } + fun setEnableNetworkLogging(enabled: Boolean) { _uiState.update { it.copy(enableNetworkLogging = enabled) } } diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index a84dd390e..2f90b2554 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -22,6 +22,7 @@ activityCompose = "1.9.3" jsoup = "1.18.1" okhttp = "4.12.0" detekt = "1.23.8" +json = "20240303" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -51,6 +52,7 @@ androidx-activity-compose = { group = "androidx.activity", name = "activity-comp jsoup = { group = "org.jsoup", name = "jsoup", version.ref = "jsoup" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" } +json = { group = "org.json", name = "json", version.ref = "json" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/ios/Demo-iOS/Sources/ConfigurationItem.swift b/ios/Demo-iOS/Sources/ConfigurationItem.swift index 3cf171574..a2ee9b2f6 100644 --- a/ios/Demo-iOS/Sources/ConfigurationItem.swift +++ b/ios/Demo-iOS/Sources/ConfigurationItem.swift @@ -34,6 +34,7 @@ enum ConfigurationItem: Identifiable, Equatable, Hashable { struct RunnableEditor: Equatable, Hashable { let configuration: EditorConfiguration let dependencies: EditorDependencies? + var enableNativeMediaUpload: Bool = true } /// Credentials loaded from the wp-env setup script output diff --git a/ios/Demo-iOS/Sources/GutenbergApp.swift b/ios/Demo-iOS/Sources/GutenbergApp.swift index 08650f077..1782b3b5f 100644 --- a/ios/Demo-iOS/Sources/GutenbergApp.swift +++ b/ios/Demo-iOS/Sources/GutenbergApp.swift @@ -56,7 +56,7 @@ struct GutenbergApp: App { let editor = navigation.editor! NavigationStack { - EditorView(configuration: editor.configuration, dependencies: editor.dependencies) + EditorView(configuration: editor.configuration, dependencies: editor.dependencies, enableNativeMediaUpload: editor.enableNativeMediaUpload) } } diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 6345c8dc5..fe359ed00 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -1,23 +1,33 @@ import SwiftUI +import ImageIO +import OSLog +import UniformTypeIdentifiers import GutenbergKit +private extension Logger { + static let demo = Logger(subsystem: "GutenbergKit-Demo", category: "media-upload") +} + struct EditorView: View { private let configuration: EditorConfiguration private let dependencies: EditorDependencies? + private let enableNativeMediaUpload: Bool @State private var viewModel = EditorViewModel() @Environment(\.dismiss) var dismiss - init(configuration: EditorConfiguration, dependencies: EditorDependencies? = nil) { + init(configuration: EditorConfiguration, dependencies: EditorDependencies? = nil, enableNativeMediaUpload: Bool = true) { self.configuration = configuration self.dependencies = dependencies + self.enableNativeMediaUpload = enableNativeMediaUpload } var body: some View { _EditorView( configuration: configuration, dependencies: dependencies, + enableNativeMediaUpload: enableNativeMediaUpload, viewModel: viewModel ) .toolbar { toolbar } @@ -99,15 +109,18 @@ struct EditorView: View { private struct _EditorView: UIViewControllerRepresentable { private let configuration: EditorConfiguration private let dependencies: EditorDependencies? + private let enableNativeMediaUpload: Bool private let viewModel: EditorViewModel init( configuration: EditorConfiguration, dependencies: EditorDependencies? = nil, + enableNativeMediaUpload: Bool = true, viewModel: EditorViewModel ) { self.configuration = configuration self.dependencies = dependencies + self.enableNativeMediaUpload = enableNativeMediaUpload self.viewModel = viewModel } @@ -118,6 +131,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 @@ -135,7 +151,7 @@ private struct _EditorView: UIViewControllerRepresentable { } @MainActor - class Coordinator: NSObject, EditorViewControllerDelegate { + class Coordinator: NSObject, EditorViewControllerDelegate, MediaUploadDelegate { let viewModel: EditorViewModel init(viewModel: EditorViewModel) { @@ -223,6 +239,61 @@ private struct _EditorView: UIViewControllerRepresentable { // In a real app, return the persisted title and content from autosave. return nil } + + // MARK: - MediaUploadDelegate + + /// Resizes images to a maximum dimension of 2000px before upload. + nonisolated func processFile(at url: URL, mimeType: String) async throws -> URL { + guard mimeType.hasPrefix("image/"), mimeType != "image/gif" else { + return url + } + + let maxDimension: CGFloat = 2000 + + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? CGFloat, + let height = properties[kCGImagePropertyPixelHeight] as? CGFloat else { + return url + } + + let longestSide = max(width, height) + guard longestSide > maxDimension else { + return url + } + + let options: [CFString: Any] = [ + kCGImageSourceThumbnailMaxPixelSize: maxDimension, + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true + ] + + guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + return url + } + + let outputURL = url.deletingLastPathComponent() + .appending(component: "resized-\(url.lastPathComponent)") + + let sourceType = CGImageSourceGetType(source) ?? (UTType.png.identifier as CFString) + guard let destination = CGImageDestinationCreateWithURL( + outputURL as CFURL, + sourceType, + 1, + nil + ) else { + return url + } + + CGImageDestinationAddImage(destination, thumbnail, nil) + guard CGImageDestinationFinalize(destination) else { + return url + } + + Logger.demo.info("Resized image from \(Int(width))x\(Int(height)) to fit \(Int(maxDimension))px") + return outputURL + } + } } diff --git a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift index b2aab0be4..6df66847b 100644 --- a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift +++ b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift @@ -55,6 +55,7 @@ struct SitePreparationView: View { Section("Feature Configuration") { Toggle("Enable Native Inserter", isOn: $viewModel.enableNativeInserter) + Toggle("Enable Native Media Upload", isOn: $viewModel.enableNativeMediaUpload) Toggle("Enable Network Logging", isOn: $viewModel.enableNetworkLogging) Picker("Network Fallback", selection: $viewModel.networkFallbackMode) { @@ -154,6 +155,8 @@ class SitePreparationViewModel { } } + var enableNativeMediaUpload: Bool = true + var enableNetworkLogging: Bool { get { editorConfiguration?.enableNetworkLogging ?? false } set { @@ -494,7 +497,8 @@ class SitePreparationViewModel { let editor = RunnableEditor( configuration: configuration, - dependencies: self.editorDependencies + dependencies: self.editorDependencies, + enableNativeMediaUpload: self.enableNativeMediaUpload ) navigation.present(editor) diff --git a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift index 431a03828..eba61c3cb 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift @@ -32,13 +32,21 @@ struct WPError: Decodable { public actor EditorHTTPClient: EditorHTTPClientProtocol { /// Errors that can occur during HTTP requests. - enum ClientError: Error { + enum ClientError: Error, LocalizedError { /// The server returned a WordPress-formatted error response. case wpError(WPError) /// A file download failed with the given HTTP status code. case downloadFailed(statusCode: Int) /// An unexpected error occurred with the given response data and status code. case unknown(response: Data, statusCode: Int) + + var errorDescription: String? { + switch self { + case .wpError(let error): error.message + case .downloadFailed(let code): "Download failed (\(code))" + case .unknown(_, let code): "Request failed (\(code))" + } + } } /// The base user agent string identifying the platform. diff --git a/ios/Sources/GutenbergKit/Sources/EditorLogging.swift b/ios/Sources/GutenbergKit/Sources/EditorLogging.swift index 3f74e27a0..c24bf793e 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorLogging.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorLogging.swift @@ -22,6 +22,9 @@ extension Logger { /// Logs editor navigation activity public static let navigation = Logger(subsystem: "GutenbergKit", category: "navigation") + + /// Logs upload server activity + static let uploadServer = Logger(subsystem: "GutenbergKit", category: "upload-server") } public struct SignpostMonitor: Sendable { diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 1993d818d..4be82a511 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -104,11 +104,16 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// Used by `EditorViewController.warmup()` to reduce first-render latency. private let isWarmupMode: Bool + /// Delegate for customizing media file processing and upload behavior. + public weak var mediaUploadDelegate: (any MediaUploadDelegate)? + // MARK: - Private Properties (Services) private let editorService: EditorService + private let httpClient: any EditorHTTPClientProtocol private let mediaPicker: MediaPickerController? private let controller: GutenbergEditorController private let bundleProvider: EditorAssetBundleProvider + private var uploadServer: MediaUploadServer? // MARK: - Private Properties (UI) @@ -164,6 +169,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro self.configuration = configuration self.dependencies = dependencies + self.httpClient = httpClient self.editorService = EditorService( configuration: configuration, httpClient: httpClient @@ -233,10 +239,12 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro if let dependencies { // FAST PATH: Dependencies were provided at init() - load immediately - do { - try self.loadEditor(dependencies: dependencies) - } catch { - self.error = error + self.dependencyTaskHandle = Task(priority: .userInitiated) { [weak self] in + do { + try await self?.loadEditor(dependencies: dependencies) + } catch { + self?.error = error + } } } else { // ASYNC FLOW: No dependencies - fetch them asynchronously @@ -259,6 +267,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro public override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) self.dependencyTaskHandle?.cancel() + self.uploadServer?.stop() } /// Fetches all required dependencies and then loads the editor. @@ -279,7 +288,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro self.dependencies = dependencies // Continue to the shared loading path - try self.loadEditor(dependencies: dependencies) + try await self.loadEditor(dependencies: dependencies) } catch { // Display error view - this sets self.error which triggers displayError() self.error = error @@ -296,12 +305,15 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// The editor will eventually emit an `onEditorLoaded` message, triggering `didLoadEditor()`. /// @MainActor - private func loadEditor(dependencies: EditorDependencies) throws { + private func loadEditor(dependencies: EditorDependencies) async throws { self.displayActivityView() // Set asset bundle for the URL scheme handler to serve cached plugin/theme assets self.bundleProvider.set(bundle: dependencies.assetBundle) + // Start the local upload server for native media processing + await startUploadServer() + // Build and inject editor configuration as window.GBKit let editorConfig = try buildEditorConfiguration(dependencies: dependencies) webView.configuration.userContentController.addUserScript(editorConfig) @@ -334,7 +346,12 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// when it initializes. /// private func buildEditorConfiguration(dependencies: EditorDependencies) throws -> WKUserScript { - let gbkitGlobal = try GBKitGlobal(configuration: self.configuration, dependencies: dependencies) + let gbkitGlobal = try GBKitGlobal( + configuration: self.configuration, + dependencies: dependencies, + nativeUploadPort: uploadServer.map { Int($0.port) }, + nativeUploadToken: uploadServer?.token + ) let stringValue = try gbkitGlobal.toString() let jsCode = """ @@ -346,6 +363,32 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro return WKUserScript(source: jsCode, injectionTime: .atDocumentStart, forMainFrameOnly: true) } + /// Starts the local HTTP server for routing file uploads through native processing. + /// + /// The server binds to localhost on a random port. If it fails to start, the editor + /// falls back to Gutenberg's default upload behavior (the JS override won't activate + /// because `nativeUploadPort` will be nil in GBKit). + private func startUploadServer() async { + guard mediaUploadDelegate != nil else { + return + } + + let defaultUploader = DefaultMediaUploader( + httpClient: httpClient, + siteApiRoot: configuration.siteApiRoot, + siteApiNamespace: configuration.siteApiNamespace + ) + + do { + self.uploadServer = try await MediaUploadServer.start( + uploadDelegate: mediaUploadDelegate, + defaultUploader: defaultUploader + ) + } catch { + Logger.uploadServer.error("Failed to start upload server: \(error). Falling back to default upload behavior.") + } + } + /// Deletes all cached editor data for all sites public static func deleteAllData() throws { if FileManager.default.directoryExists(at: Paths.defaultCacheRoot) { diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift new file mode 100644 index 000000000..08947ca22 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift @@ -0,0 +1,54 @@ +import Foundation + +/// Result of a successful media upload to the remote WordPress server. +/// +/// This structure matches the format expected by Gutenberg's `onFileChange` callback. +public struct MediaUploadResult: Codable, Sendable { + public let id: Int + public let url: String + public let alt: String + public let caption: String + public let title: String + public let mime: String + public let type: String + public let width: Int? + public let height: Int? + + public init(id: Int, url: String, alt: String = "", caption: String = "", title: String, mime: String, type: String, width: Int? = nil, height: Int? = nil) { + self.id = id + self.url = url + self.alt = alt + self.caption = caption + self.title = title + self.mime = mime + self.type = type + self.width = width + self.height = height + } +} + +/// Protocol for customizing media upload behavior. +/// +/// The native host app can provide an implementation to resize images, +/// transcode video, or use its own upload service. Default implementations +/// pass files through unchanged and upload via the WordPress REST API. +public protocol MediaUploadDelegate: AnyObject, Sendable { + /// Process a file before upload (e.g., resize image, transcode video). + /// Return the URL of the processed file, or the original URL for passthrough. + func processFile(at url: URL, mimeType: String) async throws -> URL + + /// Upload a processed file to the remote WordPress site. + /// Return the Gutenberg-compatible media result, or `nil` to use the default uploader. + func uploadFile(at url: URL, mimeType: String, filename: String) async throws -> MediaUploadResult? +} + +/// Default implementations. +extension MediaUploadDelegate { + public func processFile(at url: URL, mimeType: String) async throws -> URL { + url + } + + public func uploadFile(at url: URL, mimeType: String, filename: String) async throws -> MediaUploadResult? { + nil + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift new file mode 100644 index 000000000..c4ef144b8 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -0,0 +1,383 @@ +import Foundation +import GutenbergKitHTTP +import OSLog + +/// A local HTTP server that receives file uploads from the WebView and routes +/// them through the native media processing pipeline. +/// +/// Built on ``HTTPServer`` from `GutenbergKitHTTP`, which handles TCP binding, +/// HTTP parsing, bearer token authentication, and multipart form-data parsing. +/// This class provides the upload-specific handler: receiving a file, delegating +/// to the host app for processing/upload, and returning the result as JSON. +/// +/// Lifecycle is tied to `EditorViewController` — start when the editor loads, +/// stop on deinit. +final class MediaUploadServer: Sendable { + + /// The port the server is listening on. + let port: UInt16 + + /// Per-session auth token for validating incoming requests. + let token: String + + private let server: HTTPServer + + /// Creates and starts a new upload server. + /// + /// - Parameters: + /// - uploadDelegate: Optional delegate for customizing file processing and upload. + /// - defaultUploader: Fallback uploader used when no delegate provides `uploadFile`. + static func start( + uploadDelegate: (any MediaUploadDelegate)? = nil, + defaultUploader: DefaultMediaUploader? = nil + ) async throws -> MediaUploadServer { + let context = UploadContext(uploadDelegate: uploadDelegate, defaultUploader: defaultUploader) + + let server = try await HTTPServer.start( + name: "media-upload", + requiresAuthentication: true, + handler: { request in + await Self.handleRequest(request, context: context) + } + ) + + return MediaUploadServer(server: server) + } + + private init(server: HTTPServer) { + self.server = server + self.port = server.port + self.token = server.token + } + + /// Stops the server and releases resources. + func stop() { + server.stop() + } + + // MARK: - Request Handling + + private static func handleRequest(_ request: HTTPServer.Request, context: UploadContext) async -> HTTPResponse { + let parsed = request.parsed + + // CORS preflight — the library exempts OPTIONS from auth, so this is + // reached without a token. + if parsed.method.uppercased() == "OPTIONS" { + return corsPreflightResponse() + } + + // Route: only POST /upload is handled. + guard parsed.method.uppercased() == "POST", parsed.target == "/upload" else { + return errorResponse(status: 404, body: "Not found") + } + + return await handleUpload(request, context: context) + } + + private static func handleUpload(_ request: HTTPServer.Request, context: UploadContext) async -> HTTPResponse { + let parts: [MultipartPart] + do { + parts = try request.parsed.multipartParts() + } catch { + Logger.uploadServer.error("Multipart parse failed: \(error)") + return errorResponse(status: 400, body: "Expected multipart/form-data") + } + + // Find the file part (the first part with a filename). + guard let filePart = parts.first(where: { $0.filename != nil }) else { + return errorResponse(status: 400, body: "No file found in request") + } + + // Write part body to a dedicated temp file for the delegate. + // + // The library's RequestBody may be a byte-range slice of a larger temp + // file whose lifecycle is tied to ARC. The delegate needs a standalone + // file that outlives the handler return, so we stream to our own file. + let filename = sanitizeFilename(filePart.filename ?? "upload") + let mimeType = filePart.contentType + + let tempDir = FileManager.default.temporaryDirectory + .appending(component: "GutenbergKit-uploads", directoryHint: .isDirectory) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + + let fileURL = tempDir.appending(component: "\(UUID().uuidString)-\(filename)") + do { + let inputStream = try filePart.body.makeInputStream() + try writeStream(inputStream, to: fileURL) + } catch { + Logger.uploadServer.error("Failed to write upload to disk: \(error)") + return errorResponse(status: 500, body: "Failed to save file") + } + + // Process and upload through the delegate pipeline. + let result: Result + var processedURL: URL? + do { + let (media, processed) = try await processAndUpload( + fileURL: fileURL, mimeType: mimeType, filename: filePart.filename ?? "upload", context: context + ) + processedURL = processed + result = .success(media) + } catch { + result = .failure(error) + } + + // Clean up temp files (success or failure). + try? FileManager.default.removeItem(at: fileURL) + if let processedURL, processedURL != fileURL { + try? FileManager.default.removeItem(at: processedURL) + } + + switch result { + case .success(let media): + do { + let json = try JSONEncoder().encode(media) + return HTTPResponse( + status: 200, + headers: corsHeaders + [("Content-Type", "application/json")], + body: json + ) + } catch { + return errorResponse(status: 500, body: "Failed to encode response") + } + case .failure(let error): + Logger.uploadServer.error("Upload processing failed: \(error)") + return errorResponse(status: 500, body: error.localizedDescription) + } + } + + // MARK: - Delegate Pipeline + + private static func processAndUpload( + fileURL: URL, mimeType: String, filename: String, context: UploadContext + ) async throws -> (MediaUploadResult, URL) { + // Step 1: Process (resize, transcode, etc.) + let processedURL: URL + if let delegate = context.uploadDelegate { + processedURL = try await delegate.processFile(at: fileURL, mimeType: mimeType) + } else { + processedURL = fileURL + } + + // Step 2: Upload to remote WordPress + if let delegate = context.uploadDelegate, + let result = try await delegate.uploadFile(at: processedURL, mimeType: mimeType, filename: filename) { + return (result, processedURL) + } else if let defaultUploader = context.defaultUploader { + return (try await defaultUploader.upload(fileURL: processedURL, mimeType: mimeType, filename: filename), processedURL) + } else { + throw UploadError.noUploader + } + } + + // MARK: - CORS + + private static let corsHeaders: [(String, String)] = [ + ("Access-Control-Allow-Origin", "*"), + ("Access-Control-Allow-Headers", "Relay-Authorization, Content-Type"), + ] + + private static func corsPreflightResponse() -> HTTPResponse { + HTTPResponse( + status: 204, + headers: corsHeaders + [ + ("Access-Control-Allow-Methods", "POST, OPTIONS"), + ("Access-Control-Max-Age", "86400"), + ], + body: Data() + ) + } + + private static func errorResponse(status: Int, body: String) -> HTTPResponse { + HTTPResponse( + status: status, + headers: corsHeaders + [("Content-Type", "text/plain")], + body: Data(body.utf8) + ) + } + + // MARK: - Helpers + + /// Sanitizes a filename to prevent path traversal. + private static func sanitizeFilename(_ name: String) -> String { + let safe = (name as NSString).lastPathComponent + .replacingOccurrences(of: "/", with: "") + .replacingOccurrences(of: "\\", with: "") + return safe.isEmpty ? "upload" : safe + } + + /// Streams an InputStream to a file URL. + private static func writeStream(_ inputStream: InputStream, to url: URL) throws { + inputStream.open() + defer { inputStream.close() } + + let outputStream = OutputStream(url: url, append: false)! + outputStream.open() + defer { outputStream.close() } + + let bufferSize = 65_536 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + + // Use read() return value as the sole termination signal. Do NOT check + // hasBytesAvailable — for piped streams (used by file-slice RequestBody), + // it can return false before the writer thread has pumped the next chunk, + // causing an early exit and a truncated file. + while true { + let bytesRead = inputStream.read(buffer, maxLength: bufferSize) + if bytesRead < 0 { + throw inputStream.streamError ?? UploadError.streamReadFailed + } + if bytesRead == 0 { break } + + var totalWritten = 0 + while totalWritten < bytesRead { + let written = outputStream.write(buffer.advanced(by: totalWritten), maxLength: bytesRead - totalWritten) + if written < 0 { + throw outputStream.streamError ?? UploadError.streamWriteFailed + } + totalWritten += written + } + } + } + + // MARK: - Errors + + enum UploadError: Error, LocalizedError { + case noUploader + case streamReadFailed + case streamWriteFailed + + var errorDescription: String? { + switch self { + case .noUploader: "No upload delegate or default uploader configured" + case .streamReadFailed: "Failed to read upload stream" + case .streamWriteFailed: "Failed to write upload to disk" + } + } + } +} + +// MARK: - Upload Context + +/// Thread-safe container for the upload delegate and default uploader, +/// captured by the HTTPServer handler closure. +private struct UploadContext: Sendable { + let uploadDelegate: (any MediaUploadDelegate)? + let defaultUploader: DefaultMediaUploader? +} + +// MARK: - Default Media Uploader + +/// Uploads files to the WordPress REST API using site credentials from EditorConfiguration. +class DefaultMediaUploader: @unchecked Sendable { + private let httpClient: EditorHTTPClientProtocol + private let siteApiRoot: URL + private let siteApiNamespace: String? + + init(httpClient: EditorHTTPClientProtocol, siteApiRoot: URL, siteApiNamespace: [String] = []) { + self.httpClient = httpClient + self.siteApiRoot = siteApiRoot + self.siteApiNamespace = siteApiNamespace.first + } + + func upload(fileURL: URL, mimeType: String, filename: String) async throws -> MediaUploadResult { + let fileData = try Data(contentsOf: fileURL) + let boundary = UUID().uuidString + + 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(fileData) + body.append("\r\n--\(boundary)--\r\n") + + // When a site API namespace is configured (e.g. "sites/12345/"), insert + // it into the media endpoint path so the request reaches the correct site. + let mediaPath = if let siteApiNamespace { + "wp/v2/\(siteApiNamespace)media" + } else { + "wp/v2/media" + } + let uploadURL = siteApiRoot.appending(path: mediaPath) + var request = URLRequest(url: uploadURL) + request.httpMethod = "POST" + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.httpBody = body + + let (data, response) = try await httpClient.perform(request) + + guard (200...299).contains(response.statusCode) else { + let preview = String(data: data.prefix(500), encoding: .utf8) ?? "" + throw MediaUploadError.uploadFailed(statusCode: response.statusCode, preview: preview) + } + + // Parse the WordPress media response into our result type + let wpMedia: WPMediaResponse + do { + wpMedia = try JSONDecoder().decode(WPMediaResponse.self, from: data) + } catch { + let preview = String(data: data.prefix(500), encoding: .utf8) ?? "" + throw MediaUploadError.unexpectedResponse(preview: preview, underlyingError: error) + } + + return MediaUploadResult( + id: wpMedia.id, + url: wpMedia.source_url, + alt: wpMedia.alt_text ?? "", + caption: wpMedia.caption?.rendered ?? "", + title: wpMedia.title.rendered, + mime: wpMedia.mime_type, + type: wpMedia.media_type, + width: wpMedia.media_details?.width, + height: wpMedia.media_details?.height + ) + } +} + +/// WordPress REST API media response (subset of fields). +private struct WPMediaResponse: Decodable { + let id: Int + let source_url: String + let alt_text: String? + let caption: RenderedField? + let title: RenderedField + let mime_type: String + let media_type: String + let media_details: MediaDetails? + + struct RenderedField: Decodable { + let rendered: String + } + + struct MediaDetails: Decodable { + let width: Int? + let height: Int? + } +} + +/// Errors specific to the native media upload pipeline. +enum MediaUploadError: Error, LocalizedError { + /// The WordPress REST API returned a non-success HTTP status code. + case uploadFailed(statusCode: Int, preview: String) + + /// The WordPress REST API returned a non-JSON response (e.g. HTML error page). + case unexpectedResponse(preview: String, underlyingError: Error) + + var errorDescription: String? { + switch self { + case .uploadFailed(let statusCode, let preview): + return "Upload failed (\(statusCode)): \(preview)" + case .unexpectedResponse(let preview, _): + return "WordPress returned an unexpected response: \(preview)" + } + } +} + +// MARK: - Helpers + +private extension Data { + mutating func append(_ string: String) { + append(Data(string.utf8)) + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift b/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift index 131ea1db6..7cf266c90 100644 --- a/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift +++ b/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift @@ -79,9 +79,15 @@ public struct GBKitGlobal: Sendable, Codable { /// Whether to log network requests in the JavaScript console. let enableNetworkLogging: Bool - + + /// Port the local HTTP server is listening on for native media uploads. + let nativeUploadPort: Int? + + /// Per-session auth token for requests to the local upload server. + let nativeUploadToken: String? + let editorSettings: JSON? - + let preloadData: JSON? /// Pre-fetched editor assets (scripts, styles, allowed block types) for plugin loading. @@ -92,9 +98,13 @@ public struct GBKitGlobal: Sendable, Codable { /// - Parameters: /// - configuration: The editor configuration. /// - dependencies: The pre-fetched editor dependencies (unused but reserved for future use). + /// - nativeUploadPort: Port of the local upload server, or nil if not running. + /// - nativeUploadToken: Auth token for the local upload server, or nil if not running. public init( configuration: EditorConfiguration, - dependencies: EditorDependencies + dependencies: EditorDependencies, + nativeUploadPort: Int? = nil, + nativeUploadToken: String? = nil ) throws { self.siteURL = configuration.isOfflineModeEnabled ? nil : configuration.siteURL self.siteApiRoot = configuration.isOfflineModeEnabled ? nil : configuration.siteApiRoot @@ -117,6 +127,8 @@ public struct GBKitGlobal: Sendable, Codable { ) self.logLevel = configuration.logLevel.rawValue self.enableNetworkLogging = configuration.enableNetworkLogging + self.nativeUploadPort = nativeUploadPort + self.nativeUploadToken = nativeUploadToken self.editorSettings = dependencies.editorSettings.jsonValue self.preloadData = try dependencies.preloadList?.build() self.editorAssets = Self.buildEditorAssets(from: dependencies.assetBundle) diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift new file mode 100644 index 000000000..2ebb21bd4 --- /dev/null +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -0,0 +1,261 @@ +import Foundation +import Testing +@testable import GutenbergKit + +/// Check if HTTPServer can bind in this environment (fails in some test sandboxes). +private let _canStartUploadServer: Bool = { + let result = UnsafeMutableSendablePointer(false) + let semaphore = DispatchSemaphore(value: 0) + Task { + do { + let server = try await MediaUploadServer.start() + server.stop() + result.value = true + } catch { + result.value = false + } + semaphore.signal() + } + semaphore.wait() + return result.value +}() + +/// Sendable wrapper for a mutable value, used to communicate results out of a Task. +private final class UnsafeMutableSendablePointer: @unchecked Sendable { + var value: T + init(_ value: T) { self.value = value } +} + +// MARK: - Integration Tests (require network) + +@Suite("MediaUploadServer Integration", .enabled(if: _canStartUploadServer)) +struct MediaUploadServerTests { + + @Test("starts and provides a port and token") + func startAndStop() async throws { + let server = try await MediaUploadServer.start() + #expect(server.port > 0) + #expect(!server.token.isEmpty) + server.stop() + } + + @Test("rejects requests without auth token") + func rejectsUnauthenticated() async throws { + let server = try await MediaUploadServer.start() + defer { server.stop() } + + let url = URL(string: "http://127.0.0.1:\(server.port)/upload")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + + let (_, response) = try await URLSession.shared.data(for: request) + let httpResponse = try #require(response as? HTTPURLResponse) + #expect(httpResponse.statusCode == 407) + } + + @Test("rejects requests with wrong token") + func rejectsWrongToken() async throws { + let server = try await MediaUploadServer.start() + defer { server.stop() } + + let url = URL(string: "http://127.0.0.1:\(server.port)/upload")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer wrong-token", forHTTPHeaderField: "Relay-Authorization") + + let (_, response) = try await URLSession.shared.data(for: request) + let httpResponse = try #require(response as? HTTPURLResponse) + #expect(httpResponse.statusCode == 407) + } + + @Test("responds to OPTIONS preflight with CORS headers") + func corsPreflightResponse() async throws { + let server = try await MediaUploadServer.start() + defer { server.stop() } + + let url = URL(string: "http://127.0.0.1:\(server.port)/upload")! + var request = URLRequest(url: url) + request.httpMethod = "OPTIONS" + + let (_, response) = try await URLSession.shared.data(for: request) + let httpResponse = try #require(response as? HTTPURLResponse) + #expect(httpResponse.statusCode == 204) + #expect(httpResponse.value(forHTTPHeaderField: "Access-Control-Allow-Origin") == "*") + #expect(httpResponse.value(forHTTPHeaderField: "Access-Control-Allow-Methods")?.contains("POST") == true) + } + + @Test("returns 404 for unknown paths") + func unknownPath() async throws { + let server = try await MediaUploadServer.start() + defer { server.stop() } + + let url = URL(string: "http://127.0.0.1:\(server.port)/unknown")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + + let (_, response) = try await URLSession.shared.data(for: request) + let httpResponse = try #require(response as? HTTPURLResponse) + #expect(httpResponse.statusCode == 404) + } + + @Test("calls delegate and returns upload result") + func delegateProcessAndUpload() async throws { + let delegate = MockUploadDelegate() + let server = try await MediaUploadServer.start(uploadDelegate: delegate) + defer { server.stop() } + + let boundary = UUID().uuidString + let fileData = "fake image data".data(using: .utf8)! + let body = buildMultipartBody(boundary: boundary, filename: "photo.jpg", mimeType: "image/jpeg", data: fileData) + + let url = URL(string: "http://127.0.0.1:\(server.port)/upload")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.httpBody = body + + let (data, response) = try await URLSession.shared.data(for: request) + let httpResponse = try #require(response as? HTTPURLResponse) + #expect(httpResponse.statusCode == 200) + + #expect(delegate.processFileCalled) + #expect(delegate.uploadFileCalled) + #expect(delegate.lastMimeType == "image/jpeg") + #expect(delegate.lastFilename == "photo.jpg") + + let result = try JSONDecoder().decode(MediaUploadResult.self, from: data) + #expect(result.id == 42) + #expect(result.url == "https://example.com/photo.jpg") + #expect(result.type == "image") + } + + @Test("falls back to default uploader when delegate returns nil") + func delegateFallbackToDefault() async throws { + let delegate = ProcessOnlyDelegate() + let mockUploader = MockDefaultUploader() + let server = try await MediaUploadServer.start(uploadDelegate: delegate, defaultUploader: mockUploader) + defer { server.stop() } + + let boundary = UUID().uuidString + let fileData = "fake data".data(using: .utf8)! + let body = buildMultipartBody(boundary: boundary, filename: "doc.pdf", mimeType: "application/pdf", data: fileData) + + let url = URL(string: "http://127.0.0.1:\(server.port)/upload")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.httpBody = body + + let (data, response) = try await URLSession.shared.data(for: request) + let httpResponse = try #require(response as? HTTPURLResponse) + #expect(httpResponse.statusCode == 200) + + #expect(delegate.processFileCalled) + #expect(mockUploader.uploadCalled) + + let result = try JSONDecoder().decode(MediaUploadResult.self, from: data) + #expect(result.id == 99) + } + + 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: - Mocks + +private final class MockUploadDelegate: MediaUploadDelegate, @unchecked Sendable { + private let lock = NSLock() + private var _processFileCalled = false + private var _uploadFileCalled = false + private var _lastMimeType: String? + private var _lastFilename: String? + + var processFileCalled: Bool { lock.withLock { _processFileCalled } } + var uploadFileCalled: Bool { lock.withLock { _uploadFileCalled } } + var lastMimeType: String? { lock.withLock { _lastMimeType } } + var lastFilename: String? { lock.withLock { _lastFilename } } + + func processFile(at url: URL, mimeType: String) async throws -> URL { + lock.withLock { + _processFileCalled = true + _lastMimeType = mimeType + } + return url + } + + func uploadFile(at url: URL, mimeType: String, filename: String) async throws -> MediaUploadResult? { + lock.withLock { + _uploadFileCalled = true + _lastFilename = filename + } + return MediaUploadResult( + id: 42, + url: "https://example.com/photo.jpg", + title: "photo", + mime: "image/jpeg", + type: "image" + ) + } +} + +private final class ProcessOnlyDelegate: MediaUploadDelegate, @unchecked Sendable { + private let lock = NSLock() + private var _processFileCalled = false + + var processFileCalled: Bool { lock.withLock { _processFileCalled } } + + func processFile(at url: URL, mimeType: String) async throws -> URL { + lock.withLock { _processFileCalled = true } + return url + } +} + +private final class MockDefaultUploader: DefaultMediaUploader, @unchecked Sendable { + private let lock = NSLock() + private var _uploadCalled = false + + var uploadCalled: Bool { lock.withLock { _uploadCalled } } + + init() { + super.init(httpClient: MockHTTPClient(), siteApiRoot: URL(string: "https://example.com/wp-json/")!) + } + + override func upload(fileURL: URL, mimeType: String, filename: String) async throws -> MediaUploadResult { + lock.withLock { _uploadCalled = true } + return MediaUploadResult( + id: 99, + url: "https://example.com/doc.pdf", + title: "doc", + mime: "application/pdf", + type: "file" + ) + } +} + +private struct MockHTTPClient: EditorHTTPClientProtocol { + func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) { + let response = HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(), response) + } + + func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse) { + let response = HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (FileManager.default.temporaryDirectory, response) + } +} + +private extension Data { + mutating func append(_ string: String) { + append(string.data(using: .utf8)!) + } +} diff --git a/src/utils/api-fetch-upload-middleware.test.js b/src/utils/api-fetch-upload-middleware.test.js new file mode 100644 index 000000000..952a5baa8 --- /dev/null +++ b/src/utils/api-fetch-upload-middleware.test.js @@ -0,0 +1,352 @@ +/** + * External dependencies + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +/** + * Internal dependencies + */ +import { nativeMediaUploadMiddleware } from './api-fetch'; + +// Mock dependencies +vi.mock( './bridge', () => ( { + getGBKit: vi.fn( () => ( {} ) ), +} ) ); + +vi.mock( './logger', () => ( { + info: vi.fn(), + error: vi.fn(), +} ) ); + +import { getGBKit } from './bridge'; + +function makeNext() { + return vi.fn( () => Promise.resolve( { passthrough: true } ) ); +} + +function makePostMediaOptions( file ) { + const body = new FormData(); + if ( file ) { + body.append( 'file', file, file.name ); + } + return { + method: 'POST', + path: '/wp/v2/media', + body, + }; +} + +function makeFile( name = 'photo.jpg', type = 'image/jpeg' ) { + return new File( [ 'fake data' ], name, { type } ); +} + +describe( 'nativeMediaUploadMiddleware', () => { + beforeEach( () => { + vi.restoreAllMocks(); + global.fetch = vi.fn(); + } ); + + // MARK: - Passthrough cases + + it( 'passes through when nativeUploadPort is not configured', () => { + getGBKit.mockReturnValue( {} ); + const next = makeNext(); + + nativeMediaUploadMiddleware( makePostMediaOptions( makeFile() ), next ); + + expect( next ).toHaveBeenCalled(); + expect( global.fetch ).not.toHaveBeenCalled(); + } ); + + it( 'passes through for non-POST requests', () => { + getGBKit.mockReturnValue( { + nativeUploadPort: 8080, + nativeUploadToken: 'token', + } ); + const next = makeNext(); + + nativeMediaUploadMiddleware( + { method: 'GET', path: '/wp/v2/media', body: new FormData() }, + next + ); + + expect( next ).toHaveBeenCalled(); + expect( global.fetch ).not.toHaveBeenCalled(); + } ); + + it( 'passes through for non-media paths', () => { + getGBKit.mockReturnValue( { + nativeUploadPort: 8080, + nativeUploadToken: 'token', + } ); + const next = makeNext(); + + nativeMediaUploadMiddleware( + { method: 'POST', path: '/wp/v2/posts', body: new FormData() }, + next + ); + + expect( next ).toHaveBeenCalled(); + expect( global.fetch ).not.toHaveBeenCalled(); + } ); + + it( 'passes through for media sub-paths like /wp/v2/media/123', () => { + getGBKit.mockReturnValue( { + nativeUploadPort: 8080, + nativeUploadToken: 'token', + } ); + const next = makeNext(); + const body = new FormData(); + body.append( 'file', makeFile(), 'photo.jpg' ); + + nativeMediaUploadMiddleware( + { method: 'POST', path: '/wp/v2/media/123', body }, + next + ); + + expect( next ).toHaveBeenCalled(); + expect( global.fetch ).not.toHaveBeenCalled(); + } ); + + it( 'passes through for similarly-prefixed paths like /wp/v2/media-categories', () => { + getGBKit.mockReturnValue( { + nativeUploadPort: 8080, + nativeUploadToken: 'token', + } ); + const next = makeNext(); + const body = new FormData(); + body.append( 'file', makeFile(), 'photo.jpg' ); + + nativeMediaUploadMiddleware( + { method: 'POST', path: '/wp/v2/media-categories', body }, + next + ); + + expect( next ).toHaveBeenCalled(); + expect( global.fetch ).not.toHaveBeenCalled(); + } ); + + it( 'passes through when body is not FormData', () => { + getGBKit.mockReturnValue( { + nativeUploadPort: 8080, + nativeUploadToken: 'token', + } ); + const next = makeNext(); + + nativeMediaUploadMiddleware( + { method: 'POST', path: '/wp/v2/media', body: '{}' }, + next + ); + + expect( next ).toHaveBeenCalled(); + expect( global.fetch ).not.toHaveBeenCalled(); + } ); + + it( 'passes through when FormData has no file field', () => { + getGBKit.mockReturnValue( { + nativeUploadPort: 8080, + nativeUploadToken: 'token', + } ); + const next = makeNext(); + const body = new FormData(); + body.append( 'title', 'no file here' ); + + nativeMediaUploadMiddleware( + { method: 'POST', path: '/wp/v2/media', body }, + next + ); + + expect( next ).toHaveBeenCalled(); + expect( global.fetch ).not.toHaveBeenCalled(); + } ); + + // MARK: - Interception + + it( 'intercepts POST /wp/v2/media with file and fetches to local server', async () => { + getGBKit.mockReturnValue( { + nativeUploadPort: 12345, + nativeUploadToken: 'test-token', + } ); + const next = makeNext(); + + global.fetch = vi.fn( () => + Promise.resolve( { + ok: true, + json: () => + Promise.resolve( { + id: 42, + url: 'https://example.com/photo.jpg', + alt: '', + caption: '', + title: 'photo', + mime: 'image/jpeg', + type: 'image', + } ), + } ) + ); + + await nativeMediaUploadMiddleware( + makePostMediaOptions( makeFile() ), + next + ); + + expect( next ).not.toHaveBeenCalled(); + expect( global.fetch ).toHaveBeenCalledOnce(); + + const [ url, options ] = global.fetch.mock.calls[ 0 ]; + expect( url ).toBe( 'http://localhost:12345/upload' ); + expect( options.method ).toBe( 'POST' ); + expect( options.headers[ 'Relay-Authorization' ] ).toBe( + 'Bearer test-token' + ); + expect( options.body ).toBeInstanceOf( FormData ); + } ); + + it( 'transforms native response to WordPress REST API shape', async () => { + getGBKit.mockReturnValue( { + nativeUploadPort: 8080, + nativeUploadToken: 'token', + } ); + + global.fetch = vi.fn( () => + Promise.resolve( { + ok: true, + json: () => + Promise.resolve( { + id: 77, + url: 'https://example.com/image.jpg', + alt: 'alt text', + caption: 'a caption', + title: 'image', + mime: 'image/jpeg', + type: 'image', + } ), + } ) + ); + + const result = await nativeMediaUploadMiddleware( + makePostMediaOptions( makeFile() ), + makeNext() + ); + + expect( result ).toEqual( { + id: 77, + source_url: 'https://example.com/image.jpg', + alt_text: 'alt text', + caption: { raw: 'a caption', rendered: 'a caption' }, + title: { raw: 'image', rendered: 'image' }, + mime_type: 'image/jpeg', + media_type: 'image', + media_details: { width: 0, height: 0 }, + link: 'https://example.com/image.jpg', + } ); + } ); + + it( 'handles missing optional fields in native response', async () => { + getGBKit.mockReturnValue( { + nativeUploadPort: 8080, + nativeUploadToken: 'token', + } ); + + global.fetch = vi.fn( () => + Promise.resolve( { + ok: true, + json: () => + Promise.resolve( { + id: 1, + url: 'https://example.com/file.pdf', + title: 'file', + mime: 'application/pdf', + type: 'application', + } ), + } ) + ); + + const result = await nativeMediaUploadMiddleware( + makePostMediaOptions( makeFile( 'file.pdf', 'application/pdf' ) ), + makeNext() + ); + + expect( result.alt_text ).toBe( '' ); + expect( result.caption ).toEqual( { raw: '', rendered: '' } ); + } ); + + // MARK: - Error handling + + it( 'throws on non-ok response from local server', async () => { + getGBKit.mockReturnValue( { + nativeUploadPort: 8080, + nativeUploadToken: 'token', + } ); + + global.fetch = vi.fn( () => + Promise.resolve( { + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: () => Promise.resolve( 'Server crashed' ), + } ) + ); + + await expect( + nativeMediaUploadMiddleware( + makePostMediaOptions( makeFile() ), + makeNext() + ) + ).rejects.toMatchObject( { + code: 'upload_failed', + message: expect.stringContaining( '500' ), + } ); + } ); + + it( 'throws on fetch network error', async () => { + getGBKit.mockReturnValue( { + nativeUploadPort: 8080, + nativeUploadToken: 'token', + } ); + + global.fetch = vi.fn( () => + Promise.reject( new Error( 'Failed to fetch' ) ) + ); + + await expect( + nativeMediaUploadMiddleware( + makePostMediaOptions( makeFile() ), + makeNext() + ) + ).rejects.toBeDefined(); + } ); + + // MARK: - Signal forwarding + + it( 'forwards abort signal to fetch', async () => { + getGBKit.mockReturnValue( { + nativeUploadPort: 8080, + nativeUploadToken: 'token', + } ); + + global.fetch = vi.fn( () => + Promise.resolve( { + ok: true, + json: () => + Promise.resolve( { + id: 1, + url: '', + title: '', + mime: '', + type: '', + } ), + } ) + ); + + const controller = new AbortController(); + const options = makePostMediaOptions( makeFile() ); + options.signal = controller.signal; + + await nativeMediaUploadMiddleware( options, makeNext() ); + + expect( global.fetch.mock.calls[ 0 ][ 1 ].signal ).toBe( + controller.signal + ); + } ); +} ); diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js index 76f891825..2c55447cd 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 } 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( @@ -131,6 +136,112 @@ function filterEndpointsMiddleware( options, next ) { return next( options ); } +/** + * Middleware that routes media uploads through the native host's local HTTP + * server for processing (e.g. image resizing) before uploading to WordPress. + * + * Exported for testing only. + * + * When `nativeUploadPort` is configured in GBKit, this middleware intercepts + * `POST /wp/v2/media` requests, forwards the file to the native server, and + * returns the response in WordPress REST API attachment format so the existing + * Gutenberg upload pipeline (blob previews, save locking, entity caching) + * works unchanged. + * + * When the native server is not configured, requests pass through unmodified. + * + * Note: Ideally, media uploads would be handled via the `mediaUpload` editor + * setting (see the Gutenberg Framework guides), but GutenbergKit uses + * Gutenberg's `EditorProvider` which overwrites that setting internally: + * https://github.com/WordPress/gutenberg/blob/29914e1d09a344edce58d938fa4992e1ec248e41/packages/editor/src/components/provider/use-block-editor-settings.js#L340 + * + * Until GutenbergKit is refactored to use `BlockEditorProvider` and aligns + * with the Gutenberg Framework guides (https://wordpress.org/gutenberg-framework/docs/intro/), + * this api-fetch middleware approach is necessary. For context, see: + * - https://github.com/wordpress-mobile/GutenbergKit/pull/24 + * - https://github.com/wordpress-mobile/GutenbergKit/pull/50 + * - https://github.com/wordpress-mobile/GutenbergKit/pull/108 + * + * @type {APIFetchMiddleware} + */ +export function nativeMediaUploadMiddleware( options, next ) { + const { nativeUploadPort, nativeUploadToken } = getGBKit(); + + if ( + ! nativeUploadPort || + ! options.method || + options.method.toUpperCase() !== 'POST' || + ! options.path || + ! MEDIA_UPLOAD_PATH.test( options.path ) || + ! ( options.body instanceof FormData ) + ) { + return next( options ); + } + + const file = options.body.get( 'file' ); + if ( ! file ) { + return next( options ); + } + + info( + `Routing upload of ${ file.name } through native server on port ${ nativeUploadPort }` + ); + + const formData = new FormData(); + formData.append( 'file', file, file.name ); + + return fetch( `http://localhost:${ nativeUploadPort }/upload`, { + method: 'POST', + headers: { + 'Relay-Authorization': `Bearer ${ nativeUploadToken }`, + }, + body: formData, + signal: options.signal, + } ) + .then( ( response ) => { + if ( ! response.ok ) { + return response.text().then( ( body ) => { + const error = new Error( + `Native upload failed (${ response.status }): ${ + body || response.statusText + }` + ); + error.code = 'upload_failed'; + throw error; + } ); + } + return response.json(); + } ) + .then( ( result ) => { + // Transform native server response into WordPress REST API + // attachment shape expected by @wordpress/media-utils. + return { + id: result.id, + source_url: result.url, + alt_text: result.alt || '', + caption: { + raw: result.caption || '', + rendered: result.caption || '', + }, + title: { + raw: result.title || '', + rendered: result.title || '', + }, + mime_type: result.mime, + media_type: result.type, + media_details: { + width: result.width || 0, + height: result.height || 0, + }, + link: result.url, + }; + } ) + .catch( ( err ) => { + logError( 'Native upload failed', err ); + throw err; + } ); +} + /** * Middleware to modify media upload requests. * @@ -142,7 +253,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 f389b5e37..04d567818 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -213,6 +213,8 @@ export function onNetworkRequest( requestData ) { * @property {string} [hideTitle] Whether to hide the title. * @property {Post} [post] The post data. * @property {boolean} [enableNetworkLogging] Enables logging of all network requests/responses to the native host via onNetworkRequest bridge method. + * @property {number} [nativeUploadPort] Port the local HTTP server is listening on. If absent, the native upload override is not activated. + * @property {string} [nativeUploadToken] Per-session auth token for requests to the local upload server. */ /**