diff --git a/android/Gutenberg/detekt-baseline.xml b/android/Gutenberg/detekt-baseline.xml
index 4f6c96915..ce3b4481a 100644
--- a/android/Gutenberg/detekt-baseline.xml
+++ b/android/Gutenberg/detekt-baseline.xml
@@ -11,6 +11,7 @@
ExplicitItLambdaParameter:EditorAssetsLibrary.kt$EditorAssetsLibrary${ str, it -> str + "%02x".format(it) }
FunctionNaming:EditorURLCache.kt$EditorURLCache$private fun __store( response: EditorURLResponse, url: String, httpMethod: EditorHttpMethod, currentDate: Date )
LargeClass:GutenbergView.kt$GutenbergView : FrameLayout
+ LargeClass:MediaUploadServerTest.kt$MediaUploadServerTest
LongMethod:FixtureTests.kt$FixtureTests$@Test fun `request parsing - all basic cases pass`()
LongMethod:FixtureTests.kt$FixtureTests$@Test fun `request parsing - all incremental cases pass`()
LongMethod:HTTPRequestParser.kt$HTTPRequestParser$fun append(data: ByteArray): Unit
diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt
index accb34f03..fee46d45b 100644
--- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt
+++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt
@@ -123,14 +123,32 @@ class GutenbergView : FrameLayout {
*/
var mediaUploadDelegate: MediaUploadDelegate? = null
set(value) {
- check(!hasStartedLoading) {
- "mediaUploadDelegate must be set before the editor loads (e.g. right " +
- "after construction). It is captured when the page begins loading; " +
- "setting it afterward has no effect."
- }
+ check(!hasStartedLoading) { lateMediaAssignmentMessage("mediaUploadDelegate") }
+ field = value
+ }
+
+ /**
+ * Takes over media upload on the host's own stack (background service, offline
+ * queue, resumable transport). Setting it makes the host own every upload and its
+ * whole lifecycle; GutenbergKit stays out of the network entirely for media.
+ *
+ * Same lifecycle rules as [mediaUploadDelegate]: set it before the editor loads,
+ * and this view owns it for its lifetime — so you needn't retain it yourself, just
+ * don't strongly retain this [GutenbergView] from your uploader.
+ *
+ * Takes precedence over the deprecated [MediaUploadDelegate.uploadFile]: with an
+ * uploader set, that hook is never called.
+ */
+ var mediaUploader: MediaUploader? = null
+ set(value) {
+ check(!hasStartedLoading) { lateMediaAssignmentMessage("mediaUploader") }
field = value
}
+ private fun lateMediaAssignmentMessage(name: String) =
+ "$name must be set before the editor loads (e.g. right after construction). " +
+ "It is captured when the page begins loading; setting it afterward has no effect."
+
@Volatile private var uploadServer: MediaUploadServer? = null
/**
@@ -676,10 +694,10 @@ class GutenbergView : FrameLayout {
}
private fun startUploadServer() {
- // No delegate means nothing wants to customize uploads, so there's no reason
- // to route them through the native server — leave it down and let uploads
- // fall to the default WebView path. (Matches iOS.)
- if (mediaUploadDelegate == null) return
+ // Nothing to route through the native server unless the host provided a
+ // delegate or an uploader — leave it down and let uploads fall to the default
+ // WebView path. (Matches iOS.)
+ if (mediaUploadDelegate == null && mediaUploader == null) return
// The native upload server relays through InternalMediaClient, which needs a
// site root and an auth header (every host provides one — the editor injects
@@ -715,6 +733,7 @@ class GutenbergView : FrameLayout {
uploadServer = MediaUploadServer(
uploadDelegate = mediaUploadDelegate,
internalClient = internalClient,
+ uploader = mediaUploader,
cacheDir = context.cacheDir,
scope = coroutineScope
)
diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt
index defbe3b38..27c58f2f4 100644
--- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt
+++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt
@@ -6,6 +6,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
@@ -106,13 +108,102 @@ interface MediaUploadDelegate {
* Upload a processed file to the remote WordPress site.
*
* Return the raw WordPress response (status code + body), which GutenbergKit
- * relays to the editor unchanged, or null to use the default uploader. A host
- * that uploads to WordPress should return the exact response it received so
+ * relays to the editor unchanged, or null to use the internal media client. A
+ * host that uploads to WordPress should return the exact response it received so
* the editor sees a complete attachment object.
+ *
+ * Returning a raw response splits one upload's HTTP across two owners: you
+ * perform the POST, but the editor drives the `post-process` retries and orphan
+ * cleanup behind it, through the WebView rather than your stack. It also receives
+ * no form fields, so an attachment uploaded this way lands unattached to its post.
+ * Implement [MediaUploader] instead — it owns the upload end-to-end and receives a
+ * [MediaUpload] carrying the fields.
*/
+ // No ReplaceWith: it takes a replacement *expression* the IDE substitutes for the
+ // call, and there is none that means "implement a different interface" — the
+ // quick-fix would drop the arguments and leave a type name where a
+ // MediaUploadResponse? was expected. The message carries the guidance instead.
+ @Deprecated(
+ "Implement MediaUploader instead — it owns the upload's retries and receives the editor's form fields."
+ )
suspend fun uploadFile(file: File, mimeType: String, filename: String): MediaUploadResponse? = null
}
+/**
+ * One of the editor's non-file form fields, as sent with a media upload.
+ *
+ * A named type rather than a pair so the field's meaning is legible at every call
+ * site, and so the type can gain members without a source break for every host.
+ *
+ * @property name The field name, e.g. `post`. Not unique — a `field[]` array repeats it.
+ * @property value The field's value, decoded as UTF-8.
+ */
+data class MediaUploadField(val name: String, val value: String)
+
+/**
+ * Everything a [MediaUploader] needs to reproduce a native upload: the file to send,
+ * its metadata, the editor's non-file form fields, and the request's query.
+ *
+ * @property file The file to upload — already processed, if a [MediaUploadDelegate] ran.
+ * @property mimeType The file's MIME type.
+ * @property filename The file's name.
+ * @property fields The editor's non-file form fields, in order, each decoded as UTF-8 —
+ * most importantly `post`, the parent post's ID, without which the attachment is
+ * created unattached. A list, not a map, so repeated field names (e.g. a `field[]`
+ * array) survive verbatim. Send each as a form part on your `POST /wp/v2/media`, in
+ * the given order.
+ * @property query The request's query string (leading `?`, e.g. `?_embed=wp:featuredmedia`),
+ * or empty. Carry it on your request so the editor gets the response it expects.
+ */
+data class MediaUpload(
+ val file: File,
+ val mimeType: String,
+ val filename: String,
+ val fields: List,
+ val query: String
+)
+
+/**
+ * Takes over *performing* a media upload — on the host's own stack: its own
+ * networking (say, to log every request), a background service, an offline queue, a
+ * resumable transport, its own retry policy.
+ *
+ * This is a choice of *who executes the requests*, not where they go: an uploader and
+ * GutenbergKit's internal media client both target the same configured site. Setting
+ * [GutenbergView.mediaUploader] makes the host own that upload end-to-end — the
+ * request, its own retries, and its recovery and cleanup — with GutenbergKit out of
+ * the network entirely. Because the host does the retries itself, there's no raw
+ * response left for the editor to retry behind it.
+ */
+interface MediaUploader {
+ /**
+ * Upload a (possibly processed) file and return the finished WordPress attachment
+ * JSON the editor inserts — the same object a direct `POST /wp/v2/media` returns.
+ * Return only once the upload is genuinely done, or throw on terminal failure: a
+ * returned value is taken as a completed attachment, and there is no GutenbergKit
+ * recovery behind you.
+ *
+ * The [MediaUpload] carries the file plus the editor's form fields (e.g. `post`)
+ * and query — send them all so the created attachment matches a native upload
+ * rather than landing as an unattached orphan.
+ *
+ * That recovery is yours to run. When `POST /wp/v2/media` fatals in server-side
+ * post-processing it returns a 5xx carrying the attachment's ID in
+ * `x-wp-upload-attachment-id` — the attachment exists but is unfinished. Don't
+ * re-upload; drive `POST /wp/v2/media//post-process` to completion, the way
+ * core recovers its own uploads (up to 5 attempts), then return the finished
+ * attachment. That request needs a body of `{"action": "create-image-subsizes"}` —
+ * core registers `action` as **required**, so a post-process request without it
+ * fails with a 400 every time rather than recovering.
+ *
+ * Owning the upload means owning cleanup on the server too: if post-process can't
+ * be recovered, force-delete the orphan (`DELETE /wp/v2/media/?force=true`)
+ * before you throw, or it stays on the site — neither GutenbergKit nor the editor
+ * cleans up behind you.
+ */
+ suspend fun upload(upload: MediaUpload): ByteArray
+}
+
/**
* A local HTTP server that receives file uploads from the WebView and routes
* them through the native media processing pipeline.
@@ -128,6 +219,7 @@ interface MediaUploadDelegate {
internal class MediaUploadServer(
private val uploadDelegate: MediaUploadDelegate?,
private val internalClient: InternalMediaClient?,
+ private val uploader: MediaUploader? = null,
cacheDir: File? = null,
scope: CoroutineScope? = null,
ioDispatcher: CoroutineDispatcher = Dispatchers.IO
@@ -264,9 +356,9 @@ internal class MediaUploadServer(
* browser blocks it at preflight. Relaying it here lets the cleanup run.
*/
private suspend fun handleDelete(attachmentId: String, query: String): HttpResponse {
- val uploader = internalClient ?: return errorResponse(500, "No internal media client configured")
+ val client = internalClient ?: return errorResponse(500, "No internal media client configured")
return try {
- relayResponse(uploader.deleteMedia(attachmentId, query))
+ relayResponse(client.deleteMedia(attachmentId, query))
} catch (e: IOException) {
Log.e(TAG, "Media deletion failed", e)
errorResponse(500, e.message ?: "Deletion failed")
@@ -290,14 +382,21 @@ internal class MediaUploadServer(
// like this. If not, forward the original upload to WordPress directly,
// skipping a full temp-file copy of a file the delegate won't process or
// upload (e.g. a video handed to an image-only delegate).
- if (uploadDelegate?.handlesFile(mimeType, filename) != true) {
+ // An uploader takes over delivery for *every* file, so with one set there is no
+ // passthrough to fall to and the gate can't decline the upload outright. It
+ // still decides whether processFile runs, though — a declined file is handed to
+ // the uploader unprocessed rather than to a delegate that said it won't touch it
+ // — so the answer is carried into processAndUpload rather than short-circuited
+ // away here. Asked exactly once per upload, matching iOS.
+ val delegateWantsFile = uploadDelegate?.handlesFile(mimeType, filename) == true
+ if (uploader == null && !delegateWantsFile) {
return passthroughResponse(request, query)
}
val tempFile = writePartToTempFile(filePart)
?: return errorResponse(500, "Failed to save file")
- return processAndRespond(request, tempFile, filePart, extraParts, query)
+ return processAndRespond(request, tempFile, filePart, extraParts, query, delegateWantsFile)
}
@Suppress("TooGenericExceptionCaught")
@@ -381,11 +480,12 @@ internal class MediaUploadServer(
@Suppress("TooGenericExceptionCaught")
private suspend fun processAndRespond(
request: HttpRequest, tempFile: File, filePart: MultipartPart,
- extraParts: List, query: String
+ extraParts: List, query: String, delegateWantsFile: Boolean
): HttpResponse {
try {
val uploadResult = processAndUpload(
- tempFile, filePart.contentType, filePart.filename ?: "upload", extraParts, query
+ tempFile, filePart.contentType, filePart.filename ?: "upload",
+ extraParts, query, delegateWantsFile
)
val response = when (uploadResult) {
is UploadResult.Uploaded -> {
@@ -430,18 +530,29 @@ internal class MediaUploadServer(
private suspend fun performPassthroughUpload(request: HttpRequest, query: String): MediaUploadResponse {
val body = request.body
val contentType = request.header("Content-Type")
- val uploader = internalClient
- if (body == null || contentType == null || uploader == null) {
- throw MediaUploadException("Passthrough upload requires a request body, Content-Type, and internal media client")
+ val client = internalClient
+ if (body == null || contentType == null || client == null) {
+ throw MediaUploadException(
+ "Passthrough upload requires a request body, Content-Type, and internal media client"
+ )
}
- return uploader.passthroughUpload(body, contentType, query)
+ return client.passthroughUpload(body, contentType, query)
}
private suspend fun processAndUpload(
file: File, mimeType: String, filename: String,
- extraParts: List, query: String
+ extraParts: List, query: String, delegateWantsFile: Boolean
): UploadResult {
- val processed = uploadDelegate?.processFile(file, mimeType, filename) ?: ProcessedProxyFile.Original
+ // Process (resize, transcode, etc.) — but only for a file the delegate's
+ // metadata gate accepted. handlesFile returning false is the delegate saying it
+ // won't touch a file like this, so handing it one anyway would break the
+ // contract the gate documents. With an uploader set the file still gets
+ // delivered; it just skips processing on its way there.
+ val processed = if (delegateWantsFile) {
+ uploadDelegate?.processFile(file, mimeType, filename) ?: ProcessedProxyFile.Original
+ } else {
+ ProcessedProxyFile.Original
+ }
// Resolve the file to upload and its metadata. Processed uses the
// delegate's values verbatim, so a format change is reported to WordPress.
@@ -462,7 +573,30 @@ internal class MediaUploadServer(
}
try {
- // If the delegate provided its own upload, use that.
+ // The editor was torn down (or the client disconnected) while we processed.
+ // Don't put an upload on the wire whose response nobody will read — it would
+ // create an attachment neither GutenbergKit nor the host knows to clean up.
+ // Checking here rather than relying on the delivery path to notice keeps this
+ // true for a host uploader that isn't cancellation-cooperative. (Matches iOS.)
+ currentCoroutineContext().ensureActive()
+
+ // An uploader owns delivery on the host's own stack and returns the finished
+ // attachment JSON (or throws); GutenbergKit relays that as a success and
+ // never runs its own recovery behind it.
+ uploader?.let { hostUploader ->
+ val upload = MediaUpload(
+ file = targetFile,
+ mimeType = targetMimeType,
+ filename = targetFilename,
+ fields = formFields(extraParts),
+ query = query
+ )
+ return UploadResult.Uploaded(MediaUploadResponse(201, hostUploader.upload(upload)))
+ }
+
+ // The deprecated delegate path: the host performs the POST but returns the
+ // raw response, leaving the editor to drive post-process recovery behind it.
+ @Suppress("DEPRECATION")
uploadDelegate?.uploadFile(targetFile, targetMimeType, targetFilename)?.let {
return UploadResult.Uploaded(it)
}
@@ -485,6 +619,15 @@ internal class MediaUploadServer(
}
}
+ /**
+ * The editor's non-file form parts as ordered, UTF-8-decoded fields.
+ *
+ * A list rather than a map so repeated names (e.g. a `field[]` array) survive
+ * verbatim, in the order the editor sent them.
+ */
+ private fun formFields(parts: List): List =
+ parts.map { MediaUploadField(it.name, String(it.body.readBytes(), Charsets.UTF_8)) }
+
// MARK: - Response Building
private fun errorResponse(status: Int, message: String): HttpResponse {
diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt
index a83cfe5f5..5db7e718a 100644
--- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt
+++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt
@@ -94,6 +94,39 @@ class GutenbergViewUploadServerTest {
}
}
+ @Test
+ fun `the upload server starts for an uploader with no delegate`() {
+ val view = makeView()
+ try {
+ // An uploader alone must bring the server up: it is the only route the
+ // editor has to the host's upload stack. Without this, `startUploadServer`
+ // could drop the `mediaUploader` clause from its gate and stay green.
+ view.mediaUploader = mock(MediaUploader::class.java)
+ startLoading(view)
+ idle()
+ assertNotNull(
+ "an uploader provided before load should bring up the upload server",
+ uploadServerOf(view)
+ )
+ } finally {
+ detach(view) // stops the server, releasing the bound socket
+ }
+ }
+
+ @Test
+ fun `setting the uploader after the page has started loading throws`() {
+ val view = makeView()
+ try {
+ startLoading(view)
+ idle()
+ assertThrows(IllegalStateException::class.java) {
+ view.mediaUploader = mock(MediaUploader::class.java)
+ }
+ } finally {
+ detach(view)
+ }
+ }
+
@Test
fun `setting the delegate after the page has started loading throws`() {
val view = makeView()
diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt
index ba8690037..3e43d0902 100644
--- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt
+++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt
@@ -168,6 +168,146 @@ class MediaUploadServerTest {
assertEquals(listOf("text/plain"), response.rawHeaderValues("content-type"))
}
+ @Test
+ fun `an uploader performs the upload and its result is relayed`() {
+ val uploader = RecordingUploader()
+ val client = MockInternalMediaClient()
+ server.stop()
+ server = MediaUploadServer(
+ uploadDelegate = null, internalClient = client, uploader = uploader, cacheDir = tempFolder.root
+ )
+
+ val boundary = "test-boundary-uploader"
+ val body = buildMultipartBody(boundary, "photo.jpg", "image/jpeg", "fake image data".toByteArray())
+ val response = sendRawRequest(
+ method = "POST",
+ path = "/upload",
+ headers = mapOf(
+ "Relay-Authorization" to "Bearer ${server.token}",
+ "Content-Type" to "multipart/form-data; boundary=$boundary"
+ ),
+ body = body
+ )
+
+ assertTrue("Expected 201 but got: ${response.statusLine}", response.statusLine.contains("201"))
+ assertTrue(response.body.contains("\"id\":7"))
+ // GutenbergKit stays out of the network when a host uploader is set.
+ assertFalse(client.uploadCalled)
+ assertFalse(client.passthroughUploadCalled)
+ assertEquals("photo.jpg", uploader.received?.filename)
+ assertEquals("image/jpeg", uploader.received?.mimeType)
+ }
+
+ @Test
+ fun `an uploader receives the editor's form fields in order, and the query`() {
+ // Without `post` the attachment is created unattached, and repeated names (a
+ // `field[]` array) must survive as repeats rather than collapse into a map.
+ val uploader = RecordingUploader()
+ server.stop()
+ server = MediaUploadServer(
+ uploadDelegate = null, internalClient = MockInternalMediaClient(), uploader = uploader,
+ cacheDir = tempFolder.root
+ )
+
+ val boundary = "test-boundary-fields"
+ val body = java.io.ByteArrayOutputStream().apply {
+ for ((name, value) in listOf("post" to "42", "tags[]" to "a", "tags[]" to "b")) {
+ write("--$boundary\r\n".toByteArray())
+ write("Content-Disposition: form-data; name=\"$name\"\r\n\r\n".toByteArray())
+ write("$value\r\n".toByteArray())
+ }
+ write("--$boundary\r\n".toByteArray())
+ write("Content-Disposition: form-data; name=\"file\"; filename=\"photo.jpg\"\r\n".toByteArray())
+ write("Content-Type: image/jpeg\r\n\r\n".toByteArray())
+ write("fake image data".toByteArray())
+ write("\r\n--$boundary--\r\n".toByteArray())
+ }.toByteArray()
+
+ sendRawRequest(
+ method = "POST",
+ path = "/upload?_embed=wp:featuredmedia",
+ headers = mapOf(
+ "Relay-Authorization" to "Bearer ${server.token}",
+ "Content-Type" to "multipart/form-data; boundary=$boundary"
+ ),
+ body = body
+ )
+
+ assertEquals(
+ listOf(
+ MediaUploadField("post", "42"),
+ MediaUploadField("tags[]", "a"),
+ MediaUploadField("tags[]", "b")
+ ),
+ uploader.received?.fields
+ )
+ assertEquals("?_embed=wp:featuredmedia", uploader.received?.query)
+ }
+
+ @Test
+ fun `an uploader takes precedence over the deprecated uploadFile hook`() {
+ // Both set: the uploader owns delivery and the deprecated hook must not run.
+ // The delegate still processes — only delivery moves to the uploader.
+ val uploader = RecordingUploader()
+ val delegate = MockUploadDelegate()
+ val client = MockInternalMediaClient()
+ server.stop()
+ server = MediaUploadServer(
+ uploadDelegate = delegate, internalClient = client, uploader = uploader,
+ cacheDir = tempFolder.root
+ )
+
+ val boundary = "test-boundary-precedence"
+ val body = buildMultipartBody(boundary, "photo.jpg", "image/jpeg", "data".toByteArray())
+ sendRawRequest(
+ method = "POST",
+ path = "/upload",
+ headers = mapOf(
+ "Relay-Authorization" to "Bearer ${server.token}",
+ "Content-Type" to "multipart/form-data; boundary=$boundary"
+ ),
+ body = body
+ )
+
+ assertNotNull(uploader.received)
+ assertFalse(delegate.uploadFileCalled)
+ assertTrue(delegate.processFileCalled)
+ assertFalse(client.uploadCalled)
+ }
+
+ @Test
+ fun `an uploader sees a file the delegate's metadata gate would have declined`() {
+ // The gate exists to skip a temp copy for a file the delegate won't touch. An
+ // uploader takes over delivery for every file, so passing through here would
+ // silently bypass it.
+ val uploader = RecordingUploader()
+ val client = MockInternalMediaClient()
+ val delegate = DeclineByMetadataDelegate()
+ server.stop()
+ server = MediaUploadServer(
+ uploadDelegate = delegate, internalClient = client, uploader = uploader,
+ cacheDir = tempFolder.root
+ )
+
+ val boundary = "test-boundary-declined"
+ val body = buildMultipartBody(boundary, "clip.mov", "video/quicktime", "movie".toByteArray())
+ sendRawRequest(
+ method = "POST",
+ path = "/upload",
+ headers = mapOf(
+ "Relay-Authorization" to "Bearer ${server.token}",
+ "Content-Type" to "multipart/form-data; boundary=$boundary"
+ ),
+ body = body
+ )
+
+ assertEquals("clip.mov", uploader.received?.filename)
+ assertFalse(client.passthroughUploadCalled)
+ // ...but a declined file must still not reach processFile: handlesFile
+ // returning false is the delegate saying it won't touch a file like this.
+ assertFalse(delegate.processFileCalled)
+ }
+
@Test
fun `routes upload with a query string and relays the query`() {
val delegate = ProcessOnlyDelegate()
@@ -768,6 +908,7 @@ class MediaUploadServerTest {
return ProcessedProxyFile.Original
}
+ @Suppress("OVERRIDE_DEPRECATION")
override suspend fun uploadFile(file: File, mimeType: String, filename: String): MediaUploadResponse? {
uploadFileCalled = true
lastFilename = filename
@@ -786,8 +927,9 @@ class MediaUploadServerTest {
}
/**
- * Declines every file by metadata via [handlesFile], so the server must pass
- * through without materializing the file or calling [processFile].
+ * Declines every file by metadata via [handlesFile]. With no uploader the server
+ * must pass through without materializing the file; with one, delivery still
+ * happens but [processFile] must not be called. [processFileCalled] pins both.
*/
private class DeclineByMetadataDelegate : MediaUploadDelegate {
@Volatile var processFileCalled = false
@@ -830,6 +972,21 @@ class MediaUploadServerTest {
)
}
+ /** Records the [MediaUpload] it is handed, and returns a finished attachment. */
+ private class RecordingUploader : MediaUploader {
+ @Volatile var received: MediaUpload? = null
+
+ override suspend fun upload(upload: MediaUpload): ByteArray {
+ received = upload
+ // Shaped like a real attachment: the editor's `transformAttachment`
+ // reads `title.raw`, so an example without it would model a body that
+ // fails in the editor.
+ val attachment = """{"id":7,"source_url":"https://example.com/photo.jpg",""" +
+ """"media_type":"image","title":{"raw":"photo"},"caption":{"raw":""}}"""
+ return attachment.toByteArray()
+ }
+ }
+
private class MockInternalMediaClient : InternalMediaClient(
httpClient = okhttp3.OkHttpClient(),
siteApiRoot = "https://example.com/wp-json/",
diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift
index 269d21b72..a1690d737 100644
--- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift
+++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift
@@ -143,6 +143,22 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
// swiftlint:disable:next weak_delegate
public private(set) var mediaUploadDelegate: (any MediaUploadDelegate)?
+ /// Takes over media upload on the host's own stack (background session, offline
+ /// queue, resumable transport). Passing one makes the host own every upload and its
+ /// whole lifecycle; GutenbergKit stays out of the network entirely for media.
+ ///
+ /// Same ownership rules as ``mediaUploadDelegate``: supplied at `init`, held for the
+ /// editor's lifetime, and not conformed by the object that owns the editor.
+ ///
+ /// Reuse is the expected shape here, more so than for a delegate: the transports this
+ /// exists for outlive any one editor by definition — a background `URLSession` has a
+ /// fixed identifier and must survive app relaunch, an offline queue spans sessions.
+ /// Build the uploader once, hold it, and pass the same instance to each editor.
+ ///
+ /// Takes precedence over the deprecated ``MediaUploadDelegate/uploadFile(at:mimeType:filename:)``:
+ /// with an uploader set, that hook is never called.
+ public private(set) var mediaUploader: (any MediaUploader)?
+
// MARK: - Private Properties (Services)
private let editorService: EditorService
private let httpClient: any EditorHTTPClientProtocol
@@ -206,6 +222,8 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
/// object carrying the settings it needs. If you must write the retaining shape,
/// call ``stopMediaHandling()`` when you are done. To reuse one delegate across
/// editors, keep your own reference — the editor drops only its own when it goes.
+ /// - mediaUploader: Takes over media upload on the host's own stack. Same ownership
+ /// rules as `mediaUploadDelegate`.
/// - httpClient: Replaces the client used for editor and media requests.
/// - isWarmupMode: Loads the editor shell without dependencies, to warm WebKit.
public init(
@@ -213,6 +231,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
dependencies: EditorDependencies? = nil,
mediaPicker: MediaPickerController? = nil,
mediaUploadDelegate: (any MediaUploadDelegate)? = nil,
+ mediaUploader: (any MediaUploader)? = nil,
httpClient: EditorHTTPClient? = nil,
isWarmupMode: Bool = false
) {
@@ -231,6 +250,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
self.bundleProvider = EditorAssetBundleProvider(httpClient: httpClient)
self.mediaPicker = mediaPicker
self.mediaUploadDelegate = mediaUploadDelegate
+ self.mediaUploader = mediaUploader
self.lockdownModeMonitor = LockdownModeMonitor()
self.controller = GutenbergEditorController(configuration: configuration, lockdownModeMonitor: self.lockdownModeMonitor)
@@ -344,7 +364,8 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
}
/// Releases the editor's media handling: stops the local upload server, drops the
- /// host's ``mediaUploadDelegate``, and withdraws the upload endpoint from the page.
+ /// host's ``mediaUploadDelegate`` and ``mediaUploader``, and withdraws the upload
+ /// endpoint from the page.
///
/// Most hosts never need this. Releasing the editor runs `deinit`, which does the
/// same work. It is only required when the delegate holds the editor back — which
@@ -388,6 +409,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
uploadServer?.stop()
uploadServer = nil
mediaUploadDelegate = nil
+ mediaUploader = nil
revokeNativeUploadEndpoint()
}
@@ -557,10 +579,10 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
/// because `nativeUploadPort` will be nil in GBKit).
private func startUploadServer() async {
// Nothing to route through the native server unless the host provided a
- // delegate. The editor owns it — `mediaUploadDelegate` is strong — so there's
- // no released-before-load case to guard against; it lives as long as the
- // editor does.
- guard mediaUploadDelegate != nil else {
+ // delegate or an uploader. The editor owns whichever it was given — both
+ // properties are strong — so there's no released-before-load case to guard
+ // against; they live as long as it does.
+ guard mediaUploadDelegate != nil || mediaUploader != nil else {
return
}
@@ -588,6 +610,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
do {
let server = try await MediaUploadServer.start(
uploadDelegate: mediaUploadDelegate,
+ uploader: mediaUploader,
internalClient: internalClient
)
diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift
index 73752166b..a5bfb7d5b 100644
--- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift
+++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift
@@ -78,9 +78,18 @@ public protocol MediaUploadDelegate: AnyObject, Sendable {
/// Upload a processed file to the remote WordPress site.
///
/// Return the raw WordPress response (status code + body), which GutenbergKit
- /// relays to the editor unchanged, or `nil` to use the default uploader. A
+ /// relays to the editor unchanged, or `nil` to use the internal media client. A
/// host that uploads to WordPress should return the exact response it
/// received so the editor sees a complete attachment object.
+ ///
+ /// - Warning: Returning a raw response splits one upload's HTTP across two
+ /// owners — you perform the `POST`, but GutenbergKit's editor drives the
+ /// `post-process` retries and orphan cleanup behind it, through the WebView
+ /// rather than your stack. It also receives no form fields, so an attachment
+ /// uploaded this way lands unattached to its post. Conform to ``MediaUploader``
+ /// instead: it owns the upload end-to-end and receives a ``MediaUpload``
+ /// carrying the fields.
+ @available(*, deprecated, message: "Conform to MediaUploader instead — it owns the upload's retries and receives the editor's form fields.")
func uploadFile(at url: URL, mimeType: String, filename: String) async throws -> MediaUploadResponse?
}
@@ -94,7 +103,98 @@ extension MediaUploadDelegate {
.original
}
+ @available(*, deprecated, message: "Conform to MediaUploader instead — it owns the upload's retries and receives the editor's form fields.")
public func uploadFile(at url: URL, mimeType: String, filename: String) async throws -> MediaUploadResponse? {
nil
}
}
+
+/// One of the editor's non-file form fields, as sent with a media upload.
+///
+/// A named type rather than a `(name, value)` tuple: tuples are not nominal, so a
+/// tuple-typed property would permanently block `Equatable`/`Hashable`/`Codable`
+/// synthesis on ``MediaUpload`` — including inside GutenbergKit, and not fixable
+/// later without a source break for every host.
+public struct MediaUploadField: Sendable, Hashable, Codable {
+ /// The field name, e.g. `post`. Not unique — a `field[]` array repeats it.
+ public let name: String
+
+ /// The field's value, decoded as UTF-8.
+ public let value: String
+
+ public init(name: String, value: String) {
+ self.name = name
+ self.value = value
+ }
+}
+
+/// Everything a ``MediaUploader`` needs to reproduce a native upload: the file to
+/// send, its metadata, the editor's non-file form fields, and the request's query.
+public struct MediaUpload: Sendable {
+ /// The file to upload — already processed, if a ``MediaUploadDelegate`` ran.
+ public let fileURL: URL
+
+ /// The file's MIME type.
+ public let mimeType: String
+
+ /// The file's name.
+ public let filename: String
+
+ /// The editor's non-file form fields, in order, each decoded as UTF-8 — most
+ /// importantly `post`, the parent post's ID, without which the attachment is
+ /// created unattached. A list, not a dictionary, so repeated field names (e.g. a
+ /// `field[]` array) survive verbatim. Send each as a form part on your
+ /// `POST /wp/v2/media`, in the given order.
+ public let fields: [MediaUploadField]
+
+ /// The request's query string (leading `?`, e.g. `?_embed=wp:featuredmedia`), or
+ /// empty. Carry it on your request so the editor gets the response it expects.
+ public let query: String
+
+ public init(fileURL: URL, mimeType: String, filename: String, fields: [MediaUploadField], query: String) {
+ self.fileURL = fileURL
+ self.mimeType = mimeType
+ self.filename = filename
+ self.fields = fields
+ self.query = query
+ }
+}
+
+/// Takes over *performing* a media upload — on the host's own stack: its own
+/// networking (say, to log every request), a background session, an offline queue,
+/// a resumable transport, its own retry policy.
+///
+/// This is a choice of *who executes the requests*, not where they go: an uploader
+/// and GutenbergKit's internal media client both target the same configured site.
+/// Setting ``EditorViewController/mediaUploader`` makes the host own that upload
+/// end-to-end — the request, its own retries, and its recovery and cleanup — with
+/// GutenbergKit out of the network entirely. Because the host does the retries
+/// itself, there's no raw response left for the editor to retry behind it. The
+/// attachment you return lives on that same configured site, where the editor reads
+/// and updates it by ID.
+public protocol MediaUploader: AnyObject, Sendable {
+ /// Upload a (possibly processed) file and return the finished WordPress
+ /// attachment JSON the editor inserts — the same object a direct
+ /// `POST /wp/v2/media` returns. Return only once the upload is genuinely done,
+ /// or `throw` on terminal failure: a returned value is taken as a completed
+ /// attachment, and there is no GutenbergKit recovery behind you.
+ ///
+ /// The ``MediaUpload`` carries the file plus the editor's form fields (e.g.
+ /// `post`) and query — send them all so the created attachment matches a native
+ /// upload rather than landing as an unattached orphan.
+ ///
+ /// That recovery is yours to run. When `POST /wp/v2/media` fatals in server-side
+ /// post-processing it returns a 5xx carrying the attachment's ID in
+ /// `x-wp-upload-attachment-id` — the attachment exists but is unfinished. Don't
+ /// re-upload; drive `POST /wp/v2/media//post-process` to completion, the way
+ /// core recovers its own uploads (up to 5 attempts), then return the finished
+ /// attachment. That request needs a body of `{"action": "create-image-subsizes"}`
+ /// — core registers `action` as **required**, so a post-process request without
+ /// it fails with a 400 every time rather than recovering.
+ ///
+ /// Owning the upload means owning cleanup on the server too: if post-process
+ /// can't be recovered, force-delete the orphan
+ /// (`DELETE /wp/v2/media/?force=true`) before you `throw`, or it stays on the
+ /// site — neither GutenbergKit nor the editor cleans up behind you.
+ func upload(_ upload: MediaUpload) async throws -> Data
+}
diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift
index ed58ad69e..634492f3f 100644
--- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift
+++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift
@@ -30,12 +30,14 @@ final class MediaUploadServer: Sendable {
///
/// - Parameters:
/// - uploadDelegate: Optional delegate for customizing file processing and upload.
+ /// - uploader: Optional host uploader that performs the upload on its own stack.
/// - internalClient: GutenbergKit's own client for the configured site. Delivers
- /// uploads when no delegate provides `uploadFile`, and every media delete.
+ /// uploads when no host uploader or delegate does, and every media delete.
/// - maxRequestBodySize: The maximum allowed request body size in bytes.
/// Requests exceeding this limit receive a 413 response. Defaults to 4 GB.
static func start(
uploadDelegate: (any MediaUploadDelegate)? = nil,
+ uploader: (any MediaUploader)? = nil,
internalClient: InternalMediaClient? = nil,
maxRequestBodySize: Int64 = HTTPRequestParser.defaultMaxBodySize
) async throws -> MediaUploadServer {
@@ -46,7 +48,7 @@ final class MediaUploadServer: Sendable {
cleanOrphanedUploads()
}
- let context = UploadContext(uploadDelegate: uploadDelegate, internalClient: internalClient)
+ let context = UploadContext(uploadDelegate: uploadDelegate, uploader: uploader, internalClient: internalClient)
// A generous ceiling for receiving the upload body. The body read is
// primarily bounded by the per-read idle timeout (which reaps a stalled
@@ -182,11 +184,19 @@ final class MediaUploadServer: Sendable {
let filename = filePart.filename ?? "upload"
let mimeType = filePart.contentType
- // Ask the delegate — from metadata alone — whether it will touch a file
- // like this. If not, forward the original upload to WordPress directly,
- // skipping a full temp-file copy of a file the delegate won't process or
- // upload (e.g. a video handed to an image-only delegate).
- guard context.uploadDelegate?.handlesFile(ofType: mimeType, named: filename) ?? false else {
+ // Ask the delegate — from metadata alone — whether it will touch a file like
+ // this. If not, forward the original upload to WordPress directly, skipping a
+ // full temp-file copy of a file the delegate won't process (e.g. a video handed
+ // to an image-only delegate).
+ //
+ // An uploader takes over delivery for *every* file, so with one set there is
+ // no passthrough to fall to and the gate can't decline the upload outright.
+ // It still decides whether `processFile` runs, though — a declined file is
+ // handed to the uploader unprocessed rather than to a delegate that said it
+ // won't touch it — so the answer is carried into `processAndUpload` rather
+ // than discarded here. Asked exactly once per upload, matching Android.
+ let delegateWantsFile = context.uploadDelegate?.handlesFile(ofType: mimeType, named: filename) ?? false
+ guard context.uploader != nil || delegateWantsFile else {
do {
return try await passthroughResponse(request, query: query, internalClient: context.internalClient)
} catch {
@@ -194,10 +204,10 @@ final class MediaUploadServer: Sendable {
}
}
- // The delegate wants the file. Stream the part body to a dedicated temp
- // file for it — the library's RequestBody may be a byte-range slice of a
- // larger temp file whose lifecycle is tied to ARC, so the delegate needs a
- // standalone file that outlives the handler return.
+ // Someone wants the file — the delegate, the uploader, or both. Stream the
+ // part body to a dedicated temp file for them: the library's RequestBody may
+ // be a byte-range slice of a larger temp file whose lifecycle is tied to ARC,
+ // so they need a standalone file that outlives the handler return.
let tempDir = uploadsTempDirectory
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
@@ -219,7 +229,8 @@ final class MediaUploadServer: Sendable {
do {
let uploadResult = try await processAndUpload(
fileURL: fileURL, mimeType: mimeType, filename: filename,
- extraParts: extraParts, query: query, context: context
+ extraParts: extraParts, query: query,
+ delegateWantsFile: delegateWantsFile, context: context
)
switch uploadResult {
case .uploaded(let uploaded):
@@ -328,8 +339,8 @@ final class MediaUploadServer: Sendable {
/// Result of the delegate processing + upload pipeline.
private enum UploadResult {
- /// The delegate (or the internal media client) completed the upload; carries the
- /// raw WordPress response to relay.
+ /// The uploader, delegate, or internal media client completed the upload;
+ /// carries the raw WordPress response to relay.
case uploaded(MediaUploadResponse)
/// The delegate didn't modify the file and `uploadFile` returned nil.
/// The caller should forward the original request body to WordPress.
@@ -338,11 +349,16 @@ final class MediaUploadServer: Sendable {
private static func processAndUpload(
fileURL: URL, mimeType: String, filename: String,
- extraParts: [MultipartPart], query: String, context: UploadContext
+ extraParts: [MultipartPart], query: String,
+ delegateWantsFile: Bool, context: UploadContext
) async throws -> UploadResult {
- // Step 1: Process (resize, transcode, etc.)
+ // Step 1: Process (resize, transcode, etc.) — but only for a file the
+ // delegate's metadata gate accepted. `handlesFile` returning false is the
+ // delegate saying it won't touch a file like this, so handing it one anyway
+ // would break the contract the gate documents. With an uploader set the file
+ // still gets delivered; it just skips processing on its way there.
let processed: ProcessedProxyFile
- if let delegate = context.uploadDelegate {
+ if let delegate = context.uploadDelegate, delegateWantsFile {
processed = try await delegate.processFile(at: fileURL, mimeType: mimeType, filename: filename)
} else {
processed = .original
@@ -380,9 +396,25 @@ final class MediaUploadServer: Sendable {
// keeps this true for a host-injected `URLSessionProtocol` that doesn't.
try Task.checkCancellation()
- // Step 2: Upload to remote WordPress
+ // Step 2: deliver. An uploader owns delivery on the host's own stack and
+ // returns the finished attachment JSON (or throws); GutenbergKit relays that
+ // as a success and never runs its own recovery behind it.
+ if let uploader = context.uploader {
+ let upload = MediaUpload(
+ fileURL: uploadURL,
+ mimeType: uploadMimeType,
+ filename: uploadFilename,
+ fields: try await formFields(from: extraParts),
+ query: query
+ )
+ let attachment = try await uploader.upload(upload)
+ return .uploaded(MediaUploadResponse(statusCode: 201, body: attachment))
+ }
+
+ // The deprecated delegate path: the host performs the POST but returns the raw
+ // response, leaving the editor to drive post-process recovery behind it.
if let delegate = context.uploadDelegate,
- let result = try await delegate.uploadFile(at: uploadURL, mimeType: uploadMimeType, filename: uploadFilename) {
+ let result = try await deprecatedUploadFile(delegate, uploadURL, uploadMimeType, uploadFilename) {
return .uploaded(result)
} else if let internalClient = context.internalClient {
// Unmodified — forward the original request body directly, skipping
@@ -397,6 +429,29 @@ final class MediaUploadServer: Sendable {
}
}
+ /// The editor's non-file form parts as ordered, UTF-8-decoded fields.
+ ///
+ /// A list rather than a dictionary so repeated names (e.g. a `field[]` array)
+ /// survive verbatim, in the order the editor sent them.
+ private static func formFields(from parts: [MultipartPart]) async throws -> [MediaUploadField] {
+ var fields: [MediaUploadField] = []
+ for part in parts {
+ fields.append(MediaUploadField(name: part.name, value: String(decoding: try await part.body.data, as: UTF8.self)))
+ }
+ return fields
+ }
+
+ /// Calls the deprecated `uploadFile` hook from one place.
+ ///
+ /// This deliberately leaves one deprecation warning in GutenbergKit's own build:
+ /// the marker exists to tell *hosts* to migrate, and supporting the hook until it
+ /// is removed means calling it. The warning marks the code that goes with it.
+ private static func deprecatedUploadFile(
+ _ delegate: any MediaUploadDelegate, _ url: URL, _ mimeType: String, _ filename: String
+ ) async throws -> MediaUploadResponse? {
+ try await delegate.uploadFile(at: url, mimeType: mimeType, filename: filename)
+ }
+
private static func errorResponse(status: Int, message: String) -> HTTPResponse {
// Emit a WordPress-REST-style error object so the JS middleware normalizes
// it (and surfaces `message`) the same way it does a relayed WordPress
@@ -519,10 +574,10 @@ enum UploadError: Error, LocalizedError {
// MARK: - Upload Context
-/// Container for the upload delegate and the internal media client, captured by the
-/// HTTPServer handler closure and read on each request.
+/// Container for the upload delegate, host uploader, and internal media client,
+/// captured by the HTTPServer handler closure and read on each request.
///
-/// Both are held **strongly**, so a delegate that admitted a file for processing
+/// All are held **strongly**, so a delegate that admitted a file for processing
/// will process it — the three reads within a request can't disagree, and an
/// in-flight upload keeps the host's delegate alive until it unwinds. That lifetime
/// comes from the handler closure, which the listener retains for the server's
@@ -543,6 +598,7 @@ enum UploadError: Error, LocalizedError {
/// protocol and `InternalMediaClient` is `@unchecked Sendable`.
private struct UploadContext: Sendable {
let uploadDelegate: (any MediaUploadDelegate)?
+ let uploader: (any MediaUploader)?
let internalClient: InternalMediaClient?
}
diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift
index 0a3294326..d8ef1b848 100644
--- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift
+++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift
@@ -385,6 +385,150 @@ struct MediaUploadServerTests {
#expect(FileManager.default.fileExists(atPath: fresh.path(percentEncoded: false)))
}
+ @Test("an uploader performs the upload and its result is relayed")
+ func uploaderPerformsUpload() async throws {
+ let uploader = RecordingUploader()
+ let internalClient = MockInternalMediaClient()
+ let server = try await MediaUploadServer.start(uploader: uploader, internalClient: internalClient)
+ defer { server.stop() }
+
+ let boundary = UUID().uuidString
+ let body = buildMultipartBody(boundary: boundary, filename: "photo.jpg", mimeType: "image/jpeg", data: Data("fake image data".utf8))
+ let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
+ request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+ request.httpBody = body
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+ let httpResponse = try #require(response as? HTTPURLResponse)
+
+ #expect(httpResponse.statusCode == 201)
+ #expect(String(decoding: data, as: UTF8.self).contains("\"id\":7"))
+ // GutenbergKit stays out of the network when a host uploader is set.
+ #expect(!internalClient.uploadCalled)
+ #expect(!internalClient.passthroughUploadCalled)
+ #expect(uploader.received?.filename == "photo.jpg")
+ #expect(uploader.received?.mimeType == "image/jpeg")
+ }
+
+ @Test("an uploader receives the editor's form fields in order, and the query")
+ func uploaderReceivesFieldsAndQuery() async throws {
+ // Without `post` the attachment is created unattached, and repeated names (a
+ // `field[]` array) must survive as repeats rather than collapse into a dictionary.
+ let uploader = RecordingUploader()
+ let server = try await MediaUploadServer.start(uploader: uploader, internalClient: MockInternalMediaClient())
+ defer { server.stop() }
+
+ let boundary = UUID().uuidString
+ var body = Data()
+ for (name, value) in [("post", "42"), ("tags[]", "a"), ("tags[]", "b")] {
+ body.append("--\(boundary)\r\n")
+ body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n")
+ body.append("\(value)\r\n")
+ }
+ body.append("--\(boundary)\r\n")
+ body.append("Content-Disposition: form-data; name=\"file\"; filename=\"photo.jpg\"\r\n")
+ body.append("Content-Type: image/jpeg\r\n\r\n")
+ body.append(Data("fake image data".utf8))
+ body.append("\r\n--\(boundary)--\r\n")
+
+ let url = URL(string: "http://127.0.0.1:\(server.port)/upload?_embed=wp:featuredmedia")!
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
+ request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+ request.httpBody = body
+
+ _ = try await URLSession.shared.data(for: request)
+
+ let received = try #require(uploader.received)
+ #expect(received.fields == [
+ MediaUploadField(name: "post", value: "42"),
+ MediaUploadField(name: "tags[]", value: "a"),
+ MediaUploadField(name: "tags[]", value: "b"),
+ ])
+ #expect(received.query == "?_embed=wp:featuredmedia")
+ }
+
+ @Test("an uploader takes precedence over the deprecated uploadFile hook")
+ func uploaderWinsOverDeprecatedHook() async throws {
+ let delegate = MockUploadDelegate()
+ let uploader = RecordingUploader()
+ let server = try await MediaUploadServer.start(uploadDelegate: delegate, uploader: uploader, internalClient: MockInternalMediaClient())
+ defer { server.stop() }
+
+ let boundary = UUID().uuidString
+ let body = buildMultipartBody(boundary: boundary, filename: "photo.jpg", mimeType: "image/jpeg", data: Data("fake image data".utf8))
+ let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
+ request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+ request.httpBody = body
+
+ _ = try await URLSession.shared.data(for: request)
+
+ // The delegate still processes; only delivery moves to the uploader.
+ #expect(delegate.processFileCalled)
+ #expect(!delegate.uploadFileCalled)
+ #expect(uploader.received != nil)
+ }
+
+ @Test("an uploader sees a file the delegate's metadata gate would have declined")
+ func uploaderSeesDeclinedFile() async throws {
+ // The gate exists to skip a temp copy for a file the delegate won't touch. An
+ // uploader takes over delivery for every file, so passing through here would
+ // silently bypass it.
+ let delegate = DeclineByMetadataDelegate()
+ let uploader = RecordingUploader()
+ let internalClient = MockInternalMediaClient()
+ let server = try await MediaUploadServer.start(uploadDelegate: delegate, uploader: uploader, internalClient: internalClient)
+ defer { server.stop() }
+
+ let boundary = UUID().uuidString
+ let body = buildMultipartBody(boundary: boundary, filename: "clip.mov", mimeType: "video/quicktime", data: Data("movie".utf8))
+ let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
+ request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+ request.httpBody = body
+
+ _ = try await URLSession.shared.data(for: request)
+
+ #expect(uploader.received?.filename == "clip.mov")
+ #expect(!internalClient.passthroughUploadCalled)
+ // ...but a declined file must still not reach `processFile`: `handlesFile`
+ // returning false is the delegate saying it won't touch a file like this.
+ #expect(!delegate.processFileCalled)
+ }
+
+ @Test("an uploader that throws surfaces as a failure, with no GutenbergKit retry")
+ func uploaderThrowSurfaces() async throws {
+ let internalClient = MockInternalMediaClient()
+ let server = try await MediaUploadServer.start(uploader: ThrowingUploader(), internalClient: internalClient)
+ defer { server.stop() }
+
+ let boundary = UUID().uuidString
+ let body = buildMultipartBody(boundary: boundary, filename: "photo.jpg", mimeType: "image/jpeg", data: Data("fake image data".utf8))
+ let url = URL(string: "http://127.0.0.1:\(server.port)/upload")!
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization")
+ request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+ request.httpBody = body
+
+ let (_, response) = try await URLSession.shared.data(for: request)
+ let httpResponse = try #require(response as? HTTPURLResponse)
+
+ #expect(httpResponse.statusCode == 500)
+ // Recovery is the uploader's, not GutenbergKit's — it must not re-deliver.
+ #expect(!internalClient.uploadCalled)
+ #expect(!internalClient.passthroughUploadCalled)
+ }
+
@Test("retains the delegate for the server's lifetime, and releases it after")
func retainsDelegateForServerLifetime() async throws {
weak var weakDelegate: MockUploadDelegate?
@@ -873,6 +1017,32 @@ private func readAllFromStream(_ stream: InputStream) -> Data {
// MARK: - Mocks
+/// Records the ``MediaUpload`` it is handed, and returns a finished attachment.
+private final class RecordingUploader: MediaUploader, @unchecked Sendable {
+ private let lock = NSLock()
+ private var _received: MediaUpload?
+
+ var received: MediaUpload? { lock.withLock { _received } }
+
+ func upload(_ upload: MediaUpload) async throws -> Data {
+ lock.withLock { _received = upload }
+ // Shaped like a real attachment: the editor's `transformAttachment` reads
+ // `title.raw`, so an example without it would model a body that fails in the
+ // editor.
+ return Data(#"{"id":7,"source_url":"https://example.com/photo.jpg","media_type":"image","title":{"raw":"photo"},"caption":{"raw":""}}"#.utf8)
+ }
+}
+
+/// An uploader whose delivery fails terminally, as one would after exhausting its own
+/// post-process recovery and force-deleting the orphan.
+private final class ThrowingUploader: MediaUploader, @unchecked Sendable {
+ struct Failure: Error {}
+
+ func upload(_ upload: MediaUpload) async throws -> Data {
+ throw Failure()
+ }
+}
+
/// A delegate that transcodes, used to check the server holds it across the whole
/// request rather than re-reading a reference the host may have dropped.
private final class TranscodingDelegate: MediaUploadDelegate, @unchecked Sendable {
@@ -929,9 +1099,10 @@ private final class ProcessOnlyDelegate: MediaUploadDelegate, @unchecked Sendabl
}
}
-/// A delegate that declines every file by metadata via `handlesFile`, so the
-/// server must pass through without ever materializing the file or calling
-/// `processFile`.
+/// A delegate that declines every file by metadata via `handlesFile`. With no
+/// uploader the server must pass through without ever materializing the file; with
+/// one, delivery still happens but `processFile` must not be called.
+/// `processFileCalled` pins both.
private final class DeclineByMetadataDelegate: MediaUploadDelegate, @unchecked Sendable {
private let lock = NSLock()
private var _processFileCalled = false