Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions android/Gutenberg/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<ID>ExplicitItLambdaParameter:EditorAssetsLibrary.kt$EditorAssetsLibrary${ str, it -&gt; str + "%02x".format(it) }</ID>
<ID>FunctionNaming:EditorURLCache.kt$EditorURLCache$private fun __store( response: EditorURLResponse, url: String, httpMethod: EditorHttpMethod, currentDate: Date )</ID>
<ID>LargeClass:GutenbergView.kt$GutenbergView : FrameLayout</ID>
<ID>LargeClass:MediaUploadServerTest.kt$MediaUploadServerTest</ID>
<ID>LongMethod:FixtureTests.kt$FixtureTests$@Test fun `request parsing - all basic cases pass`()</ID>
<ID>LongMethod:FixtureTests.kt$FixtureTests$@Test fun `request parsing - all incremental cases pass`()</ID>
<ID>LongMethod:HTTPRequestParser.kt$HTTPRequestParser$fun append(data: ByteArray): Unit</ID>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -671,10 +689,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
Expand Down Expand Up @@ -710,6 +728,7 @@ class GutenbergView : FrameLayout {
uploadServer = MediaUploadServer(
uploadDelegate = mediaUploadDelegate,
internalClient = internalClient,
uploader = mediaUploader,
cacheDir = context.cacheDir,
scope = coroutineScope
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,97 @@ 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.
*/
@Deprecated(
"Implement MediaUploader instead — it owns the upload's retries and receives the editor's form fields.",
ReplaceWith("MediaUploader")
)
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<MediaUploadField>,
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/<id>/post-process` to completion, the way
* core recovers its own uploads (up to 5 attempts), then return the finished
* attachment.
*
* 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/<id>?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.
Expand All @@ -128,6 +212,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
Expand Down Expand Up @@ -247,6 +332,15 @@ internal class MediaUploadServer(
* Deliberately narrow: this server relays media operations, not arbitrary
* REST requests, so only a numeric attachment ID under `/media/` matches.
*/
/**
* 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<MultipartPart>): List<MediaUploadField> =
parts.map { MediaUploadField(it.name, String(it.body.readBytes(), Charsets.UTF_8)) }

private fun attachmentIdFromPath(path: String): String? {
val components = path.split("/").filter { it.isNotEmpty() }
if (components.size != 2 || components[0] != "media") return null
Expand Down Expand Up @@ -290,7 +384,10 @@ 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: only the delegate's metadata gate can decline a file,
// and only when no uploader is configured.
if (uploader == null && uploadDelegate?.handlesFile(mimeType, filename) != true) {
return passthroughResponse(request, query)
}

Expand Down Expand Up @@ -461,7 +558,23 @@ internal class MediaUploadServer(
}

try {
// If the delegate provided its own upload, use that.
// 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)
}
Expand All @@ -473,7 +586,7 @@ internal class MediaUploadServer(
}

val result = internalClient?.upload(targetFile, targetMimeType, targetFilename, extraParts, query)
?: error("No upload delegate or default uploader configured")
?: error("No upload delegate or internal media client configured")
return UploadResult.Uploaded(result)
} finally {
// The processed file (if the delegate produced a new one) is ours to
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,111 @@ 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 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()
server.stop()
server = MediaUploadServer(
uploadDelegate = DecliningDelegate(), 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)
}

@Test
fun `routes upload with a query string and relays the query`() {
val delegate = ProcessOnlyDelegate()
Expand Down Expand Up @@ -830,6 +935,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
return """{"id":7,"source_url":"https://example.com/photo.jpg","media_type":"image"}""".toByteArray()
}
}

/** A delegate that declines every file by metadata. */
private class DecliningDelegate : MediaUploadDelegate {
override fun handlesFile(mimeType: String, filename: String) = false
}

private class MockInternalMediaClient : InternalMediaClient(
httpClient = okhttp3.OkHttpClient(),
siteApiRoot = "https://example.com/wp-json/",
Expand Down
Loading
Loading