From c9fddf62e02884b64b0687e57e9527f1829c89f5 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 27 Mar 2026 15:12:47 -0400 Subject: [PATCH 01/12] feat(js): route media uploads through native server via api-fetch middleware Add nativeMediaUploadMiddleware that intercepts POST /wp/v2/media requests when a native upload port is configured in GBKit. The middleware forwards uploads to the local HTTP server with Relay-Authorization bearer token auth, then transforms the native response into the WordPress REST API attachment shape. Includes user-friendly error handling for 413 (file too large) and generic upload failures. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/utils/api-fetch.js | 118 ++++++++++++++++++++++++++++++++++++++++- src/utils/bridge.js | 2 + 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js index 76f891825..dbacd6181 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,117 @@ 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 message = + response.status === 413 + ? `The file is too large to upload. Please choose a smaller file.` + : `Native upload failed (${ response.status }): ${ + body || response.statusText + }`; + const error = new Error( message ); + error.code = + response.status === 413 + ? 'upload_file_too_large' + : '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 +258,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. */ /** From 649b15a86f89014e66b2e1e97dad8274d80ba88a Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 27 Mar 2026 15:13:00 -0400 Subject: [PATCH 02/12] feat(ios): add media upload server and delegate Add MediaUploadServer backed by the GutenbergKitHTTP library, which handles TCP binding, HTTP parsing, bearer token auth, and multipart parsing. The upload server provides a thin handler that routes file uploads through a native delegate pipeline for processing (e.g. image resize, video transcode) before uploading to WordPress. - MediaUploadDelegate protocol with processFile and uploadFile hooks - DefaultMediaUploader for WordPress REST API uploads with namespace support - EditorViewController integration with async server lifecycle - GBKitGlobal nativeUploadPort/nativeUploadToken injection - GutenbergKitHTTP added as dependency of GutenbergKit target Co-Authored-By: Claude Opus 4.6 (1M context) --- Package.resolved | 2 +- Package.swift | 2 +- .../GutenbergKit/Sources/EditorLogging.swift | 3 + .../Sources/EditorViewController.swift | 57 ++- .../Sources/Media/MediaUploadDelegate.swift | 54 +++ .../Sources/Media/MediaUploadServer.swift | 386 ++++++++++++++++++ .../Sources/Model/GBKitGlobal.swift | 18 +- 7 files changed, 510 insertions(+), 12 deletions(-) create mode 100644 ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift 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/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..9741cff42 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -0,0 +1,386 @@ +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 { + // Parse multipart form-data using the library's RFC 7578 parser. + 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: [ + ("Access-Control-Allow-Origin", "*"), + ("Access-Control-Allow-Methods", "POST, OPTIONS"), + ("Access-Control-Allow-Headers", "Relay-Authorization, Content-Type"), + ("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) From b50663a792821ffba942c2478ea067dbb49e83f3 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 27 Mar 2026 15:13:11 -0400 Subject: [PATCH 03/12] feat(android): add media upload server and delegate Add MediaUploadServer backed by the HttpServer library, which handles TCP binding, HTTP parsing, bearer token auth, and connection management. The upload server provides a thin handler that routes file uploads through a native delegate pipeline for processing (e.g. image resize, video transcode) before uploading to WordPress. - MediaUploadDelegate interface with processFile and uploadFile hooks - DefaultMediaUploader for WordPress REST API uploads with namespace support - GutenbergView integration with synchronous server lifecycle - GBKitGlobal nativeUploadPort/nativeUploadToken injection - org.json test dependency added Co-Authored-By: Claude Opus 4.6 (1M context) --- android/Gutenberg/build.gradle.kts | 1 + .../org/wordpress/gutenberg/GutenbergView.kt | 49 ++- .../wordpress/gutenberg/MediaUploadServer.kt | 311 ++++++++++++++++++ .../wordpress/gutenberg/model/GBKitGlobal.kt | 12 +- android/gradle/libs.versions.toml | 2 + 5 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt 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..858b4e14a 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,24 @@ class GutenbergView : WebView { var requestInterceptor: GutenbergRequestInterceptor = DefaultGutenbergRequestInterceptor() + /** Optional delegate for customizing media upload behavior (resize, transcode, custom upload). */ + var mediaUploadDelegate: MediaUploadDelegate? = null + set(value) { + 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 +459,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 +475,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 +922,8 @@ class GutenbergView : WebView { override fun onDetachedFromWindow() { super.onDetachedFromWindow() stopNetworkMonitoring() + uploadServer?.stop() + uploadServer = null clearConfig() this.stopLoading() FileCache.clearCache(context) @@ -944,6 +989,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..4d0967724 --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt @@ -0,0 +1,311 @@ +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 = mapOf( + "Access-Control-Allow-Origin" to "*", + "Access-Control-Allow-Methods" to "POST, OPTIONS", + "Access-Control-Allow-Headers" to "Relay-Authorization, Content-Type", + "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) { + throw MediaUploadException( + "Upload failed (${response.code}): ${body ?: response.message}" + ) + } + + 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/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" } From 7d30f4c1aa984a11c288c39b8d77de194951b4ec Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 27 Mar 2026 15:13:31 -0400 Subject: [PATCH 04/12] feat: add image resize delegate to iOS and Android demo apps Add DemoMediaUploadDelegate implementations that resize images to a maximum dimension of 2000px before upload. Includes a toggle in the site preparation screen to enable/disable native media upload processing. Co-Authored-By: Claude Opus 4.6 (1M context) --- android/app/detekt-baseline.xml | 5 +- .../gutenbergkit/DemoMediaUploadDelegate.kt | 64 ++++++++++++++++ .../example/gutenbergkit/EditorActivity.kt | 8 ++ .../gutenbergkit/SitePreparationActivity.kt | 31 ++++++-- .../gutenbergkit/SitePreparationViewModel.kt | 5 ++ ios/Demo-iOS/Sources/ConfigurationItem.swift | 1 + ios/Demo-iOS/Sources/GutenbergApp.swift | 2 +- ios/Demo-iOS/Sources/Views/EditorView.swift | 75 ++++++++++++++++++- .../Sources/Views/SitePreparationView.swift | 6 +- 9 files changed, 186 insertions(+), 11 deletions(-) create mode 100644 android/app/src/main/java/com/example/gutenbergkit/DemoMediaUploadDelegate.kt 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/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) From 82ce007d114148ec82ca405214f1a0c8c05bb9d7 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 27 Mar 2026 15:13:51 -0400 Subject: [PATCH 05/12] test(ios): add MediaUploadServer tests Integration tests covering server lifecycle, bearer token auth (407 on missing/wrong token), CORS preflight, routing (404 for unknown paths), delegate processing pipeline, and fallback to default uploader. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Media/MediaUploadServerTests.swift | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift 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)!) + } +} From a638cb90ef9fb76f06c26de4c26fa6e84f24fdc8 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 27 Mar 2026 15:14:00 -0400 Subject: [PATCH 06/12] test(android): add MediaUploadServer tests Integration tests covering server lifecycle, bearer token auth (407 on missing/wrong token), CORS preflight, routing (404 for unknown paths), delegate processing pipeline, fallback to default uploader, DefaultMediaUploader request format, and error handling for bad requests and non-multipart content types. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../gutenberg/MediaUploadServerTest.kt | 402 ++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt 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..8f01a2070 --- /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("500")) + } + + 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" + ) + } + } + +} From 18f81b7cf5e38eaf4d924cb823d4614ab3a41fe7 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 27 Mar 2026 15:26:15 -0400 Subject: [PATCH 07/12] test(js): add nativeMediaUploadMiddleware tests Tests covering passthrough behavior (missing port, non-POST, non-media paths, sub-paths, non-FormData), upload interception with Relay-Authorization auth, response transformation to WordPress REST API shape, error handling (413 file too large, generic failures), and abort signal forwarding. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/utils/api-fetch-upload-middleware.test.js | 379 ++++++++++++++++++ 1 file changed, 379 insertions(+) create mode 100644 src/utils/api-fetch-upload-middleware.test.js 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..695db3b2f --- /dev/null +++ b/src/utils/api-fetch-upload-middleware.test.js @@ -0,0 +1,379 @@ +/** + * 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 user-friendly error on 413 response', async () => { + getGBKit.mockReturnValue( { + nativeUploadPort: 8080, + nativeUploadToken: 'token', + } ); + + global.fetch = vi.fn( () => + Promise.resolve( { + ok: false, + status: 413, + statusText: 'Payload Too Large', + text: () => + Promise.resolve( 'Upload exceeds maximum allowed size' ), + } ) + ); + + await expect( + nativeMediaUploadMiddleware( + makePostMediaOptions( makeFile() ), + makeNext() + ) + ).rejects.toMatchObject( { + code: 'upload_file_too_large', + message: expect.stringContaining( 'too large' ), + } ); + } ); + + 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 + ); + } ); +} ); From 8c78c1a9ab6ef51ce4b1750f08ab7d591cb1713e Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 27 Mar 2026 15:55:04 -0400 Subject: [PATCH 08/12] fix: surface server error messages in upload failure snackbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add LocalizedError conformance to EditorHTTPClient.ClientError so that WordPress error messages (e.g. "This file is too large. The maximum upload size is 10 KB.") are surfaced to the user instead of a cryptic Swift type description. Remove the dead 413-specific handling from the JS middleware — the HTTP library rejects oversized uploads at the connection level (never producing an HTTP response the browser can read), so the 413 branch was unreachable. All upload errors now go through the generic path which includes the server's error message. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Sources/EditorHTTPClient.swift | 10 ++++++- src/utils/api-fetch-upload-middleware.test.js | 27 ------------------- src/utils/api-fetch.js | 17 +++++------- 3 files changed, 15 insertions(+), 39 deletions(-) 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/src/utils/api-fetch-upload-middleware.test.js b/src/utils/api-fetch-upload-middleware.test.js index 695db3b2f..952a5baa8 100644 --- a/src/utils/api-fetch-upload-middleware.test.js +++ b/src/utils/api-fetch-upload-middleware.test.js @@ -273,33 +273,6 @@ describe( 'nativeMediaUploadMiddleware', () => { // MARK: - Error handling - it( 'throws user-friendly error on 413 response', async () => { - getGBKit.mockReturnValue( { - nativeUploadPort: 8080, - nativeUploadToken: 'token', - } ); - - global.fetch = vi.fn( () => - Promise.resolve( { - ok: false, - status: 413, - statusText: 'Payload Too Large', - text: () => - Promise.resolve( 'Upload exceeds maximum allowed size' ), - } ) - ); - - await expect( - nativeMediaUploadMiddleware( - makePostMediaOptions( makeFile() ), - makeNext() - ) - ).rejects.toMatchObject( { - code: 'upload_file_too_large', - message: expect.stringContaining( 'too large' ), - } ); - } ); - it( 'throws on non-ok response from local server', async () => { getGBKit.mockReturnValue( { nativeUploadPort: 8080, diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js index dbacd6181..2c55447cd 100644 --- a/src/utils/api-fetch.js +++ b/src/utils/api-fetch.js @@ -201,17 +201,12 @@ export function nativeMediaUploadMiddleware( options, next ) { .then( ( response ) => { if ( ! response.ok ) { return response.text().then( ( body ) => { - const message = - response.status === 413 - ? `The file is too large to upload. Please choose a smaller file.` - : `Native upload failed (${ response.status }): ${ - body || response.statusText - }`; - const error = new Error( message ); - error.code = - response.status === 413 - ? 'upload_file_too_large' - : 'upload_failed'; + const error = new Error( + `Native upload failed (${ response.status }): ${ + body || response.statusText + }` + ); + error.code = 'upload_failed'; throw error; } ); } From a3ff56cee774b34b8a9aa92b1659c02ffbe00c1a Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 27 Mar 2026 16:04:00 -0400 Subject: [PATCH 09/12] fix(android): extract human-readable message from WordPress error responses Parse the WordPress JSON error body to extract the message field (e.g. "This file is too large. The maximum upload size is 10 KB.") instead of showing the raw JSON in the upload failure snackbar. Falls back to the raw body for non-JSON error responses. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/org/wordpress/gutenberg/MediaUploadServer.kt | 10 +++++++--- .../org/wordpress/gutenberg/MediaUploadServerTest.kt | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) 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 4d0967724..8bc953de0 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt @@ -277,9 +277,13 @@ internal open class DefaultMediaUploader( val body = response.body?.string() if (!response.isSuccessful) { - throw MediaUploadException( - "Upload failed (${response.code}): ${body ?: response.message}" - ) + // 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) { 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 8f01a2070..a7f4f16d7 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt @@ -230,7 +230,7 @@ class MediaUploadServerTest { runBlocking { uploader.upload(file, "image/jpeg", "fail.jpg") } throw AssertionError("Expected exception") } catch (e: MediaUploadException) { - assertTrue(e.message!!.contains("500")) + assertTrue(e.message!!.contains("Internal error")) } mockWpServer.shutdown() From c98d0685564e424f82010af949e82bf6e991bcdd Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 27 Mar 2026 16:30:48 -0400 Subject: [PATCH 10/12] refactor: deduplicate CORS headers in upload server responses Compose corsPreflightResponse() from the shared corsHeaders constant instead of re-declaring origin and allowed-headers values. Also removes a redundant "what" comment on the multipart parsing call. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../main/java/org/wordpress/gutenberg/MediaUploadServer.kt | 4 +--- .../GutenbergKit/Sources/Media/MediaUploadServer.swift | 5 +---- 2 files changed, 2 insertions(+), 7 deletions(-) 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 8bc953de0..badb4d803 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt @@ -197,10 +197,8 @@ internal class MediaUploadServer( private fun corsPreflightResponse(): HttpResponse = HttpResponse( status = 204, - headers = mapOf( - "Access-Control-Allow-Origin" to "*", + headers = corsHeaders + mapOf( "Access-Control-Allow-Methods" to "POST, OPTIONS", - "Access-Control-Allow-Headers" to "Relay-Authorization, Content-Type", "Access-Control-Max-Age" to "86400" ), body = ByteArray(0) diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift index 9741cff42..c4ef144b8 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -75,7 +75,6 @@ final class MediaUploadServer: Sendable { } private static func handleUpload(_ request: HTTPServer.Request, context: UploadContext) async -> HTTPResponse { - // Parse multipart form-data using the library's RFC 7578 parser. let parts: [MultipartPart] do { parts = try request.parsed.multipartParts() @@ -181,10 +180,8 @@ final class MediaUploadServer: Sendable { private static func corsPreflightResponse() -> HTTPResponse { HTTPResponse( status: 204, - headers: [ - ("Access-Control-Allow-Origin", "*"), + headers: corsHeaders + [ ("Access-Control-Allow-Methods", "POST, OPTIONS"), - ("Access-Control-Allow-Headers", "Relay-Authorization, Content-Type"), ("Access-Control-Max-Age", "86400"), ], body: Data() From 6192fe144f6765f33994d47f2b212d2897029d2d Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 27 Mar 2026 16:30:58 -0400 Subject: [PATCH 11/12] fix(android): skip upload server restart on redundant delegate assignment Guard the mediaUploadDelegate setter with an identity check so that assigning the same delegate instance does not needlessly stop and restart the upload server. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/main/java/org/wordpress/gutenberg/GutenbergView.kt | 1 + 1 file changed, 1 insertion(+) 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 858b4e14a..bac7e053c 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -108,6 +108,7 @@ class GutenbergView : WebView { /** 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() From 60234e04e9f9f4a1856329819bce893bea2b8cab Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 9 Apr 2026 15:17:11 -0400 Subject: [PATCH 12/12] fix: improve upload server error handling and memory efficiency (#419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Avoid "native" term in user-facing errors messages The term "native" is likely unfamiliar to users, provides no tangible value, and may cause confusion. * fix: drain oversized request body before sending 413 response When Content-Length exceeds maxBodySize, the server now reads and discards the full request body before responding with 413. This ensures the client (WebView fetch) receives the error response cleanly instead of a connection reset (RFC 9110 §15.5.14). Adds a `.draining` parser state that tracks consumed bytes without buffering them, keeping memory and disk usage at zero for rejected uploads. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: include CORS headers on server-generated error responses Add an errorResponseHeaders parameter to HTTPServer (iOS) and HttpServer (Android) so that callers can specify headers to include on all server-generated error responses (413, 407, 408, etc.). MediaUploadServer passes its CORS headers through this parameter so the browser does not block error responses due to missing Access-Control-Allow-Origin. Co-Authored-By: Claude Opus 4.6 (1M context) * Revert "fix: include CORS headers on server-generated error responses" This reverts commit 0b6c67be77c1b9b5adb0eda8e5c37d1dca46c0d8. * fix: route 413 response through handler for CORS headers Instead of the HTTP server library building 413 responses directly (which lacked CORS headers), payloadTooLarge is now treated as a non-fatal parse error. parseRequest() returns the parsed headers as a partial request and exposes the error via a new parseError property. The server passes it to the handler via a serverError field on the request, letting MediaUploadServer build the response with CORS headers — consistent with how OPTIONS preflight is handled. Co-Authored-By: Claude Opus 4.6 (1M context) * perf(ios): stream multipart upload body from disk instead of memory Replace Data(contentsOf:) + httpBody with a streaming InputStream via httpBodyStream in DefaultMediaUploader. The multipart body (preamble, file content, epilogue) is written through a bound stream pair on a background thread, keeping peak memory at ~65 KB regardless of file size — down from ~2x file size previously. Android already streams via OkHttp's file.asRequestBody() and needs no changes. Co-Authored-By: Claude Opus 4.6 (1M context) * perf: passthrough upload when delegate does not modify the file When the delegate's processFile returns the original file unchanged (e.g., GIFs, non-images, files already within size limits), the original request body is forwarded directly to WordPress — skipping multipart re-encoding and the extra file read. Detection: after processFile, compare the returned URL/File to the input. If unchanged and uploadFile returns nil, signal passthrough back to handleUpload which streams the original body via passthroughUpload(). Also extracts shared response parsing into performUpload() on both platforms to avoid duplication between upload() and passthroughUpload(). Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(android): fix Detekt lint violations Extract readUntil() helper from HttpServer.handleRequest() to reduce nesting depth and throw count. Extract performPassthroughUpload() from MediaUploadServer.processAndRespond() to consolidate throw statements. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(ios): prevent integer overflow in drain mode byte tracking Cast `bytesWritten` and `offset` to Int64 individually before subtracting, avoiding a potential Int overflow when the difference is computed before the widening cast. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(ios): replace deprecated URL.path with path(percentEncoded:) URL.path is deprecated on iOS 16+. Use path(percentEncoded: false) to get the file system path without percent-encoding. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(ios): localize "file too large" error via EditorLocalization Add a `fileTooLarge` case to `EditorLocalizableString` so host apps can provide translations for the 413 error message. The hardcoded string in MediaUploadServer now reads from the localization system. Co-Authored-By: Claude Opus 4.6 (1M context) * Revert "refactor(ios): localize "file too large" error via EditorLocalization" This reverts commit 71440d97016f8f0a694b501b0a7612e9c4c9cb07. * fix(ios): review adjustments for #419 (#441) * fix(ios): use Int64 for HTTPRequestParser.bytesWritten Change `bytesWritten` from `Int` to `Int64` for consistency with `expectedContentLength` and `maxBodySize`, which are already `Int64`. * test(ios): add end-to-end test for 413 response with CORS headers Expose maxRequestBodySize on MediaUploadServer.start() and add an integration test that sends an oversized request and verifies the response includes both a 413 status and CORS headers. * fix(test): increase maxRequestBodySize for 413 test The multipart overhead (~191 bytes) plus auth headers meant the previous limit of 100 bytes caused the connection to reset before the drain could complete. Use 1024 bytes with a 2048-byte payload so the parser can drain the body and deliver the 413 response. * fix(test): use raw TCP for 413 test to avoid URLSession connection reset URLSession treats a server response during upload as a connection error (NSURLErrorNetworkConnectionLost). Use a raw NWConnection to send the request and read the response directly, which correctly receives the 413 with CORS headers. * fix: complete drain immediately when body arrives with headers When the entire HTTP request (headers + body) arrives in a single read, the parser enters DRAINING but never completes because the body bytes were already counted in bytesWritten. Subsequent reads find no more data, causing a timeout. Check the drain condition immediately when entering the draining state, transitioning to complete if all body bytes have already been received. Fixes both iOS and Android parsers. --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> --- .../http/InstrumentedFixtureTests.kt | 13 +- .../org/wordpress/gutenberg/HttpServer.kt | 71 +++++-- .../wordpress/gutenberg/MediaUploadServer.kt | 111 ++++++++-- .../gutenberg/http/HTTPRequestParser.kt | 51 ++++- .../gutenberg/MediaUploadServerTest.kt | 32 ++- .../wordpress/gutenberg/http/FixtureTests.kt | 13 +- .../gutenberg/http/HTTPRequestParserTests.kt | 63 ++++++ .../Sources/Media/MediaUploadServer.swift | 199 ++++++++++++++++-- .../GutenbergKitHTTP/HTTPRequestParser.swift | 70 ++++-- ios/Sources/GutenbergKitHTTP/HTTPServer.swift | 25 ++- .../GutenbergKitHTTPTests/FixtureTests.swift | 9 +- .../HTTPRequestParserTests.swift | 63 +++++- .../Media/MediaUploadServerTests.swift | 118 ++++++++++- src/utils/api-fetch.js | 2 +- 14 files changed, 741 insertions(+), 99 deletions(-) diff --git a/android/Gutenberg/src/androidTest/java/org/wordpress/gutenberg/http/InstrumentedFixtureTests.kt b/android/Gutenberg/src/androidTest/java/org/wordpress/gutenberg/http/InstrumentedFixtureTests.kt index 86f4b2a36..0c69a115f 100644 --- a/android/Gutenberg/src/androidTest/java/org/wordpress/gutenberg/http/InstrumentedFixtureTests.kt +++ b/android/Gutenberg/src/androidTest/java/org/wordpress/gutenberg/http/InstrumentedFixtureTests.kt @@ -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, diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/HttpServer.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/HttpServer.kt index fcc98d655..190f4cde7 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/HttpServer.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/HttpServer.kt @@ -39,7 +39,11 @@ data class HttpRequest( val target: String, val headers: Map, val body: org.wordpress.gutenberg.http.RequestBody? = null, - val parseDurationMs: Double = 0.0 + val parseDurationMs: Double = 0.0, + /** A server-detected error that occurred after headers were parsed + * (e.g., payload too large). When set, the handler is responsible + * for building an appropriate error response. */ + val serverError: org.wordpress.gutenberg.http.HTTPRequestParseError? = null ) { /** * Returns the value of the first header matching the given name (case-insensitive). @@ -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). @@ -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 @@ -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 { @@ -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)) 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 badb4d803..c264644bc 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt @@ -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. @@ -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) + } + // CORS preflight — the library exempts OPTIONS from auth, so this is // reached without a token. if (request.method.uppercase() == "OPTIONS") { @@ -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? { @@ -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) @@ -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 { + ): 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 @@ -255,6 +305,13 @@ internal open class DefaultMediaUploader( private val authHeader: String, private val siteApiNamespace: List = 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() @@ -262,15 +319,43 @@ internal open class DefaultMediaUploader( .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() diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/http/HTTPRequestParser.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/http/HTTPRequestParser.kt index 20e46a758..0157386e4 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/http/HTTPRequestParser.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/http/HTTPRequestParser.kt @@ -37,12 +37,18 @@ class HTTPRequestParser( NEEDS_MORE_DATA, /** Headers have been fully received but the body is still incomplete. */ HEADERS_COMPLETE, + /** + * The request body exceeds the maximum allowed size and is being + * drained (read and discarded) so the server can send a clean 413 + * response. No body bytes are buffered in this state. + */ + DRAINING, /** All data has been received (headers and body). */ COMPLETE; /** Whether headers have been fully received. */ val hasHeaders: Boolean - get() = this == HEADERS_COMPLETE || this == COMPLETE + get() = this == HEADERS_COMPLETE || this == DRAINING || this == COMPLETE /** Whether all data has been received. */ val isComplete: Boolean @@ -76,6 +82,15 @@ class HTTPRequestParser( /** The current buffering state. */ val state: State get() = synchronized(lock) { _state } + /** + * The parse error detected during buffering, if any. + * + * Non-fatal errors like [HTTPRequestParseError.PAYLOAD_TOO_LARGE] are + * exposed here instead of being thrown by [parseRequest], allowing the + * caller to still access the parsed headers. + */ + val pendingParseError: HTTPRequestParseError? get() = synchronized(lock) { parseError } + /** Creates a parser and immediately parses the given raw HTTP string. */ constructor( input: String, @@ -107,6 +122,14 @@ class HTTPRequestParser( fun append(data: ByteArray): Unit = synchronized(lock) { if (_state == State.COMPLETE) return + // In drain mode, discard bytes without buffering and check + // whether the full Content-Length has been consumed. + if (_state == State.DRAINING) { + bytesWritten += data.size.toLong() + drainIfComplete() + return + } + val accepted: Boolean try { accepted = buffer.append(data) @@ -166,7 +189,11 @@ class HTTPRequestParser( if (expectedContentLength > maxBodySize) { parseError = HTTPRequestParseError.PAYLOAD_TOO_LARGE - _state = State.COMPLETE + _state = State.DRAINING + // Complete immediately if body bytes already received + // satisfy the drain — small requests may arrive as a + // single read. + drainIfComplete() return } } @@ -181,6 +208,14 @@ class HTTPRequestParser( } } + /** Transitions from DRAINING to COMPLETE if all body bytes have been received. */ + private fun drainIfComplete() { + val offset = headerEndOffset ?: return + if (bytesWritten - offset >= expectedContentLength) { + _state = State.COMPLETE + } + } + /** * Parses the buffered data into a structured HTTP request. * @@ -194,7 +229,11 @@ class HTTPRequestParser( fun parseRequest(): ParsedHTTPRequest? = synchronized(lock) { if (!_state.hasHeaders) return null - parseError?.let { throw HTTPRequestParseException(it) } + // Payload-too-large means "valid headers, rejected body" — let + // the caller access the parsed headers so the handler can build + // a response (e.g., with CORS headers). Other parse errors + // indicate genuinely malformed requests and are still thrown. + parseError?.let { if (it != HTTPRequestParseError.PAYLOAD_TOO_LARGE) throw HTTPRequestParseException(it) } if (parsedHeaders == null) { val headerData = buffer.read(0, minOf(bytesWritten, MAX_HEADER_SIZE.toLong()).toInt()) @@ -210,7 +249,11 @@ class HTTPRequestParser( val headers = parsedHeaders ?: return null - if (_state != State.COMPLETE) { + // Return partial (headers only) when the body was rejected or + // hasn't fully arrived yet. The payloadTooLarge case goes through + // drain mode which discards body bytes without buffering them, so + // there is no body to extract even though the state is COMPLETE. + if (_state != State.COMPLETE || parseError != null) { return ParsedHTTPRequest( method = headers.method, target = headers.target, diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt index a7f4f16d7..86ad0c51d 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt @@ -6,6 +6,7 @@ import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule @@ -135,7 +136,7 @@ class MediaUploadServerTest { // MARK: - Fallback to default uploader @Test - fun `falls back to default uploader when delegate returns nil for uploadFile`() { + fun `uses passthrough when delegate does not modify file`() { val delegate = ProcessOnlyDelegate() val mockUploader = MockDefaultUploader() @@ -157,7 +158,9 @@ class MediaUploadServerTest { assertTrue("Expected 200 but got: ${response.statusLine}", response.statusLine.contains("200")) assertTrue(delegate.processFileCalled) - assertTrue(mockUploader.uploadCalled) + // Passthrough: original body forwarded directly, not re-encoded. + assertTrue(mockUploader.passthroughUploadCalled) + assertFalse(mockUploader.uploadCalled) val json = JsonParser.parseString(response.body).asJsonObject assertEquals(99, json.get("id").asInt) @@ -386,17 +389,28 @@ class MediaUploadServerTest { authHeader = "Bearer mock" ) { @Volatile var uploadCalled = false + @Volatile var passthroughUploadCalled = 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" - ) + return mockResult() + } + + override suspend fun passthroughUpload( + body: org.wordpress.gutenberg.http.RequestBody, + contentType: String + ): MediaUploadResult { + passthroughUploadCalled = true + return mockResult() } + + private fun mockResult() = MediaUploadResult( + id = 99, + url = "https://example.com/doc.pdf", + title = "doc", + mime = "application/pdf", + type = "file" + ) } } diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/http/FixtureTests.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/http/FixtureTests.kt index a08e4d373..012ba42a3 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/http/FixtureTests.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/http/FixtureTests.kt @@ -149,7 +149,18 @@ class FixtureTests { try { parser.parseRequest() - fail("$description: expected error $expectedError but parsing succeeded") + // Non-fatal errors (e.g., payloadTooLarge) are exposed via + // pendingParseError instead of being thrown. + val pendingError = parser.pendingParseError + if (pendingError != null) { + assertEquals( + expectedError, + pendingError.errorId, + "$description: expected $expectedError but got ${pendingError.errorId}" + ) + } else { + fail("$description: expected error $expectedError but parsing succeeded") + } } catch (e: HTTPRequestParseException) { assertEquals( expectedError, diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/http/HTTPRequestParserTests.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/http/HTTPRequestParserTests.kt index 93bdefa35..abdc9c2e1 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/http/HTTPRequestParserTests.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/http/HTTPRequestParserTests.kt @@ -97,6 +97,69 @@ class HTTPRequestParserTests { assertArrayEquals(body.toByteArray(), request.body?.readBytes()) } + @Test + fun `drains oversized body and returns partial with parseError`() { + val parser = HTTPRequestParser(maxBodySize = 100) + parser.append("POST /upload HTTP/1.1\r\nHost: localhost\r\nContent-Length: 101\r\n\r\n".toByteArray()) + + // Parser enters drain mode — not yet complete. + assertEquals(HTTPRequestParser.State.DRAINING, parser.state) + + // Feed the remaining body bytes to complete the drain. + parser.append(ByteArray(101) { 0x41 }) + assertTrue(parser.state.isComplete) + + // parseRequest() returns partial headers instead of throwing. + val request = parser.parseRequest()!! + assertEquals("POST", request.method) + assertEquals("/upload", request.target) + assertFalse(request.isComplete) + assertEquals(HTTPRequestParseError.PAYLOAD_TOO_LARGE, parser.pendingParseError) + } + + @Test + fun `enters drain mode for oversized Content-Length even when body has not arrived`() { + val parser = HTTPRequestParser(maxBodySize = 50) + parser.append("POST /upload HTTP/1.1\r\nHost: localhost\r\nContent-Length: 999999\r\n\r\n".toByteArray()) + + // Parser enters drain mode — headers are available but not yet complete. + assertEquals(HTTPRequestParser.State.DRAINING, parser.state) + assertTrue(parser.state.hasHeaders) + assertFalse(parser.state.isComplete) + + // Feed body bytes in chunks to complete the drain. + val chunkSize = 8192 + var remaining = 999999 + while (remaining > 0) { + val size = minOf(chunkSize, remaining) + parser.append(ByteArray(size) { 0x42 }) + remaining -= size + } + + assertTrue(parser.state.isComplete) + val request = parser.parseRequest()!! + assertEquals("POST", request.method) + assertFalse(request.isComplete) + assertEquals(HTTPRequestParseError.PAYLOAD_TOO_LARGE, parser.pendingParseError) + } + + @Test + fun `drain mode does not buffer body bytes`() { + val parser = HTTPRequestParser(maxBodySize = 10) + parser.append("POST /upload HTTP/1.1\r\nHost: localhost\r\nContent-Length: 1000\r\n\r\n".toByteArray()) + assertEquals(HTTPRequestParser.State.DRAINING, parser.state) + + // Feed 1000 bytes of body data. + parser.append(ByteArray(1000) { 0x43 }) + assertTrue(parser.state.isComplete) + + // parseRequest() returns headers; error is on pendingParseError. + val request = parser.parseRequest()!! + assertEquals("POST", request.method) + assertFalse(request.isComplete) + assertEquals(HTTPRequestParseError.PAYLOAD_TOO_LARGE, parser.pendingParseError) + } + // MARK: - Error HTTP Status Mapping @Test diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift index c4ef144b8..c06a0fa79 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -27,15 +27,19 @@ final class MediaUploadServer: Sendable { /// - Parameters: /// - uploadDelegate: Optional delegate for customizing file processing and upload. /// - defaultUploader: Fallback uploader used when no delegate provides `uploadFile`. + /// - maxRequestBodySize: The maximum allowed request body size in bytes. + /// Requests exceeding this limit receive a 413 response. Defaults to 4 GB. static func start( uploadDelegate: (any MediaUploadDelegate)? = nil, - defaultUploader: DefaultMediaUploader? = nil + defaultUploader: DefaultMediaUploader? = nil, + maxRequestBodySize: Int64 = HTTPRequestParser.defaultMaxBodySize ) async throws -> MediaUploadServer { let context = UploadContext(uploadDelegate: uploadDelegate, defaultUploader: defaultUploader) let server = try await HTTPServer.start( name: "media-upload", requiresAuthentication: true, + maxRequestBodySize: maxRequestBodySize, handler: { request in await Self.handleRequest(request, context: context) } @@ -60,6 +64,16 @@ final class MediaUploadServer: Sendable { private static func handleRequest(_ request: HTTPServer.Request, context: UploadContext) async -> HTTPResponse { let parsed = request.parsed + // Server-detected error (e.g., payload too large) — build the + // error response here so it includes CORS headers. + if let serverError = request.serverError { + let message: String = switch serverError { + case .payloadTooLarge: "The file is too large to upload in the editor." + default: "\(serverError.httpStatusText)" + } + return errorResponse(status: serverError.httpStatus, body: message) + } + // CORS preflight — the library exempts OPTIONS from auth, so this is // reached without a token. if parsed.method.uppercased() == "OPTIONS" { @@ -113,11 +127,27 @@ final class MediaUploadServer: Sendable { let result: Result var processedURL: URL? do { - let (media, processed) = try await processAndUpload( + let uploadResult = try await processAndUpload( fileURL: fileURL, mimeType: mimeType, filename: filePart.filename ?? "upload", context: context ) - processedURL = processed - result = .success(media) + switch uploadResult { + case .uploaded(let media, let processed): + processedURL = processed + Logger.uploadServer.debug("Uploading processed file to WordPress") + result = .success(media) + case .passthrough: + // Delegate didn't modify the file — forward the original + // request body to WordPress without re-encoding. + Logger.uploadServer.debug("Passthrough: forwarding original request body to WordPress") + guard let body = request.parsed.body, + let contentType = request.parsed.header("Content-Type"), + let defaultUploader = context.defaultUploader else { + result = .failure(UploadError.noUploader) + break + } + let media = try await defaultUploader.passthroughUpload(body: body, contentType: contentType) + result = .success(media) + } } catch { result = .failure(error) } @@ -148,9 +178,18 @@ final class MediaUploadServer: Sendable { // MARK: - Delegate Pipeline + /// Result of the delegate processing + upload pipeline. + private enum UploadResult { + /// The delegate (or default uploader) completed the upload. + case uploaded(MediaUploadResult, processedURL: URL) + /// The delegate didn't modify the file and `uploadFile` returned nil. + /// The caller should forward the original request body to WordPress. + case passthrough + } + private static func processAndUpload( fileURL: URL, mimeType: String, filename: String, context: UploadContext - ) async throws -> (MediaUploadResult, URL) { + ) async throws -> UploadResult { // Step 1: Process (resize, transcode, etc.) let processedURL: URL if let delegate = context.uploadDelegate { @@ -162,9 +201,15 @@ final class MediaUploadServer: Sendable { // 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) + return .uploaded(result, processedURL: processedURL) } else if let defaultUploader = context.defaultUploader { - return (try await defaultUploader.upload(fileURL: processedURL, mimeType: mimeType, filename: filename), processedURL) + // If the delegate didn't modify the file, the original request + // body can be forwarded directly — skip multipart re-encoding. + if processedURL == fileURL { + return .passthrough + } + let result = try await defaultUploader.upload(fileURL: processedURL, mimeType: mimeType, filename: filename) + return .uploaded(result, processedURL: processedURL) } else { throw UploadError.noUploader } @@ -281,30 +326,47 @@ class DefaultMediaUploader: @unchecked Sendable { 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. + /// The WordPress media endpoint URL, accounting for site API namespaces. + private var mediaEndpointURL: URL { let mediaPath = if let siteApiNamespace { "wp/v2/\(siteApiNamespace)media" } else { "wp/v2/media" } - let uploadURL = siteApiRoot.appending(path: mediaPath) - var request = URLRequest(url: uploadURL) + return siteApiRoot.appending(path: mediaPath) + } + + func upload(fileURL: URL, mimeType: String, filename: String) async throws -> MediaUploadResult { + let boundary = UUID().uuidString + + let (bodyStream, contentLength) = try Self.multipartBodyStream( + fileURL: fileURL, boundary: boundary, filename: filename, mimeType: mimeType + ) + + var request = URLRequest(url: mediaEndpointURL) request.httpMethod = "POST" request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") - request.httpBody = body + request.setValue("\(contentLength)", forHTTPHeaderField: "Content-Length") + request.httpBodyStream = bodyStream + + return try await performUpload(request) + } + /// Forwards the original request body to WordPress without re-encoding. + /// + /// Used when the delegate's `processFile` returned the file unchanged — + /// the incoming multipart body is already valid for WordPress. + func passthroughUpload(body: RequestBody, contentType: String) async throws -> MediaUploadResult { + var request = URLRequest(url: mediaEndpointURL) + request.httpMethod = "POST" + request.setValue(contentType, forHTTPHeaderField: "Content-Type") + request.setValue("\(body.count)", forHTTPHeaderField: "Content-Length") + request.httpBodyStream = try body.makeInputStream() + + return try await performUpload(request) + } + + private func performUpload(_ request: URLRequest) async throws -> MediaUploadResult { let (data, response) = try await httpClient.perform(request) guard (200...299).contains(response.statusCode) else { @@ -333,6 +395,92 @@ class DefaultMediaUploader: @unchecked Sendable { height: wpMedia.media_details?.height ) } + + // MARK: - Streaming Multipart Body + + /// Builds a multipart/form-data body as an `InputStream` that streams the + /// file from disk without loading it into memory. + /// + /// Uses a bound stream pair with a background writer thread — the same + /// pattern as `RequestBody.makePipedFileSliceStream`. + /// + /// - Returns: A tuple of the input stream and the total content length. + static func multipartBodyStream( + fileURL: URL, + boundary: String, + filename: String, + mimeType: String + ) throws -> (InputStream, Int) { + let preamble = Data( + ("--\(boundary)\r\n" + + "Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n" + + "Content-Type: \(mimeType)\r\n\r\n").utf8 + ) + let epilogue = Data("\r\n--\(boundary)--\r\n".utf8) + + guard let fileSize = try FileManager.default.attributesOfItem(atPath: fileURL.path(percentEncoded: false))[.size] as? Int else { + throw MediaUploadError.streamReadFailed + } + let contentLength = preamble.count + fileSize + epilogue.count + + let fileHandle = try FileHandle(forReadingFrom: fileURL) + + var readStream: InputStream? + var writeStream: OutputStream? + Stream.getBoundStreams(withBufferSize: 65_536, inputStream: &readStream, outputStream: &writeStream) + + guard let inputStream = readStream, let outputStream = writeStream else { + try? fileHandle.close() + throw MediaUploadError.streamReadFailed + } + + outputStream.open() + + // OutputStream is not Sendable but is safely transferred to the + // writer thread — only the thread accesses it after this point. + nonisolated(unsafe) let output = outputStream + + Thread.detachNewThread { + defer { + output.close() + try? fileHandle.close() + } + + // Write preamble (multipart headers). + guard Self.writeAll(preamble, to: output) else { return } + + // Stream file content in chunks. + var remaining = fileSize + while remaining > 0 { + let chunkSize = min(65_536, remaining) + guard let chunk = try? fileHandle.read(upToCount: chunkSize), + !chunk.isEmpty else { + break + } + guard Self.writeAll(chunk, to: output) else { return } + remaining -= chunk.count + } + + // Write epilogue (closing boundary). + _ = Self.writeAll(epilogue, to: output) + } + + return (inputStream, contentLength) + } + + /// Writes all bytes of `data` to the output stream, handling partial writes. + private static func writeAll(_ data: Data, to output: OutputStream) -> Bool { + data.withUnsafeBytes { buffer in + guard let base = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return false } + var written = 0 + while written < data.count { + let result = output.write(base.advanced(by: written), maxLength: data.count - written) + if result <= 0 { return false } + written += result + } + return true + } + } } /// WordPress REST API media response (subset of fields). @@ -364,12 +512,17 @@ enum MediaUploadError: Error, LocalizedError { /// The WordPress REST API returned a non-JSON response (e.g. HTML error page). case unexpectedResponse(preview: String, underlyingError: Error) + /// Failed to read the file for streaming upload. + case streamReadFailed + 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)" + case .streamReadFailed: + return "Failed to read file for upload" } } } diff --git a/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift b/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift index 293c92773..07f188ea3 100644 --- a/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift +++ b/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift @@ -27,6 +27,10 @@ public final class HTTPRequestParser: @unchecked Sendable { case needsMoreData /// Headers have been fully received but the body is still incomplete. case headersComplete + /// The request body exceeds the maximum allowed size and is being + /// drained (read and discarded) so the server can send a clean 413 + /// response. No body bytes are buffered in this state. + case draining /// All data has been received (headers and body). case complete } @@ -46,7 +50,7 @@ public final class HTTPRequestParser: @unchecked Sendable { private var buffer: Buffer private let maxBodySize: Int64 private let inMemoryBodyThreshold: Int - private var bytesWritten: Int = 0 + private var bytesWritten: Int64 = 0 private var _state: State = .needsMoreData // Lightweight scan results (populated by append) @@ -103,6 +107,15 @@ public final class HTTPRequestParser: @unchecked Sendable { lock.withLock { _state } } + /// The parse error detected during buffering, if any. + /// + /// Non-fatal errors like ``HTTPRequestParseError/payloadTooLarge`` are + /// exposed here instead of being thrown by ``parseRequest()``, allowing + /// the caller to still access the parsed headers. + public var parseError: HTTPRequestParseError? { + lock.withLock { _parseError } + } + /// The expected body length from `Content-Length`, available once headers have been received. public var expectedBodyLength: Int64? { lock.withLock { @@ -124,12 +137,16 @@ public final class HTTPRequestParser: @unchecked Sendable { try lock.withLock { guard _state.hasHeaders else { return nil } - if let error = _parseError { + // Payload-too-large means "valid headers, rejected body" — let + // the caller access the parsed headers so the handler can build + // a response (e.g., with CORS headers). Other parse errors + // indicate genuinely malformed requests and are still thrown. + if let error = _parseError, error != .payloadTooLarge { throw error } if _parsedHeaders == nil { - let headerData = try buffer.read(from: 0, maxLength: min(bytesWritten, Self.maxHeaderSize)) + let headerData = try buffer.read(from: 0, maxLength: Int(min(bytesWritten, Int64(Self.maxHeaderSize)))) switch HTTPRequestSerializer.parseHeaders(from: headerData) { case .parsed(let headers): _parsedHeaders = headers @@ -143,7 +160,11 @@ public final class HTTPRequestParser: @unchecked Sendable { guard let headers = _parsedHeaders else { return nil } - guard _state.isComplete else { + // Return partial (headers only) when the body was rejected or + // hasn't fully arrived yet. The payloadTooLarge case goes through + // drain mode which discards body bytes without buffering them, so + // there is no body to extract even though the state is .complete. + guard _state.isComplete, _parseError == nil else { return .partial( method: headers.method, target: headers.target, @@ -179,6 +200,17 @@ public final class HTTPRequestParser: @unchecked Sendable { lock.withLock { guard !_state.isComplete else { return } + // In drain mode, discard bytes without buffering and check + // whether the full Content-Length has been consumed. + if case .draining = _state { + bytesWritten += Int64(data.count) + if let offset = headerEndOffset, + bytesWritten - Int64(offset) >= expectedContentLength { + _state = .complete + } + return + } + let accepted: Bool do { accepted = try buffer.append(data) @@ -192,12 +224,12 @@ public final class HTTPRequestParser: @unchecked Sendable { _state = .complete return } - bytesWritten += data.count + bytesWritten += Int64(data.count) if headerEndOffset == nil { let buffered: Data do { - buffered = try buffer.read(from: 0, maxLength: min(bytesWritten, Self.maxHeaderSize)) + buffered = try buffer.read(from: 0, maxLength: Int(min(bytesWritten, Int64(Self.maxHeaderSize)))) } catch { _parseError = .bufferIOError _state = .complete @@ -215,7 +247,7 @@ public final class HTTPRequestParser: @unchecked Sendable { let effectiveData = buffered[scanStart...] guard let separatorRange = effectiveData.range(of: separator) else { - if bytesWritten > Self.maxHeaderSize { + if bytesWritten > Int64(Self.maxHeaderSize) { _parseError = .headersTooLarge _state = .complete } else { @@ -236,15 +268,23 @@ public final class HTTPRequestParser: @unchecked Sendable { if expectedContentLength > maxBodySize { _parseError = .payloadTooLarge - _state = .complete + // Check if the body bytes already received in this + // chunk satisfy the drain — small requests may arrive + // as a single read. + if let offset = headerEndOffset, + bytesWritten - Int64(offset) >= expectedContentLength { + _state = .complete + } else { + _state = .draining + } return } } guard let offset = headerEndOffset else { return } - let bodyBytesAvailable = bytesWritten - offset + let bodyBytesAvailable = bytesWritten - Int64(offset) - if Int64(bodyBytesAvailable) >= expectedContentLength { + if bodyBytesAvailable >= expectedContentLength { _state = .complete } else { _state = .headersComplete @@ -403,14 +443,16 @@ extension HTTPRequestParser.State { /// Whether all data has been received (headers and body). public var isComplete: Bool { - if case .complete = self { return true } - return false + switch self { + case .complete: return true + case .needsMoreData, .headersComplete, .draining: return false + } } - /// Whether headers have been fully received (true for both `.headersComplete` and `.complete`). + /// Whether headers have been fully received (true for `.headersComplete`, `.draining`, and `.complete`). public var hasHeaders: Bool { switch self { - case .headersComplete, .complete: return true + case .headersComplete, .draining, .complete: return true case .needsMoreData: return false } } diff --git a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift index 17cfeb1c7..b49aa4602 100644 --- a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift +++ b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift @@ -75,6 +75,16 @@ public final class HTTPServer: Sendable { public let parsed: ParsedHTTPRequest /// Time spent receiving and parsing the request. public let parseDuration: Duration + /// A server-detected error that occurred after headers were parsed + /// (e.g., payload too large). When set, the handler is responsible + /// for building an appropriate error response. + public let serverError: HTTPRequestParseError? + + init(parsed: ParsedHTTPRequest, parseDuration: Duration, serverError: HTTPRequestParseError? = nil) { + self.parsed = parsed + self.parseDuration = parseDuration + self.serverError = serverError + } } public typealias Response = HTTPResponse @@ -270,11 +280,24 @@ public final class HTTPServer: Sendable { // Phase 1: receive headers only. try await Self.receiveUntil(\.hasHeaders, parser: parser, on: connection, idleTimeout: idleTimeout) + // Drain oversized body before throwing so the + // client receives the 413 (RFC 9110 §15.5.14). + if parser.state == .draining { + try await Self.receiveUntil(\.isComplete, parser: parser, on: connection, idleTimeout: idleTimeout) + } + // Validate headers (triggers full RFC validation). guard let partial = try parser.parseRequest() else { throw HTTPServerError.connectionClosed } + // If the parser detected a non-fatal error (e.g., + // payload too large after drain), return the partial + // request so the handler can build the response. + if parser.parseError != nil { + return partial + } + // Check auth before consuming body to avoid buffering // up to maxRequestBodySize for unauthenticated clients. // OPTIONS is exempt because CORS preflight requests @@ -313,7 +336,7 @@ public final class HTTPServer: Sendable { } } - let response = await handler(Request(parsed: request, parseDuration: duration)) + let response = await handler(Request(parsed: request, parseDuration: duration, serverError: parser.parseError)) await send(response, on: connection) let (sec, atto) = duration.components let ms = Double(sec) * 1000.0 + Double(atto) / 1_000_000_000_000_000.0 diff --git a/ios/Tests/GutenbergKitHTTPTests/FixtureTests.swift b/ios/Tests/GutenbergKitHTTPTests/FixtureTests.swift index f361ccdb7..22feadbd0 100644 --- a/ios/Tests/GutenbergKitHTTPTests/FixtureTests.swift +++ b/ios/Tests/GutenbergKitHTTPTests/FixtureTests.swift @@ -255,7 +255,14 @@ struct RequestParsingFixtureTests { let expectedError = testCase.expected.error do { _ = try parser.parseRequest() - Issue.record("Expected error \(expectedError) but parsing succeeded — \(testCase.description)") + // Non-fatal errors (e.g., payloadTooLarge) are exposed via + // parseError instead of being thrown. + if let parseError = parser.parseError { + let errorName = String(describing: parseError) + #expect(errorName == expectedError, "\(testCase.description): expected \(expectedError) but got \(errorName)") + } else { + Issue.record("Expected error \(expectedError) but parsing succeeded — \(testCase.description)") + } } catch { let errorName = String(describing: error) #expect(errorName == expectedError, "\(testCase.description): expected \(expectedError) but got \(errorName)") diff --git a/ios/Tests/GutenbergKitHTTPTests/HTTPRequestParserTests.swift b/ios/Tests/GutenbergKitHTTPTests/HTTPRequestParserTests.swift index f4df6bd08..c4dc5366c 100644 --- a/ios/Tests/GutenbergKitHTTPTests/HTTPRequestParserTests.swift +++ b/ios/Tests/GutenbergKitHTTPTests/HTTPRequestParserTests.swift @@ -389,15 +389,24 @@ struct HTTPRequestParserTests { // MARK: - Max Body Size - @Test("rejects request when Content-Length exceeds maxBodySize") - func rejectsOversizedContentLength() { + @Test("drains oversized body and returns partial with parseError") + func rejectsOversizedContentLength() throws { let parser = HTTPRequestParser(maxBodySize: 100) parser.append(Data("POST /upload HTTP/1.1\r\nHost: localhost\r\nContent-Length: 101\r\n\r\n".utf8)) + // Parser enters drain mode — not yet complete. + #expect(parser.state == .draining) + + // Feed the remaining body bytes to complete the drain. + parser.append(Data(repeating: 0x41, count: 101)) #expect(parser.state.isComplete) - #expect(throws: HTTPRequestParseError.payloadTooLarge) { - try parser.parseRequest() - } + + // parseRequest() returns partial headers instead of throwing. + let request = try #require(try parser.parseRequest()) + #expect(request.method == "POST") + #expect(request.target == "/upload") + #expect(!request.isComplete) + #expect(parser.parseError == .payloadTooLarge) } @Test("accepts request when Content-Length equals maxBodySize") @@ -424,16 +433,48 @@ struct HTTPRequestParserTests { #expect(try readAll(requestBody) == Data(body.utf8)) } - @Test("rejects oversized Content-Length even when body data hasn't arrived") - func rejectsOversizedBeforeBodyArrives() { + @Test("enters drain mode for oversized Content-Length even when body hasn't arrived") + func rejectsOversizedBeforeBodyArrives() throws { let parser = HTTPRequestParser(maxBodySize: 50) parser.append(Data("POST /upload HTTP/1.1\r\nHost: localhost\r\nContent-Length: 999999\r\n\r\n".utf8)) - // Parser should mark complete immediately without waiting for body bytes - #expect(parser.state.isComplete) - #expect(throws: HTTPRequestParseError.payloadTooLarge) { - try parser.parseRequest() + // Parser enters drain mode — headers are available but not yet complete. + #expect(parser.state == .draining) + #expect(parser.state.hasHeaders) + #expect(!parser.state.isComplete) + + // Feed body bytes in chunks to complete the drain. + let chunkSize = 8192 + var remaining = 999999 + while remaining > 0 { + let size = min(chunkSize, remaining) + parser.append(Data(repeating: 0x42, count: size)) + remaining -= size } + + #expect(parser.state.isComplete) + let request = try #require(try parser.parseRequest()) + #expect(request.method == "POST") + #expect(!request.isComplete) + #expect(parser.parseError == .payloadTooLarge) + } + + @Test("drain mode does not buffer body bytes") + func drainDoesNotBuffer() throws { + let parser = HTTPRequestParser(maxBodySize: 10) + let headers = "POST /upload HTTP/1.1\r\nHost: localhost\r\nContent-Length: 1000\r\n\r\n" + parser.append(Data(headers.utf8)) + #expect(parser.state == .draining) + + // Feed 1000 bytes of body data. + parser.append(Data(repeating: 0x43, count: 1000)) + #expect(parser.state.isComplete) + + // parseRequest() returns headers; error is on parseError. + let request = try #require(try parser.parseRequest()) + #expect(request.method == "POST") + #expect(!request.isComplete) + #expect(parser.parseError == .payloadTooLarge) } @Test("rejects headers that exceed maxHeaderSize without terminator") diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index 2ebb21bd4..02f2f93e7 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -1,4 +1,5 @@ import Foundation +import GutenbergKitHTTP import Testing @testable import GutenbergKit @@ -131,8 +132,8 @@ struct MediaUploadServerTests { #expect(result.type == "image") } - @Test("falls back to default uploader when delegate returns nil") - func delegateFallbackToDefault() async throws { + @Test("uses passthrough when delegate does not modify file") + func delegatePassthrough() async throws { let delegate = ProcessOnlyDelegate() let mockUploader = MockDefaultUploader() let server = try await MediaUploadServer.start(uploadDelegate: delegate, defaultUploader: mockUploader) @@ -154,12 +155,39 @@ struct MediaUploadServerTests { #expect(httpResponse.statusCode == 200) #expect(delegate.processFileCalled) - #expect(mockUploader.uploadCalled) + // Passthrough: original body forwarded directly, not re-encoded. + #expect(mockUploader.passthroughUploadCalled) + #expect(!mockUploader.uploadCalled) let result = try JSONDecoder().decode(MediaUploadResult.self, from: data) #expect(result.id == 99) } + @Test("returns 413 with CORS headers when request body exceeds max size") + func oversizedUploadReturns413WithCORSHeaders() async throws { + let server = try await MediaUploadServer.start(maxRequestBodySize: 1024) + defer { server.stop() } + + let boundary = UUID().uuidString + let oversizedData = Data(repeating: 0x42, count: 2048) + let body = buildMultipartBody(boundary: boundary, filename: "big.bin", mimeType: "application/octet-stream", data: oversizedData) + + let url = URL(string: "http://127.0.0.1:\(server.port)/upload")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.httpBody = body + + let (data, response) = try await URLSession.shared.data(for: request) + let httpResponse = try #require(response as? HTTPURLResponse) + #expect(httpResponse.statusCode == 413) + #expect(httpResponse.value(forHTTPHeaderField: "Access-Control-Allow-Origin") == "*") + + let responseBody = String(data: data, encoding: .utf8) ?? "" + #expect(responseBody.contains("too large")) + } + private func buildMultipartBody(boundary: String, filename: String, mimeType: String, data: Data) -> Data { var body = Data() body.append("--\(boundary)\r\n") @@ -171,6 +199,77 @@ struct MediaUploadServerTests { } } +// MARK: - Streaming Multipart Body Tests + +@Suite("DefaultMediaUploader streaming multipart body") +struct MultipartBodyStreamTests { + + @Test("streaming output matches in-memory multipart format") + func streamMatchesInMemory() throws { + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("stream-test-\(UUID().uuidString)") + let fileContent = Data("hello world".utf8) + try fileContent.write(to: tempFile) + defer { try? FileManager.default.removeItem(at: tempFile) } + + let boundary = "test-boundary-123" + let filename = "photo.jpg" + let mimeType = "image/jpeg" + + // Build expected output using the old in-memory approach. + var expected = Data() + expected.append(Data("--\(boundary)\r\n".utf8)) + expected.append(Data("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".utf8)) + expected.append(Data("Content-Type: \(mimeType)\r\n\r\n".utf8)) + expected.append(fileContent) + expected.append(Data("\r\n--\(boundary)--\r\n".utf8)) + + // Build streaming output. + let (stream, contentLength) = try DefaultMediaUploader.multipartBodyStream( + fileURL: tempFile, boundary: boundary, filename: filename, mimeType: mimeType + ) + #expect(contentLength == expected.count) + + let result = readAllFromStream(stream) + #expect(result == expected) + } + + @Test("content length matches actual stream output for larger files") + func contentLengthAccurate() throws { + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("stream-test-\(UUID().uuidString)") + let fileContent = Data(repeating: 0x42, count: 100_000) + try fileContent.write(to: tempFile) + defer { try? FileManager.default.removeItem(at: tempFile) } + + let (stream, contentLength) = try DefaultMediaUploader.multipartBodyStream( + fileURL: tempFile, boundary: "boundary", filename: "big.bin", mimeType: "application/octet-stream" + ) + + let result = readAllFromStream(stream) + #expect(result.count == contentLength) + } +} + +// MARK: - Helpers + +/// Reads all bytes from an InputStream using `read()` return value as +/// the sole termination signal (not `hasBytesAvailable`, which is +/// unreliable for piped/bound streams). +private func readAllFromStream(_ stream: InputStream) -> Data { + stream.open() + defer { stream.close() } + + var data = Data() + let bufferSize = 8192 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + while true { + let read = stream.read(buffer, maxLength: bufferSize) + if read <= 0 { break } + data.append(buffer, count: read) + } + return data +} + // MARK: - Mocks private final class MockUploadDelegate: MediaUploadDelegate, @unchecked Sendable { @@ -223,8 +322,10 @@ private final class ProcessOnlyDelegate: MediaUploadDelegate, @unchecked Sendabl private final class MockDefaultUploader: DefaultMediaUploader, @unchecked Sendable { private let lock = NSLock() private var _uploadCalled = false + private var _passthroughUploadCalled = false var uploadCalled: Bool { lock.withLock { _uploadCalled } } + var passthroughUploadCalled: Bool { lock.withLock { _passthroughUploadCalled } } init() { super.init(httpClient: MockHTTPClient(), siteApiRoot: URL(string: "https://example.com/wp-json/")!) @@ -232,7 +333,16 @@ private final class MockDefaultUploader: DefaultMediaUploader, @unchecked Sendab override func upload(fileURL: URL, mimeType: String, filename: String) async throws -> MediaUploadResult { lock.withLock { _uploadCalled = true } - return MediaUploadResult( + return mockResult() + } + + override func passthroughUpload(body: RequestBody, contentType: String) async throws -> MediaUploadResult { + lock.withLock { _passthroughUploadCalled = true } + return mockResult() + } + + private func mockResult() -> MediaUploadResult { + MediaUploadResult( id: 99, url: "https://example.com/doc.pdf", title: "doc", diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js index 2c55447cd..4f1b57d84 100644 --- a/src/utils/api-fetch.js +++ b/src/utils/api-fetch.js @@ -202,7 +202,7 @@ export function nativeMediaUploadMiddleware( options, next ) { if ( ! response.ok ) { return response.text().then( ( body ) => { const error = new Error( - `Native upload failed (${ response.status }): ${ + `Upload failed (${ response.status }): ${ body || response.statusText }` );