Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,18 @@ class InstrumentedFixtureTests {

try {
parser.parseRequest()
fail("$description: expected error $expectedError but parsing succeeded")
// Non-fatal errors (e.g., payloadTooLarge) are exposed via
// pendingParseError instead of being thrown.
val pendingError = parser.pendingParseError
if (pendingError != null) {
assertEquals(
expectedError,
pendingError.errorId,
"$description: expected $expectedError but got ${pendingError.errorId}"
)
} else {
fail("$description: expected error $expectedError but parsing succeeded")
}
} catch (e: HTTPRequestParseException) {
assertEquals(
expectedError,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ data class HttpRequest(
val target: String,
val headers: Map<String, String>,
val body: org.wordpress.gutenberg.http.RequestBody? = null,
val parseDurationMs: Double = 0.0
val parseDurationMs: Double = 0.0,
/** A server-detected error that occurred after headers were parsed
* (e.g., payload too large). When set, the handler is responsible
* for building an appropriate error response. */
val serverError: org.wordpress.gutenberg.http.HTTPRequestParseError? = null
) {
/**
* Returns the value of the first header matching the given name (case-insensitive).
Expand Down Expand Up @@ -277,13 +281,12 @@ class HttpServer(
val buffer = ByteArray(READ_CHUNK_SIZE)

// Phase 1: receive headers only.
while (!parser.state.hasHeaders) {
if (System.nanoTime() > deadlineNanos) {
throw SocketTimeoutException("Read deadline exceeded")
}
val bytesRead = input.read(buffer)
if (bytesRead == -1) break
parser.append(buffer.copyOfRange(0, bytesRead))
readUntil(parser, input, buffer, deadlineNanos) { it.hasHeaders }

// Drain oversized body before throwing so the
// client receives the 413 (RFC 9110 §15.5.14).
if (parser.state == HTTPRequestParser.State.DRAINING) {
readUntil(parser, input, buffer, deadlineNanos) { it.isComplete }
}

// Validate headers (triggers full RFC validation).
Expand Down Expand Up @@ -312,6 +315,31 @@ class HttpServer(
return
}

// If the parser detected a non-fatal error (e.g., payload too
// large after drain), let the handler build the response.
parser.pendingParseError?.let { error ->
val parseDurationMs = (System.nanoTime() - parseStart) / 1_000_000.0
val request = HttpRequest(
method = partial.method,
target = partial.target,
headers = partial.headers,
parseDurationMs = parseDurationMs,
serverError = error
)
val response = try {
handler(request)
} catch (e: Exception) {
Log.e(TAG, "Handler threw", e)
HttpResponse(
status = error.httpStatus,
body = (STATUS_TEXT[error.httpStatus] ?: "Error").toByteArray()
)
}
sendResponse(socket, response)
Log.d(TAG, "${partial.method} ${partial.target} → ${response.status} (${"%.1f".format(parseDurationMs)}ms)")
return
}

// Check auth before consuming body to avoid buffering up to
// maxBodySize for unauthenticated clients.
// OPTIONS is exempt because CORS preflight requests
Expand Down Expand Up @@ -341,14 +369,7 @@ class HttpServer(
}

// Phase 2: receive body (skipped if already complete).
while (!parser.state.isComplete) {
if (System.nanoTime() > deadlineNanos) {
throw SocketTimeoutException("Read deadline exceeded")
}
val bytesRead = input.read(buffer)
if (bytesRead == -1) break
parser.append(buffer.copyOfRange(0, bytesRead))
}
readUntil(parser, input, buffer, deadlineNanos) { it.isComplete }

// Final parse with body.
val parsed = try {
Expand Down Expand Up @@ -406,6 +427,24 @@ class HttpServer(
}
}

/** Reads data into the parser until [condition] is satisfied or the connection closes. */
private fun readUntil(
parser: HTTPRequestParser,
input: BufferedInputStream,
buffer: ByteArray,
deadlineNanos: Long,
condition: (HTTPRequestParser.State) -> Boolean
) {
while (!condition(parser.state)) {
if (System.nanoTime() > deadlineNanos) {
throw SocketTimeoutException("Read deadline exceeded")
}
val bytesRead = input.read(buffer)
if (bytesRead == -1) break
parser.append(buffer.copyOfRange(0, bytesRead))
}
}

private fun sendResponse(socket: Socket, response: HttpResponse) {
val output = socket.getOutputStream()
output.write(serializeResponse(response))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ package org.wordpress.gutenberg
import android.util.Log
import org.wordpress.gutenberg.http.HeaderValue
import org.wordpress.gutenberg.http.MultipartPart
import org.wordpress.gutenberg.http.HTTPRequestParseError
import org.wordpress.gutenberg.http.MultipartParseException
import java.io.File
import java.io.IOException
import java.util.UUID
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.asRequestBody
import okio.source

/**
* Result of a successful media upload to the remote WordPress server.
Expand Down Expand Up @@ -91,6 +93,16 @@ internal class MediaUploadServer(
// MARK: - Request Handling

private suspend fun handleRequest(request: HttpRequest): HttpResponse {
// Server-detected error (e.g., payload too large) — build the
// error response here so it includes CORS headers.
request.serverError?.let { error ->
val message = when (error) {
HTTPRequestParseError.PAYLOAD_TOO_LARGE -> "The file is too large to upload in the editor."
else -> error.errorId
}
return errorResponse(error.httpStatus, message)
}
Comment on lines +96 to +104
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The approach of passing server errors to the handler was taken rather than the adding a new errorResponseHeaders configuration (added in 0b6c67b, reverted in 0563a94). The configuration approach leaks host/caller concerns into the library. A similar consideration occurred when implementing authentication bypass for preflight OPTIONS requests.

Ultimately, the 413 responses need to include the correct CORS headers, otherwise the error code and message do not arrive to the client. It instead receives a CORS error.

@jkmassel what do you think of this approach?


// CORS preflight — the library exempts OPTIONS from auth, so this is
// reached without a token.
if (request.method.uppercase() == "OPTIONS") {
Expand All @@ -112,7 +124,7 @@ internal class MediaUploadServer(
val tempFile = writePartToTempFile(filePart)
?: return errorResponse(500, "Failed to save file")

return processAndRespond(tempFile, filePart)
return processAndRespond(request, tempFile, filePart)
}

private fun parseFilePart(request: HttpRequest): MultipartPart? {
Expand Down Expand Up @@ -157,13 +169,27 @@ internal class MediaUploadServer(
}
}

private suspend fun processAndRespond(tempFile: File, filePart: MultipartPart): HttpResponse {
private suspend fun processAndRespond(
request: HttpRequest, tempFile: File, filePart: MultipartPart
): HttpResponse {
var processedFile: File? = null
try {
val (media, processed) = processAndUpload(
val uploadResult = processAndUpload(
tempFile, filePart.contentType, filePart.filename ?: "upload"
)
processedFile = processed
val media = when (uploadResult) {
is UploadResult.Uploaded -> {
processedFile = uploadResult.processedFile
Log.d(TAG, "Uploading processed file to WordPress")
uploadResult.result
}
is UploadResult.Passthrough -> {
// Delegate didn't modify the file — forward the original
// request body to WordPress without re-encoding.
Log.d(TAG, "Passthrough: forwarding original request body to WordPress")
performPassthroughUpload(request)
}
}
return successResponse(media)
} catch (e: MediaUploadException) {
Log.e(TAG, "Upload processing failed", e)
Expand All @@ -176,16 +202,40 @@ internal class MediaUploadServer(

// MARK: - Delegate Pipeline

private sealed class UploadResult {
data class Uploaded(val result: MediaUploadResult, val processedFile: File) : UploadResult()
data object Passthrough : UploadResult()
}

private suspend fun performPassthroughUpload(request: HttpRequest): MediaUploadResult {
val body = request.body
val contentType = request.header("Content-Type")
val uploader = defaultUploader
if (body == null || contentType == null || uploader == null) {
throw MediaUploadException("Passthrough upload requires a request body, Content-Type, and default uploader")
}
return uploader.passthroughUpload(body, contentType)
}

private suspend fun processAndUpload(
file: File, mimeType: String, filename: String
): Pair<MediaUploadResult, File> {
): UploadResult {
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")
// If the delegate provided its own upload, use that.
uploadDelegate?.uploadFile(processedFile, mimeType, filename)?.let {
return UploadResult.Uploaded(it, processedFile)
}

// If the delegate didn't modify the file, the original request
// body can be forwarded directly — skip multipart re-encoding.
if (processedFile == file) {
return UploadResult.Passthrough
}

return Pair(result, processedFile)
val result = defaultUploader?.upload(processedFile, mimeType, filename)
?: error("No upload delegate or default uploader configured")
return UploadResult.Uploaded(result, processedFile)
}

// MARK: - Response Building
Expand Down Expand Up @@ -255,22 +305,57 @@ internal open class DefaultMediaUploader(
private val authHeader: String,
private val siteApiNamespace: List<String> = emptyList()
) {
/** The WordPress media endpoint URL, accounting for site API namespaces. */
private val mediaEndpointUrl: String
get() {
val namespace = siteApiNamespace.firstOrNull() ?: ""
return "${siteApiRoot}wp/v2/${namespace}media"
}

open suspend fun upload(file: File, mimeType: String, filename: String): MediaUploadResult {
val mediaType = mimeType.toMediaType()
val requestBody = okhttp3.MultipartBody.Builder()
.setType(okhttp3.MultipartBody.FORM)
.addFormDataPart("file", filename, file.asRequestBody(mediaType))
.build()

// 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")
.url(mediaEndpointUrl)
.addHeader("Authorization", authHeader)
.post(requestBody)
.build()

return performUpload(request)
}

/**
* Forwards the original request body to WordPress without re-encoding.
*
* Used when the delegate's `processFile` returned the file unchanged —
* the incoming multipart body is already valid for WordPress.
*/
open suspend fun passthroughUpload(
body: org.wordpress.gutenberg.http.RequestBody,
contentType: String
): MediaUploadResult {
val streamBody = object : okhttp3.RequestBody() {
override fun contentType() = contentType.toMediaType()
override fun contentLength() = body.size
override fun writeTo(sink: okio.BufferedSink) {
body.inputStream().use { sink.writeAll(it.source()) }
}
}

val request = okhttp3.Request.Builder()
.url(mediaEndpointUrl)
.addHeader("Authorization", authHeader)
.post(streamBody)
.build()

return performUpload(request)
}

private fun performUpload(request: okhttp3.Request): MediaUploadResult {
val response = httpClient.newCall(request).execute()
val body = response.body?.string()

Expand Down
Loading