From caef09cae6046a9398edc7480902d74bc5be55e3 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 27 Mar 2026 15:12:47 -0400 Subject: [PATCH 01/21] 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 5373b3b13..8952d8b8c 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, POST_FALLBACKS } 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( @@ -146,6 +151,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. * @@ -157,7 +273,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 e7ad4c4e9..f04b50830 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -232,6 +232,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 c092d5c7604278f767ec83fd8d4cc334601f77d3 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 27 Mar 2026 15:13:00 -0400 Subject: [PATCH 02/21] 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 | 11 +- 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, 519 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 62e7aa381..aacd18e8c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e4f07fb846fe4e484ddbb0d1c5783d0a62ffee053e850e8a2abe0b8e0323bbd4", + "originHash" : "6db3023106dfc39818a2a045dfbd8be56ad662c039842cf91e1f21a9bd7ce81f", "pins" : [ { "identity" : "svgview", @@ -18,6 +18,15 @@ "revision" : "aa85ee96017a730031bafe411cde24a08a17a9c9", "version" : "2.8.8" } + }, + { + "identity" : "wordpress-rs", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Automattic/wordpress-rs", + "state" : { + "branch" : "alpha-20260313", + "revision" : "cde2fda82257f4ac7b81543d5b831bb267d4e52c" + } } ], "version" : 3 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 e3c53e336..5b4c20544 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -104,12 +104,17 @@ 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 let lockdownModeMonitor: LockdownModeMonitor + private var uploadServer: MediaUploadServer? // MARK: - Private Properties (UI) @@ -165,6 +170,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro self.configuration = configuration self.dependencies = dependencies + self.httpClient = httpClient self.editorService = EditorService( configuration: configuration, httpClient: httpClient @@ -238,10 +244,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 @@ -264,6 +272,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. @@ -284,7 +293,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 @@ -301,12 +310,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) @@ -339,7 +351,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 = """ @@ -351,6 +368,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 6c99bc3f2186b56c1119c33c817610ef4b481cf3 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 27 Mar 2026 15:13:11 -0400 Subject: [PATCH 03/21] 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 + android/Gutenberg/detekt-baseline.xml | 1 + .../org/wordpress/gutenberg/GutenbergView.kt | 49 ++- .../wordpress/gutenberg/MediaUploadServer.kt | 311 ++++++++++++++++++ .../wordpress/gutenberg/model/GBKitGlobal.kt | 12 +- android/gradle/libs.versions.toml | 2 + 6 files changed, 374 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 02aab6734..91747a1f0 100644 --- a/android/Gutenberg/build.gradle.kts +++ b/android/Gutenberg/build.gradle.kts @@ -173,6 +173,7 @@ dependencies { implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.activity.compose) + testImplementation(libs.json) testImplementation(libs.junit) testImplementation(kotlin("test")) testImplementation(libs.kotlinx.coroutines.test) diff --git a/android/Gutenberg/detekt-baseline.xml b/android/Gutenberg/detekt-baseline.xml index 428e9d6ff..4f6c96915 100644 --- a/android/Gutenberg/detekt-baseline.xml +++ b/android/Gutenberg/detekt-baseline.xml @@ -10,6 +10,7 @@ CyclomaticComplexMethod:MultipartPart.kt$MultipartPart.Companion$fun parseChunked( source: RequestBody.FileBacked, boundary: String ): List<MultipartPart> ExplicitItLambdaParameter:EditorAssetsLibrary.kt$EditorAssetsLibrary${ str, it -> str + "%02x".format(it) } FunctionNaming:EditorURLCache.kt$EditorURLCache$private fun __store( response: EditorURLResponse, url: String, httpMethod: EditorHttpMethod, currentDate: Date ) + LargeClass:GutenbergView.kt$GutenbergView : FrameLayout LongMethod:FixtureTests.kt$FixtureTests$@Test fun `request parsing - all basic cases pass`() LongMethod:FixtureTests.kt$FixtureTests$@Test fun `request parsing - all incremental cases pass`() LongMethod:HTTPRequestParser.kt$HTTPRequestParser$fun append(data: ByteArray): Unit diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 0740ad222..12d8a84cc 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -111,6 +111,24 @@ class GutenbergView : FrameLayout { 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 @@ -579,7 +597,12 @@ class GutenbergView : FrameLayout { } 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; @@ -590,6 +613,26 @@ class GutenbergView : FrameLayout { } + 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; @@ -1018,6 +1061,8 @@ class GutenbergView : FrameLayout { override fun onDetachedFromWindow() { super.onDetachedFromWindow() stopNetworkMonitoring() + uploadServer?.stop() + uploadServer = null clearConfig() // Cancel in-flight animations to prevent withEndAction callbacks from // firing on detached views. @@ -1091,6 +1136,8 @@ class GutenbergView : FrameLayout { } 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 d0d4a411c..76c051dbc 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. */ @@ -94,10 +98,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 { val postId = (configuration.postId?.toInt() ?: -1).takeIf({ it != 0 }) @@ -122,6 +130,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 519415f38..80aa59dee 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -25,6 +25,7 @@ okhttp = "4.12.0" detekt = "1.23.8" buildkite-test-collector = "0.4.0" androidsvg = "1.4" +json = "20240303" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -59,6 +60,7 @@ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhtt okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" } buildkite-test-collector-instrumented = { group = "com.buildkite.test-collector-android", name = "instrumented-test-collector", version.ref = "buildkite-test-collector" } androidsvg = { group = "com.caverock", name = "androidsvg-aar", version.ref = "androidsvg" } +json = { group = "org.json", name = "json", version.ref = "json" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 8a1a1249bc415574dcd9dc026cd9dcf8def89c30 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 27 Mar 2026 15:13:31 -0400 Subject: [PATCH 04/21] 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 | 6 +- .../gutenbergkit/DemoMediaUploadDelegate.kt | 64 ++++++++++++++++ .../example/gutenbergkit/EditorActivity.kt | 8 ++ .../gutenbergkit/SitePreparationActivity.kt | 31 ++++++-- .../gutenbergkit/SitePreparationViewModel.kt | 5 ++ ios/Demo-iOS/Sources/ConfigurationItem.swift | 7 +- ios/Demo-iOS/Sources/GutenbergApp.swift | 3 +- ios/Demo-iOS/Sources/Views/EditorView.swift | 75 ++++++++++++++++++- .../Sources/Views/SitePreparationView.swift | 6 +- 9 files changed, 191 insertions(+), 14 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 ab0289fb0..786583b22 100644 --- a/android/app/detekt-baseline.xml +++ b/android/app/detekt-baseline.xml @@ -2,13 +2,13 @@ - LongMethod:EditorActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun EditorScreen( configuration: EditorConfiguration, dependencies: EditorDependencies? = null, accountId: ULong? = null, coroutineScope: CoroutineScope, onClose: () -> Unit, onGutenbergViewCreated: (GutenbergView) -> Unit = {} ) + LongMethod:EditorActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun EditorScreen( configuration: EditorConfiguration, dependencies: EditorDependencies? = null, accountId: ULong? = 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:PostsListActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun PostsListScreen( viewModel: PostsListViewModel, onClose: () -> Unit, onPostSelected: (AnyPostWithEditContext) -> Unit ) - LongMethod:SitePreparationActivity.kt$@Composable private fun FeatureConfigurationCard( enableNativeInserter: Boolean, onEnableNativeInserterChange: (Boolean) -> Unit, enableInserterMediaStrip: Boolean, onEnableInserterMediaStripChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, postTypes: List<PostTypeDetails>, selectedPostType: PostTypeDetails?, onPostTypeChange: (PostTypeDetails) -> Unit, showBrowseButton: Boolean = false, onBrowsePosts: () -> Unit = {} ) + LongMethod:SitePreparationActivity.kt$@Composable private fun FeatureConfigurationCard( enableNativeInserter: Boolean, onEnableNativeInserterChange: (Boolean) -> Unit, enableInserterMediaStrip: Boolean, onEnableInserterMediaStripChange: (Boolean) -> Unit, enableNativeMediaUpload: Boolean, onEnableNativeMediaUploadChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, postTypes: List<PostTypeDetails>, selectedPostType: PostTypeDetails?, onPostTypeChange: (PostTypeDetails) -> Unit, showBrowseButton: Boolean = false, onBrowsePosts: () -> 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, enableInserterMediaStrip: Boolean, onEnableInserterMediaStripChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, postTypes: List<PostTypeDetails>, selectedPostType: PostTypeDetails?, onPostTypeChange: (PostTypeDetails) -> Unit, showBrowseButton: Boolean = false, onBrowsePosts: () -> Unit = {} ) + LongParameterList:SitePreparationActivity.kt$( enableNativeInserter: Boolean, onEnableNativeInserterChange: (Boolean) -> Unit, enableInserterMediaStrip: Boolean, onEnableInserterMediaStripChange: (Boolean) -> Unit, enableNativeMediaUpload: Boolean, onEnableNativeMediaUploadChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, postTypes: List<PostTypeDetails>, selectedPostType: PostTypeDetails?, onPostTypeChange: (PostTypeDetails) -> Unit, showBrowseButton: Boolean = false, onBrowsePosts: () -> 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 213b75ab7..20f3e84b2 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -64,6 +64,7 @@ class EditorActivity : ComponentActivity() { companion object { const val EXTRA_DEPENDENCIES_PATH = "dependencies_path" const val EXTRA_ACCOUNT_ID = "account_id" + const val EXTRA_ENABLE_NATIVE_MEDIA_UPLOAD = "enable_native_media_upload" } private var gutenbergView: GutenbergView? = null @@ -104,12 +105,15 @@ class EditorActivity : ComponentActivity() { // Optional account ID for REST API persistence (set when launched from PostsListActivity) val accountId = intent.getLongExtra(EXTRA_ACCOUNT_ID, -1L).takeIf { it >= 0 }?.toULong() + val enableNativeMediaUpload = intent.getBooleanExtra(EXTRA_ENABLE_NATIVE_MEDIA_UPLOAD, true) + setContent { AppTheme { EditorScreen( configuration = configuration, dependencies = dependencies, accountId = accountId, + enableNativeMediaUpload = enableNativeMediaUpload, coroutineScope = this.lifecycleScope, onClose = { finish() }, onGutenbergViewCreated = { view -> @@ -134,6 +138,7 @@ fun EditorScreen( configuration: EditorConfiguration, dependencies: EditorDependencies? = null, accountId: ULong? = null, + enableNativeMediaUpload: Boolean = true, coroutineScope: CoroutineScope, onClose: () -> Unit, onGutenbergViewCreated: (GutenbergView) -> Unit = {} @@ -332,6 +337,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 fa5197eae..416b0aa90 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt @@ -151,8 +151,8 @@ class SitePreparationActivity : ComponentActivity() { viewModel = viewModel, accountId = accountId, onClose = { finish() }, - onStartEditor = { configuration, dependencies -> - launchEditor(configuration, dependencies) + onStartEditor = { configuration, dependencies, enableNativeMediaUpload -> + launchEditor(configuration, dependencies, enableNativeMediaUpload) }, onBrowsePosts = { configuration, dependencies, postType -> accountId?.let { @@ -166,10 +166,12 @@ class SitePreparationActivity : ComponentActivity() { private fun launchEditor( configuration: EditorConfiguration, - dependencies: EditorDependencies? + dependencies: 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) { @@ -198,7 +200,7 @@ fun SitePreparationScreen( viewModel: SitePreparationViewModel, accountId: ULong?, onClose: () -> Unit, - onStartEditor: (EditorConfiguration, EditorDependencies?) -> Unit, + onStartEditor: (EditorConfiguration, EditorDependencies?, Boolean) -> Unit, onBrowsePosts: (EditorConfiguration, EditorDependencies?, PostTypeDetails) -> Unit ) { val uiState by viewModel.uiState.collectAsState() @@ -225,7 +227,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) @@ -301,6 +303,8 @@ private fun LoadedView( onEnableNativeInserterChange = viewModel::setEnableNativeInserter, enableInserterMediaStrip = uiState.enableInserterMediaStrip, onEnableInserterMediaStripChange = viewModel::setEnableInserterMediaStrip, + enableNativeMediaUpload = uiState.enableNativeMediaUpload, + onEnableNativeMediaUploadChange = viewModel::setEnableNativeMediaUpload, enableNetworkLogging = uiState.enableNetworkLogging, onEnableNetworkLoggingChange = viewModel::setEnableNetworkLogging, postTypes = uiState.postTypes, @@ -387,6 +391,8 @@ private fun FeatureConfigurationCard( onEnableNativeInserterChange: (Boolean) -> Unit, enableInserterMediaStrip: Boolean, onEnableInserterMediaStripChange: (Boolean) -> Unit, + enableNativeMediaUpload: Boolean, + onEnableNativeMediaUploadChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, postTypes: List, @@ -434,6 +440,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 360b08af0..fec2d7e02 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt @@ -21,6 +21,7 @@ import uniffi.wp_api.PostType as WpPostType data class SitePreparationUiState( val enableNativeInserter: Boolean = false, val enableInserterMediaStrip: Boolean = false, + val enableNativeMediaUpload: Boolean = true, val enableNetworkLogging: Boolean = false, /** All viewable post types fetched from the site, or empty while loading. */ val postTypes: List = emptyList(), @@ -90,6 +91,10 @@ class SitePreparationViewModel( _uiState.update { it.copy(enableInserterMediaStrip = 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 88eb12489..e98392ae2 100644 --- a/ios/Demo-iOS/Sources/ConfigurationItem.swift +++ b/ios/Demo-iOS/Sources/ConfigurationItem.swift @@ -35,11 +35,13 @@ struct RunnableEditor: Equatable, Hashable { let configuration: EditorConfiguration let dependencies: EditorDependencies? let apiClient: WordPressAPI? + var enableNativeMediaUpload: Bool = true - init(configuration: EditorConfiguration, dependencies: EditorDependencies?, apiClient: WordPressAPI? = nil) { + init(configuration: EditorConfiguration, dependencies: EditorDependencies?, apiClient: WordPressAPI? = nil, enableNativeMediaUpload: Bool = true) { self.configuration = configuration self.dependencies = dependencies self.apiClient = apiClient + self.enableNativeMediaUpload = enableNativeMediaUpload } // `apiClient` is intentionally excluded from `==` and `hash(into:)`: @@ -47,12 +49,13 @@ struct RunnableEditor: Equatable, Hashable { // and two editors with the same configuration but different client // instances should be treated as equal for navigation/identity purposes. static func == (lhs: RunnableEditor, rhs: RunnableEditor) -> Bool { - lhs.configuration == rhs.configuration && lhs.dependencies == rhs.dependencies + lhs.configuration == rhs.configuration && lhs.dependencies == rhs.dependencies && lhs.enableNativeMediaUpload == rhs.enableNativeMediaUpload } func hash(into hasher: inout Hasher) { hasher.combine(configuration) hasher.combine(dependencies) + hasher.combine(enableNativeMediaUpload) } } diff --git a/ios/Demo-iOS/Sources/GutenbergApp.swift b/ios/Demo-iOS/Sources/GutenbergApp.swift index 3a741f03f..ee3b92386 100644 --- a/ios/Demo-iOS/Sources/GutenbergApp.swift +++ b/ios/Demo-iOS/Sources/GutenbergApp.swift @@ -59,7 +59,8 @@ struct GutenbergApp: App { EditorView( configuration: editor.configuration, dependencies: editor.dependencies, - apiClient: editor.apiClient + apiClient: editor.apiClient, + enableNativeMediaUpload: editor.enableNativeMediaUpload ) } } diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index ca1aaed8a..d40fb1aaa 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -1,4 +1,7 @@ import SwiftUI +import ImageIO +import OSLog +import UniformTypeIdentifiers import GutenbergKit import WordPressAPI // `PostUpdateParams` is not yet re-exported from `WordPressAPI` in the pinned @@ -7,19 +10,25 @@ import WordPressAPI // including Automattic/wordpress-rs#1270 is adopted. import WordPressAPIInternal +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 apiClient: WordPressAPI? + private let enableNativeMediaUpload: Bool @State private var viewModel = EditorViewModel() @Environment(\.dismiss) var dismiss - init(configuration: EditorConfiguration, dependencies: EditorDependencies? = nil, apiClient: WordPressAPI? = nil) { + init(configuration: EditorConfiguration, dependencies: EditorDependencies? = nil, apiClient: WordPressAPI? = nil, enableNativeMediaUpload: Bool = true) { self.configuration = configuration self.dependencies = dependencies self.apiClient = apiClient + self.enableNativeMediaUpload = enableNativeMediaUpload } var body: some View { @@ -27,6 +36,7 @@ struct EditorView: View { configuration: configuration, dependencies: dependencies, apiClient: apiClient, + enableNativeMediaUpload: enableNativeMediaUpload, viewModel: viewModel ) .toolbar { toolbar } @@ -101,17 +111,20 @@ private struct _EditorView: UIViewControllerRepresentable { private let configuration: EditorConfiguration private let dependencies: EditorDependencies? private let apiClient: WordPressAPI? + private let enableNativeMediaUpload: Bool private let viewModel: EditorViewModel init( configuration: EditorConfiguration, dependencies: EditorDependencies? = nil, apiClient: WordPressAPI? = nil, + enableNativeMediaUpload: Bool = true, viewModel: EditorViewModel ) { self.configuration = configuration self.dependencies = dependencies self.apiClient = apiClient + self.enableNativeMediaUpload = enableNativeMediaUpload self.viewModel = viewModel } @@ -122,6 +135,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 @@ -173,7 +189,7 @@ private struct _EditorView: UIViewControllerRepresentable { } @MainActor - class Coordinator: NSObject, EditorViewControllerDelegate { + class Coordinator: NSObject, EditorViewControllerDelegate, MediaUploadDelegate { let viewModel: EditorViewModel init(viewModel: EditorViewModel) { @@ -278,6 +294,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 68d6e0b8a..03dd496f6 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 77c8ca2f0d7db6b3021388f47136c7eacf4640e9 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 27 Mar 2026 15:13:51 -0400 Subject: [PATCH 05/21] 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 c99a12eee0cb8be6bba588fc1353f7e92f42e3cf Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 27 Mar 2026 15:14:00 -0400 Subject: [PATCH 06/21] 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 9a927618cfe41f4f12b0cf970c39a962a8aa3eb2 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 27 Mar 2026 15:26:15 -0400 Subject: [PATCH 07/21] 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 8683c2805e3b902062ad82ad2838969f92236d59 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 27 Mar 2026 15:55:04 -0400 Subject: [PATCH 08/21] 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 8952d8b8c..a58d98980 100644 --- a/src/utils/api-fetch.js +++ b/src/utils/api-fetch.js @@ -216,17 +216,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 2cc917f12f7c502bbc4b70d3ddd77ebb852bfb57 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 27 Mar 2026 16:04:00 -0400 Subject: [PATCH 09/21] 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 4bffd3c4f8dc91df34ec93439b04801c52de0630 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 27 Mar 2026 16:30:48 -0400 Subject: [PATCH 10/21] 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 75743f94b4a16efe277f99d413f6454548ccee9c Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 27 Mar 2026 16:30:58 -0400 Subject: [PATCH 11/21] 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 12d8a84cc..9756c943f 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -114,6 +114,7 @@ class GutenbergView : FrameLayout { /** 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 6b1d98b13807b7d113a2cdb452e084efa075fb51 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 9 Apr 2026 15:17:11 -0400 Subject: [PATCH 12/21] 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 1bc7f5c93..b3a558bda 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 @@ -151,7 +151,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 485cc9b79..83bf016a2 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 a58d98980..395bb9541 100644 --- a/src/utils/api-fetch.js +++ b/src/utils/api-fetch.js @@ -217,7 +217,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 }` ); From 024c791af4b88e9f44daa56887252bf2500b05cf Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:07:43 -0600 Subject: [PATCH 13/21] fix: native media upload review follow-ups (relay, delegate contract, CORS) (#546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: fall back to default upload path when native server is unreachable The native media upload middleware rethrew on any fetch failure, so a local upload server that was never started, restarted on a new port, or torn down turned every upload into a hard failure. Distinguish a connection-level failure (fall back to next()) from a non-ok response (a real server error that must surface), and re-throw AbortError so an explicit cancellation is not retried. Amplifier fix for the iOS/Android upload-server lifecycle issues. * fix(ios): stop upload server on deinit and hold the delegate weakly viewDidDisappear fires whenever another view controller is pushed or presented over the editor. HTTPServer.stop() cancels the NWListener, which is terminal, so uploads stayed broken after the user returned. Move stop() to deinit. Hold the media upload delegate weakly in UploadContext (re-read per request): mediaUploadDelegate is declared weak, and the strong capture both defeated that contract and risked a retain cycle that kept the view controller — and the server — alive so deinit never fired. * fix(android): re-advertise upload server port and token on restart setGlobalJavaScriptVariables only runs from onPageStarted, so when the media upload delegate setter restarts the server after the page has loaded (e.g. a Compose host constructing a new delegate instance per recomposition), the new port and token were never injected and JS kept fetching a dead port. Patch the two fields into window.GBKit after each (re)start; the window.GBKit guard makes it a no-op before the page loads, where onPageStarted still handles the initial injection. * fix(android): use a 60s timeout for media uploads The upload OkHttpClient used the bare defaults, including a 10s read timeout. WordPress generates image sub-sizes synchronously inside POST /wp/v2/media, so >10s responses are routine on shared hosting — and because the attachment row is created before sub-size generation, a client-side timeout orphans the attachment server-side and duplicates it on retry. Match EditorHTTPClient's 60s policy. * fix(android): return CORS headers on all upload error responses processAndRespond only caught MediaUploadException; an IOException from the upload call, JSON parse errors, a throwing delegate, and "no uploader configured" escaped to HttpServer's header-less 500 fallback, so the browser rejected the preflighted cross-origin fetch with an opaque "Failed to fetch" and hid the real error from the editor. Add a catch-all mapping to the CORS-bearing errorResponse (mirroring iOS), rethrowing coroutine cancellation so it is not swallowed. * fix(android): start upload server for cookie-auth hosts with an upload delegate startUploadServer bare-returned when authHeader was empty, but empty authHeader is in-contract for cookie-auth hosts (auth via setCookies), and a delegate implementing uploadFile can own the upload without the default REST uploader. This made an uploadFile-implementing cookie-auth host silently take the unprocessed WebView path, while the same setup worked on iOS. Build the default uploader only when siteApiRoot and authHeader are present (MediaUploadServer already accepts a null default uploader), and start the server whenever a delegate can handle uploads. * fix: relay WordPress's raw upload response instead of a synthesized shape The native upload server parsed WordPress's media response into a 9-field MediaUploadResult (duplicated in Swift, Kotlin, and JS) and the JS middleware re-synthesized an attachment from it. That dropped media_details.sizes — so every native-uploaded image fell back to sizeSlug: full and embedded the full-resolution original — plus the attachment-page link, distinct raw/rendered fields, and _embedded, and flattened WordPress's status to a local 500. Replace the schema with MediaUploadResponse (status + raw body) and relay WordPress's response verbatim. DefaultMediaUploader returns the raw bytes + status and no longer throws on non-2xx (iOS via a new non-throwing EditorHTTPClientProtocol.performRaw; Android reads the OkHttp response directly). The server relays (status, body) with CORS headers, and errorResponse now emits a {code, message} JSON body so its own errors normalize like a relayed WordPress error. The JS middleware returns response.json() unchanged on success, and on a non-2xx rejects with the parsed WordPress error body (like @wordpress/api-fetch) so media-utils surfaces WordPress's real message and code, falling back to invalid_json on a non-JSON body. The uploadFile delegate now returns MediaUploadResponse? (the raw response); nil still selects the default uploader. Forwarding post/additionalData and ?_embed is a follow-up. * fix: forward media upload fields and query through the native server The native media upload middleware rebuilt the request body with only the file field and dropped the URL query, so post (the attachment's post association), any additionalData, and ?_embed never reached WordPress — the attachment was created with no post_parent and _embedded was always absent. Forward the original request body (all fields) and the original query string from JS. On the native side, carry the incoming query onto the WordPress media URL, and — on the re-encode path where processFile changed the file — preserve the non-file parts in the rebuilt multipart body. The passthrough path already forwards them verbatim once the body is relayed unchanged. Completes the finding-3/4/18 upload-fidelity work; the multipart re-encode dedup is tracked separately in #545. * fix: clean up upload temp files on failure and sweep crash orphans When processFile produced a new file but the upload failed, the processed file leaked (the caller only cleaned up the original); partial writes leaked too; and there was no sweep for files orphaned by a crash. Android also staged temp files under java.io.tmpdir instead of the injected cache dir. Clean up the processed file inside processAndUpload (defer/finally) so the throw paths are covered, and delete the original in the write-failure catch. iOS handleUpload now uses defer + straight-line do/catch instead of the Result/mutable-var dance. Android stages temp files under the injected cacheDir (system-temp fallback). Both platforms sweep upload temp files older than an hour at startup, so crash orphans are reclaimed without disturbing in-flight uploads. Findings 13 and 21. * fix: regenerate Package.resolved against the committed manifest Package.resolved pinned wordpress-rs (branch alpha-20260313), which the root Package.swift never declares — so every swift build/test pruned it and dirtied the tree. Regenerate it from the manifest. The Demo-iOS Xcode project resolves wordpress-rs through its own package reference, so this does not affect the demo. Finding 16. * refactor(ios): remove dead MediaUploadError cases and Data.append extension After the raw-relay refactor, MediaUploadError.uploadFailed/unexpectedResponse are unused (the uploader no longer parses or throws on non-2xx), leaving only streamReadFailed — which UploadError already defines. Fold the two streamReadFailed uses into UploadError and delete MediaUploadError, plus the private Data.append(String) extension the multipart builder no longer uses (the test keeps its own copy). Finding 22 (partial). * fix(android): bake EXIF orientation into resized demo images The demo resize decodes with BitmapFactory (which ignores EXIF orientation) and re-encodes via compress() (which writes no EXIF), so a portrait photo — stored with landscape pixels plus an orientation tag — uploaded rotated. Read the tag and rotate the bitmap before compressing. Demo-only, but it is the reference processFile implementation hosts copy. Finding 15. * fix: normalize the media endpoint URL for unslashed root and namespace The upload endpoint URL was built by raw concatenation, so an unslashed siteApiRoot ("...wp-json") or namespace ("sites/123") produced a malformed URL ("...wp-jsonwp/v2/..." or ".../v2/sites/123media") and a 404. Apply the same normalization RESTAPIRepository uses — trim the root's trailing slash and give the namespace a trailing slash. Latent (in-repo producers pass slashed values today). Finding 7. * fix: give processFile an explicit result with corrected metadata processFile returned a bare URL/File, and the framework inferred "did it change?" by path equality — which conflated "unchanged" with "edited in place" and gave the delegate no way to report a new filename or MIME type. So an in-place EXIF/GPS strip was silently discarded (the original body was forwarded instead), and a format-changing transcode (MOV->MP4) uploaded with the original extension and mime_type. Replace the bare return with ProcessedProxyFile (.original / .processed(url, mimeType:, filename:)). Passthrough is now explicit, so an in-place edit is uploaded rather than dropped, and the delegate reports the resulting metadata, which is used verbatim. processFile also gains a filename parameter so the delegate has the original name to echo or rewrite — without it that data was simply lost. Breaking change to the public MediaUploadDelegate; both demos updated. Findings 11 and 12. * feat: add an opt-in permissive CORS policy to the HTTP server The HTTP library generated some responses itself — the read timeout (408) and pre-handler errors — without CORS headers, so the browser blocked the WebView from reading them and a timeout surfaced as an opaque "Failed to fetch" (and, with the JS fallback, a silent re-upload). Every handler also had to remember to add CORS to its own responses. Add an opt-in cors: .permissive policy to HTTPServer (iOS) / HttpServer (Android). When enabled the library answers the OPTIONS preflight itself and stamps permissive CORS headers on every response at the single send choke point — covering handler responses and the library's own. MediaUploadServer opts in and deletes its bespoke corsHeaders / corsPreflightResponse / OPTIONS handling, so CORS lives in one place. This also de-fangs the finding-14 contract change: the library's error responses now carry CORS regardless of whether a handler inspects serverError. Default policy is .none, so existing consumers are unaffected. Findings 10 and 14. * refactor: classify parse-error disposition (fatal vs recoverable) as an enum The parser decided which parse errors abort the connection vs. are surfaced to the handler with a single implicit condition (!= payloadTooLarge), and the fixture runners accepted an expected error via EITHER channel — so a refactor routing a smuggling-relevant error (e.g. conflicting Content-Length) into the recoverable/handler path would have kept every suite green while letting a malformed request reach the handler before auth. Make the classification typed data: HTTPRequestParseError gains a Disposition (fatal / recoverable), declared per case so a new error can't compile without one, and the parser throws on disposition == fatal. A lock test pins that only payloadTooLarge is recoverable, and the fixture runners now assert the channel matches the error's disposition (threw => fatal, pendingParseError => recoverable) on both platforms. Finding 17. * refactor: extract a shared namespaced REST URL builder RESTAPIRepository's site-root/namespace normalization was duplicated by the media uploader — the one endpoint that bypassed the canonical builder, so the two could drift. Extract it to a shared, tested helper (WordPressRESTURL on iOS, RestUrlBuilder on Android) and route RESTAPIRepository through it. No behavior change; pinned by the existing repository tests plus new builder tests. * fix: harden the native media upload server - Relay non-file multipart fields (post, additionalData) as raw bytes rather than round-tripping through String, which silently mangled any non-UTF-8 value. - Build the media endpoint through the shared URL builder, and carry the request query via percentEncodedQuery on iOS so a non-URL-safe value can't drop it. - Sweep crash-orphaned temp files off the caller's thread via an injectable scope and dispatcher (cancelled on stop; tests inject Unconfined to run it inline). Tests: iOS non-2xx relay, processed-file cleanup (both platforms), orphan-sweep age threshold, and the weak-delegate lifetime that keeps deinit teardown working. * fix(android): gate the upload server on reachability and align its timeouts - Only start when a REST uploader can be built (drop the unreachable cookie-auth branch that 500'd), and clear the advertised port on stop so the JS middleware routes to the default path instead of a dead port. - Skip start when cleartext to localhost is blocked, so a misconfigured host degrades gracefully rather than failing every upload. - Drop the total callTimeout for per-operation inactivity timeouts matching URLSession (15s connect, 60s read/write) so a large upload isn't capped. - Post the JS port/token sync to the WebView's UI thread. * fix: drop the media-upload fallback and relay errors faithfully The connection-error fallback re-ran a non-idempotent POST /wp/v2/media, which could duplicate an attachment, and pointed the wrong way under Lockdown Mode. Reachability is now gated natively, so remove it. Detect cancellation via signal.aborted and rethrow signal.reason (catches AbortSignal.timeout, not just AbortError). Normalize a non-JSON 2xx body to invalid_json like the non-ok path. * fix(android): report the re-encoded image type in the demo delegate The resize demo normalizes everything but PNG to JPEG (Bitmap.compress can't round-trip WebP/HEIC), but returned the original mime/filename — so a WebP upload was JPEG bytes labelled image/webp, which WordPress rejects. Report the actual output type and extension. * fix: route the upload server on the path, not the full target (#557) * feat(http): add path and query accessors to parsed requests The request target carries both the path and the query string, so callers that want to route on the path have to split it themselves. Expose `path` and `query` on both platforms' request types instead. A bare trailing "?" yields an empty query on both platforms, so the value can be appended to an upstream URL unconditionally. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: route the upload server on the path, not the full target Media uploads fail with a 404. `@wordpress/media-utils` uploads to `/wp/v2/media?_embed=wp:featuredmedia`, and the middleware now forwards that query on to the native server so it can be relayed to WordPress. The route guard still compared the full request target against "/upload", so a query string made it miss and return 404 before the upload handler ever ran. Match on `path` instead, and take the relayed query from `query` rather than re-deriving it in the handler. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(upload): normalize a bare trailing "?" to an empty query The native `query` accessors treat a bare trailing "?" as carrying no parameters and yield an empty string, so the value can be appended to an upstream URL unconditionally. The middleware that produces the target still derived the query with `indexOf`/`slice`, which keeps the "?" — `/wp/v2/media?` became `POST /upload?`, a target both platforms document as impossible. Extract `requestQuery` so the rule is stated once on this side and named against its native counterparts. Co-Authored-By: Claude Opus 4.8 (1M context) * test(upload): pin which upload branch relays the query Both `upload` and `passthroughUpload` record `lastQuery` on the mock, so asserting the query alone passes whichever branch ran — a regression that collapsed routing onto one path would still go green. Assert the passthrough branch explicitly, matching the sibling tests. Forcing `processFile` to return `.processed` now fails these tests; the query assertion alone did not. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(http): express `path` with prefix(upTo:) The explicit `startIndex.. --------- Co-authored-by: Claude Opus 4.8 (1M context) --------- Co-authored-by: David Calhoun Co-authored-by: Claude Opus 4.8 (1M context) --- Package.resolved | 11 +- .../http/InstrumentedFixtureTests.kt | 10 + .../org/wordpress/gutenberg/GutenbergView.kt | 107 ++++- .../org/wordpress/gutenberg/HttpServer.kt | 88 +++- .../wordpress/gutenberg/MediaUploadServer.kt | 370 +++++++++------- .../wordpress/gutenberg/RESTAPIRepository.kt | 30 +- .../org/wordpress/gutenberg/RestUrlBuilder.kt | 30 ++ .../gutenberg/http/HTTPRequestParser.kt | 12 +- .../gutenberg/http/HTTPRequestSerializer.kt | 54 ++- .../wordpress/gutenberg/HttpRequestTest.kt | 47 +++ .../gutenberg/MediaUploadServerTest.kt | 316 +++++++++++--- .../wordpress/gutenberg/RestUrlBuilderTest.kt | 47 +++ .../wordpress/gutenberg/http/FixtureTests.kt | 10 + .../gutenberg/http/HTTPRequestParserTests.kt | 15 + .../gutenbergkit/DemoMediaUploadDelegate.kt | 67 ++- ios/Demo-iOS/Sources/Views/EditorView.swift | 17 +- .../Sources/EditorHTTPClient.swift | 22 + .../Sources/EditorViewController.swift | 12 +- .../Sources/Media/MediaUploadDelegate.swift | 75 ++-- .../Sources/Media/MediaUploadServer.swift | 396 +++++++++--------- .../Sources/RESTAPIRepository.swift | 44 +- .../Sources/WordPressRESTURL.swift | 38 ++ ios/Sources/GutenbergKitHTTP/CORSPolicy.swift | 41 ++ .../GutenbergKitHTTP/HTTPRequestParser.swift | 10 +- .../HTTPRequestSerializer.swift | 30 +- ios/Sources/GutenbergKitHTTP/HTTPServer.swift | 30 +- .../GutenbergKitHTTP/ParsedHTTPRequest.swift | 23 + .../GutenbergKitHTTPTests/FixtureTests.swift | 5 +- .../HTTPRequestParserTests.swift | 11 + .../ParsedHTTPRequestTests.swift | 41 ++ .../Media/MediaUploadServerTests.swift | 386 +++++++++++++++-- .../WordPressRESTURLTests.swift | 57 +++ src/utils/api-fetch-upload-middleware.test.js | 250 +++++++++-- src/utils/api-fetch.js | 148 +++++-- 34 files changed, 2145 insertions(+), 705 deletions(-) create mode 100644 android/Gutenberg/src/main/java/org/wordpress/gutenberg/RestUrlBuilder.kt create mode 100644 android/Gutenberg/src/test/java/org/wordpress/gutenberg/HttpRequestTest.kt create mode 100644 android/Gutenberg/src/test/java/org/wordpress/gutenberg/RestUrlBuilderTest.kt create mode 100644 ios/Sources/GutenbergKit/Sources/WordPressRESTURL.swift create mode 100644 ios/Sources/GutenbergKitHTTP/CORSPolicy.swift create mode 100644 ios/Tests/GutenbergKitTests/WordPressRESTURLTests.swift diff --git a/Package.resolved b/Package.resolved index aacd18e8c..2ff636af3 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "6db3023106dfc39818a2a045dfbd8be56ad662c039842cf91e1f21a9bd7ce81f", + "originHash" : "c6b2e9abe520d144490a97766c0d11f9249bd0d35e0e6f16609262f5e91acea0", "pins" : [ { "identity" : "svgview", @@ -18,15 +18,6 @@ "revision" : "aa85ee96017a730031bafe411cde24a08a17a9c9", "version" : "2.8.8" } - }, - { - "identity" : "wordpress-rs", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Automattic/wordpress-rs", - "state" : { - "branch" : "alpha-20260313", - "revision" : "cde2fda82257f4ac7b81543d5b831bb267d4e52c" - } } ], "version" : 3 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 b3a558bda..ae739cedc 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 @@ -160,6 +160,11 @@ class InstrumentedFixtureTests { pendingError.errorId, "$description: expected $expectedError but got ${pendingError.errorId}" ) + assertEquals( + HTTPRequestParseError.Disposition.RECOVERABLE, + pendingError.disposition, + "$description: ${pendingError.errorId} surfaced via pendingParseError but is not RECOVERABLE" + ) } else { fail("$description: expected error $expectedError but parsing succeeded") } @@ -169,6 +174,11 @@ class InstrumentedFixtureTests { e.error.errorId, "$description: expected $expectedError but got ${e.error.errorId}" ) + assertEquals( + HTTPRequestParseError.Disposition.FATAL, + e.error.disposition, + "$description: ${e.error.errorId} was thrown but is not FATAL" + ) } } } 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 9756c943f..47b1fe28a 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -12,6 +12,7 @@ import android.net.Uri import android.os.Bundle import android.os.Handler import android.os.Looper +import android.security.NetworkSecurityPolicy import android.util.Log import android.view.Gravity import android.view.inputmethod.InputMethodManager @@ -119,16 +120,49 @@ class GutenbergView : FrameLayout { // 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 + // (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() } + // Reflect the resulting server state in the page: advertise a freshly + // (re)started server, or clear the port when the server did NOT + // (re)start (delegate cleared, auth missing, or cleartext-to-localhost + // blocked) so the JS upload middleware routes to the default path + // instead of fetching a now-dead port. + syncUploadServerJavaScriptVariables() } private var uploadServer: MediaUploadServer? = null - private val uploadHttpClient: okhttp3.OkHttpClient by lazy { okhttp3.OkHttpClient() } + private val uploadHttpClient: okhttp3.OkHttpClient by lazy { + // The read/write inactivity timeouts mirror URLSession's 60s + // timeoutIntervalForRequest default — an inactivity timer that resets on + // progress — so the iOS and Android upload clients behave the same. Like + // URLSession, which governs uploads with that inactivity timer rather than a + // total-duration cap, there is deliberately NO callTimeout here (unlike the + // sibling EditorHTTPClient, which caps total call duration — fine for small + // REST payloads, wrong for uploads): because each timeout resets on progress, + // a large upload is never failed on total duration, only on a genuine 60s + // stall in the active direction. + // + // - writeTimeout (upload): a transient loss of connectivity during the upload + // fails within ~60s so it surfaces and can be retried, rather than hanging. + // - readTimeout (download): gives WordPress time to generate image sub-sizes + // synchronously inside POST /wp/v2/media, during which it sends no response + // bytes. The bare OkHttpClient() 10s default fired mid-resize — and since the + // attachment row exists before resizing finishes, that orphaned it + // server-side and duplicated it on retry. + // - connectTimeout: a much shorter 15s — establishing a socket should be + // quick, so this fails fast on an unreachable host instead of making the + // user wait out the full window. (URLSession has no separate connect dial; + // it folds connection setup into the same 60s request timer.) + okhttp3.OkHttpClient.Builder() + .connectTimeout(CONNECT_TIMEOUT_SECONDS, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(UPLOAD_TIMEOUT_SECONDS, java.util.concurrent.TimeUnit.SECONDS) + .writeTimeout(UPLOAD_TIMEOUT_SECONDS, java.util.concurrent.TimeUnit.SECONDS) + .build() + } private var onFileChooserRequested: ((Intent, Int) -> Unit)? = null private var contentChangeListener: ContentChangeListener? = null @@ -613,10 +647,61 @@ class GutenbergView : FrameLayout { webView.evaluateJavascript(gbKitConfig, null) } + /** + * Syncs the current upload server's port and token into the already-loaded + * page. + * + * Advertises a running server so JS uploads route through it, and clears them + * (to `null`) when the server is stopped or was never started — so the JS + * upload middleware routes to the default path instead of fetching a now-dead + * port. The initial injection is handled by [setGlobalJavaScriptVariables] + * from `onPageStarted`; this keeps JS in sync when the server (re)starts or + * stops *after* the page has loaded (e.g. when [mediaUploadDelegate] is + * assigned, replaced, or cleared). + * + * The `window.GBKit` guard makes this a no-op before the page has loaded, so + * it is safe to call on the initial start too. + */ + private fun syncUploadServerJavaScriptVariables() { + val portJs = uploadServer?.port?.toString() ?: "null" + val tokenJs = uploadServer?.token?.let { JSONObject.quote(it) } ?: "null" + val js = """ + if (window.GBKit) { + window.GBKit.nativeUploadPort = $portJs; + window.GBKit.nativeUploadToken = $tokenJs; + localStorage.setItem('GBKit', JSON.stringify(window.GBKit)); + } + """.trimIndent() + // evaluateJavascript must run on the WebView's (UI) thread; post it so a + // delegate set from a background thread doesn't throw thread-affinity. + webView.post { webView.evaluateJavascript(js, null) } + } private fun startUploadServer() { + // The native upload server relays through DefaultMediaUploader, which needs a + // site root and an auth header (every host provides one — the editor injects + // it because the WebView has no auth cookies). Without both there is nothing + // to upload through, so leave the server down and let uploads fall to the + // default WebView path rather than start a server that could only fail. if (configuration.siteApiRoot.isEmpty() || configuration.authHeader.isEmpty()) return + // The editor reaches the loopback server over cleartext http://localhost. If + // the host app's network-security config doesn't permit cleartext to + // localhost, the WebView blocks every upload fetch (ERR_CLEARTEXT_NOT_PERMITTED) + // before it leaves the page. Detect that here and don't start the server, so + // the JS middleware routes uploads down the default path instead of a server + // it can never reach. Hosts that want native media processing must permit + // cleartext to localhost (see the demo's res/xml/network_security_config.xml). + if (!NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted(LOOPBACK_HOST)) { + Log.w( + TAG, + "Cleartext to $LOOPBACK_HOST is not permitted, so the native media upload " + + "server can't be reached from the WebView. Permit cleartext to $LOOPBACK_HOST " + + "in the app's network security config to enable native media processing." + ) + return + } + try { val defaultUploader = DefaultMediaUploader( httpClient = uploadHttpClient, @@ -627,8 +712,10 @@ class GutenbergView : FrameLayout { uploadServer = MediaUploadServer( uploadDelegate = mediaUploadDelegate, defaultUploader = defaultUploader, - cacheDir = context.cacheDir + cacheDir = context.cacheDir, + scope = coroutineScope ) + // JS is synced by the mediaUploadDelegate setter after this returns. } catch (e: Exception) { Log.w(TAG, "Failed to start upload server", e) } @@ -1144,6 +1231,18 @@ class GutenbergView : FrameLayout { private const val ASSET_LOADING_TIMEOUT_MS = 5000L + /** + * Read/write inactivity timeout for media uploads, matching URLSession's + * 60s `timeoutIntervalForRequest` default. See [uploadHttpClient]. + */ + private const val UPLOAD_TIMEOUT_SECONDS = 60L + + /** Connection-setup timeout for media uploads — short, to fail fast on an unreachable host. */ + private const val CONNECT_TIMEOUT_SECONDS = 15L + + /** Host the WebView uses to reach the loopback upload server (must match the JS fetch host). */ + private const val LOOPBACK_HOST = "localhost" + // Warmup state management private var warmupHandler: Handler? = null private var warmupRunnable: Runnable? = null 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 190f4cde7..8d0885539 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/HttpServer.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/HttpServer.kt @@ -45,6 +45,23 @@ data class HttpRequest( * for building an appropriate error response. */ val serverError: org.wordpress.gutenberg.http.HTTPRequestParseError? = null ) { + /** + * The path portion of [target], without the query component + * (e.g., "/wp/v2/posts" for "/wp/v2/posts?per_page=10"). + * + * Use this for routing — matching against [target] fails as soon as a + * client appends a query string. + */ + val path: String + get() = target.substringBefore('?') + + /** + * The query component of [target], including the leading "?" + * (e.g., "?per_page=10"), or an empty string when there is no query. + */ + val query: String + get() = target.substringAfter('?', "").let { if (it.isEmpty()) "" else "?$it" } + /** * Returns the value of the first header matching the given name (case-insensitive). */ @@ -71,6 +88,45 @@ data class HttpResponse( val body: ByteArray = ByteArray(0) ) +/** CORS behavior for an [HttpServer]. */ +enum class CorsPolicy { + /** No CORS headers are added (the default). */ + None, + + /** + * Permissive CORS for a loopback-only server serving a WebView: allows any + * origin and the methods/headers this library's clients use. The server + * answers OPTIONS preflight requests itself and stamps these headers on every + * response — including ones it generates internally (timeouts, parse errors) + * that never reach the handler. + */ + Permissive; + + /** Headers added to every response under this policy. */ + val responseHeaders: Map + get() = when (this) { + None -> emptyMap() + Permissive -> mapOf( + "Access-Control-Allow-Origin" to "*", + "Access-Control-Allow-Methods" to "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers" to "Authorization, Relay-Authorization, Content-Type", + "Access-Control-Max-Age" to "86400" + ) + } +} + +/** + * Returns a copy with [newHeaders] added, skipping any whose name + * (case-insensitive) is already present. + */ +private fun HttpResponse.addingHeadersIfAbsent(newHeaders: Map): HttpResponse { + if (newHeaders.isEmpty()) return this + val existing = headers.keys.map { it.lowercase() }.toSet() + val toAdd = newHeaders.filterKeys { it.lowercase() !in existing } + if (toAdd.isEmpty()) return this + return copy(headers = headers + toAdd) +} + /** * A lightweight local HTTP/1.1 server. * @@ -144,6 +200,7 @@ data class HttpResponse( * server.stop() * ``` */ +@Suppress("LongParameterList") class HttpServer( val name: String, private val requestedPort: Int = 0, @@ -154,6 +211,7 @@ class HttpServer( private val readTimeoutMs: Int = DEFAULT_READ_TIMEOUT_MS, private val idleTimeoutMs: Int = DEFAULT_IDLE_TIMEOUT_MS, private val cacheDir: File? = null, + private val cors: CorsPolicy = CorsPolicy.None, private val handler: suspend (HttpRequest) -> HttpResponse ) { @Volatile @@ -412,15 +470,7 @@ class HttpServer( body = parsed.body, parseDurationMs = parseDurationMs ) - val response = try { - handler(request) - } catch (e: Exception) { - Log.e(TAG, "Handler threw", e) - HttpResponse( - status = 500, - body = "Internal Server Error".toByteArray() - ) - } + val response = resolveResponse(request) sendResponse(socket, response) Log.d(TAG, "${parsed.method} ${parsed.target} → ${response.status} (${"%.1f".format(parseDurationMs)}ms)") } @@ -445,9 +495,27 @@ class HttpServer( } } + /** + * Resolves the response for a request: the CORS preflight (under a permissive + * policy) or the handler's response. Kept separate from [handleRequest] so + * that already-complex function doesn't grow. + */ + private suspend fun resolveResponse(request: HttpRequest): HttpResponse { + if (cors == CorsPolicy.Permissive && request.method.uppercase() == "OPTIONS") { + return HttpResponse(status = 204, body = ByteArray(0)) + } + return try { + handler(request) + } catch (e: Exception) { + Log.e(TAG, "Handler threw", e) + HttpResponse(status = 500, body = "Internal Server Error".toByteArray()) + } + } + private fun sendResponse(socket: Socket, response: HttpResponse) { + val decorated = response.addingHeadersIfAbsent(cors.responseHeaders) val output = socket.getOutputStream() - output.write(serializeResponse(response)) + output.write(serializeResponse(decorated)) output.flush() } 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 c264644bc..c289866a8 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt @@ -1,6 +1,11 @@ package org.wordpress.gutenberg import android.util.Log +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import org.wordpress.gutenberg.http.HeaderValue import org.wordpress.gutenberg.http.MultipartPart import org.wordpress.gutenberg.http.HTTPRequestParseError @@ -10,25 +15,44 @@ import java.io.IOException import java.util.UUID import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody import okio.source /** - * Result of a successful media upload to the remote WordPress server. + * A raw response from the WordPress REST API media endpoint. * - * Matches the format expected by Gutenberg's `onFileChange` callback. + * GutenbergKit relays this to the editor verbatim — it does not interpret the + * body. The editor receives the exact attachment object (on success) or + * WordPress REST error object (on failure) it would get from a direct upload, + * so every consumer — image sub-sizes, attachment links, error notices — + * behaves identically to a non-native upload. */ -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 +class MediaUploadResponse( + /** The HTTP status code WordPress (or the host's upload service) returned. */ + val statusCode: Int, + /** + * The raw response body — a WordPress REST attachment on success, or a + * WordPress REST error object (`{ "code", "message", "data" }`) on failure. + */ + val body: ByteArray ) +/** + * The result of a delegate's [MediaUploadDelegate.processFile]. + */ +sealed class ProcessedProxyFile { + /** The delegate did not modify the file; the original upload is forwarded unchanged. */ + data object Original : ProcessedProxyFile() + + /** + * The delegate produced a file to upload, along with its MIME type and + * filename. Both are used verbatim, so a format change (e.g. transcoding MOV + * to MP4, or an in-place EXIF strip) must report the resulting type and + * filename for WordPress to store the file correctly. + */ + data class Processed(val file: File, val mimeType: String, val filename: String) : ProcessedProxyFile() +} + /** * Interface for customizing media upload behavior. * @@ -38,15 +62,23 @@ data class MediaUploadResult( 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. + * + * Return [ProcessedProxyFile.Original] to upload the file unchanged, or + * [ProcessedProxyFile.Processed] with the processed file and its metadata. + * When the format changes, report the new mimeType and filename so WordPress + * stores it with the correct extension and type. */ - suspend fun processFile(file: File, mimeType: String): File = file + suspend fun processFile(file: File, mimeType: String, filename: String): ProcessedProxyFile = ProcessedProxyFile.Original /** * Upload a processed file to the remote WordPress site. - * Return the Gutenberg-compatible media result, or null to use the default uploader. + * + * Return the raw WordPress response (status code + body), which GutenbergKit + * relays to the editor unchanged, or null to use the default uploader. A host + * that uploads to WordPress should return the exact response it received so + * the editor sees a complete attachment object. */ - suspend fun uploadFile(file: File, mimeType: String, filename: String): MediaUploadResult? = null + suspend fun uploadFile(file: File, mimeType: String, filename: String): MediaUploadResponse? = null } /** @@ -64,7 +96,9 @@ interface MediaUploadDelegate { internal class MediaUploadServer( private val uploadDelegate: MediaUploadDelegate?, private val defaultUploader: DefaultMediaUploader?, - cacheDir: File? = null + cacheDir: File? = null, + scope: CoroutineScope = CoroutineScope(Dispatchers.IO), + ioDispatcher: CoroutineDispatcher = Dispatchers.IO ) { /** The port the server is listening on. */ val port: Int get() = server.port @@ -74,12 +108,34 @@ internal class MediaUploadServer( private val server: HttpServer + /** + * Directory for staging uploaded files, under the injected cache dir (with a + * system-temp fallback) so orphans share the app's managed cache lifecycle. + */ + private val uploadsTempDir: File = + File(cacheDir ?: File(System.getProperty("java.io.tmpdir")), "gutenbergkit-uploads") + + /** + * Sweeps crash-orphaned temp files off the caller's thread. Exposed so tests + * can await it; injecting `Dispatchers.Unconfined` for [ioDispatcher] runs the + * sweep synchronously. + */ + @Suppress("TooGenericExceptionCaught") + val cleanupJob: Job = scope.launch(ioDispatcher) { + try { + cleanOrphanedUploads() + } catch (e: Exception) { + Log.w(TAG, "Failed to sweep orphaned uploads", e) + } + } + init { server = HttpServer( name = "media-upload", externallyAccessible = false, requiresAuthentication = true, cacheDir = cacheDir, + cors = CorsPolicy.Permissive, handler = { request -> handleRequest(request) } ) server.start() @@ -87,9 +143,24 @@ internal class MediaUploadServer( /** Stops the server and releases resources. */ fun stop() { + cleanupJob.cancel() server.stop() } + /** + * Deletes upload temp files left behind by a prior crash. Files still in + * flight (only seconds old) are preserved by the age threshold, so this is + * safe even if another editor instance is mid-upload. + */ + private fun cleanOrphanedUploads() { + val cutoff = System.currentTimeMillis() - 60 * 60 * 1000L // 1 hour + uploadsTempDir.listFiles()?.forEach { file -> + if (file.lastModified() < cutoff) { + file.delete() + } + } + } + // MARK: - Request Handling private suspend fun handleRequest(request: HttpRequest): HttpResponse { @@ -103,14 +174,11 @@ internal class MediaUploadServer( 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") { - return corsPreflightResponse() - } - - // Route: only POST /upload is handled. - if (request.method.uppercase() != "POST" || request.target != "/upload") { + // Route: only POST /upload is handled. (OPTIONS preflight is answered by + // the HTTP library under its permissive CORS policy.) Match on the path + // alone — the target carries a query string (e.g. `?_embed`) that the + // upload handler relays on to WordPress. + if (request.method.uppercase() != "POST" || request.path != "/upload") { return errorResponse(404, "Not found") } @@ -118,21 +186,28 @@ internal class MediaUploadServer( } private suspend fun handleUpload(request: HttpRequest): HttpResponse { - val filePart = parseFilePart(request) + val parts = parseParts(request) ?: return errorResponse(400, "Expected multipart/form-data with a file") + val filePart = parts.firstOrNull { it.filename != null } + ?: return errorResponse(400, "Expected multipart/form-data with a file") + + // The non-file parts (post, additionalData) and the original query + // (e.g. ?_embed) must reach WordPress too — relay them alongside the file. + val extraParts = parts.filter { it.filename == null } + val query = request.query val tempFile = writePartToTempFile(filePart) ?: return errorResponse(500, "Failed to save file") - return processAndRespond(request, tempFile, filePart) + return processAndRespond(request, tempFile, filePart, extraParts, query) } - private fun parseFilePart(request: HttpRequest): MultipartPart? { + private fun parseParts(request: HttpRequest): List? { 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 { + return try { val inMemory = body.inMemoryData if (inMemory != null) { MultipartPart.parse(body, inMemory, 0L, boundary) @@ -145,16 +220,14 @@ internal class MediaUploadServer( } } catch (e: MultipartParseException) { Log.e(TAG, "Multipart parse failed", e) - return null + 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") + uploadsTempDir.mkdirs() + val tempFile = File(uploadsTempDir, "${UUID.randomUUID()}-$filename") return try { filePart.body.inputStream().use { input -> @@ -164,122 +237,141 @@ internal class MediaUploadServer( } tempFile } catch (e: IOException) { + tempFile.delete() Log.e(TAG, "Failed to write upload to disk", e) null } } + @Suppress("TooGenericExceptionCaught") private suspend fun processAndRespond( - request: HttpRequest, tempFile: File, filePart: MultipartPart + request: HttpRequest, tempFile: File, filePart: MultipartPart, + extraParts: List, query: String ): HttpResponse { - var processedFile: File? = null try { val uploadResult = processAndUpload( - tempFile, filePart.contentType, filePart.filename ?: "upload" + tempFile, filePart.contentType, filePart.filename ?: "upload", extraParts, query ) - val media = when (uploadResult) { + val response = when (uploadResult) { is UploadResult.Uploaded -> { - processedFile = uploadResult.processedFile - Log.d(TAG, "Uploading processed file to WordPress") - uploadResult.result + Log.d(TAG, "Uploaded file to WordPress") + uploadResult.response } 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) + performPassthroughUpload(request, query) } } - return successResponse(media) + // Relay WordPress's exact status and body to the editor so it sees + // the same attachment object (or error) as a direct upload. + return HttpResponse( + status = response.statusCode, + headers = mapOf("Content-Type" to "application/json"), + body = response.body + ) } catch (e: MediaUploadException) { Log.e(TAG, "Upload processing failed", e) return errorResponse(500, e.message ?: "Upload failed") + } catch (e: kotlin.coroutines.cancellation.CancellationException) { + throw e // Never swallow coroutine cancellation. + } catch (e: Exception) { + // Any other failure — IOException from the upload call, JSON parse + // errors, a throwing host delegate, or "no uploader configured" — + // must still be answered WITH CORS headers. Otherwise it escapes to + // HttpServer's header-less 500 fallback and the browser rejects the + // preflighted cross-origin fetch with an opaque "Failed to fetch", + // hiding the real error from the editor (mirrors the iOS catch-all). + Log.e(TAG, "Upload failed", e) + return errorResponse(500, e.message ?: "Upload failed") } finally { tempFile.delete() - processedFile?.let { if (it != tempFile) it.delete() } } } // MARK: - Delegate Pipeline private sealed class UploadResult { - data class Uploaded(val result: MediaUploadResult, val processedFile: File) : UploadResult() + data class Uploaded(val response: MediaUploadResponse) : UploadResult() data object Passthrough : UploadResult() } - private suspend fun performPassthroughUpload(request: HttpRequest): MediaUploadResult { + private suspend fun performPassthroughUpload(request: HttpRequest, query: String): MediaUploadResponse { 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) + return uploader.passthroughUpload(body, contentType, query) } private suspend fun processAndUpload( - file: File, mimeType: String, filename: String + file: File, mimeType: String, filename: String, + extraParts: List, query: String ): UploadResult { - val processedFile = uploadDelegate?.processFile(file, mimeType) ?: file - - // If the delegate provided its own upload, use that. - uploadDelegate?.uploadFile(processedFile, mimeType, filename)?.let { - return UploadResult.Uploaded(it, processedFile) + val processed = uploadDelegate?.processFile(file, mimeType, filename) ?: ProcessedProxyFile.Original + + // Resolve the file to upload and its metadata. Processed uses the + // delegate's values verbatim, so a format change is reported to WordPress. + val targetFile: File + val targetMimeType: String + val targetFilename: String + when (processed) { + is ProcessedProxyFile.Original -> { + targetFile = file + targetMimeType = mimeType + targetFilename = filename + } + is ProcessedProxyFile.Processed -> { + targetFile = processed.file + targetMimeType = processed.mimeType + targetFilename = processed.filename + } } - // 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 - } + try { + // If the delegate provided its own upload, use that. + uploadDelegate?.uploadFile(targetFile, targetMimeType, targetFilename)?.let { + return UploadResult.Uploaded(it) + } - val result = defaultUploader?.upload(processedFile, mimeType, filename) - ?: error("No upload delegate or default uploader configured") - return UploadResult.Uploaded(result, processedFile) + // Unmodified — forward the original request body directly, skipping + // multipart re-encoding. + if (processed is ProcessedProxyFile.Original) { + return UploadResult.Passthrough + } + + val result = defaultUploader?.upload(targetFile, targetMimeType, targetFilename, extraParts, query) + ?: error("No upload delegate or default uploader configured") + return UploadResult.Uploaded(result) + } finally { + // The processed file (if the delegate produced a new one) is ours to + // clean up — covers the success and throw paths alike. + if (targetFile != file) { + targetFile.delete() + } + } } // MARK: - Response Building - private val corsHeaders: Map = mapOf( - "Access-Control-Allow-Origin" to "*", - "Access-Control-Allow-Headers" to "Relay-Authorization, Content-Type" - ) - - private fun corsPreflightResponse(): HttpResponse = HttpResponse( - status = 204, - headers = corsHeaders + mapOf( - "Access-Control-Allow-Methods" to "POST, OPTIONS", - "Access-Control-Max-Age" to "86400" - ), - body = ByteArray(0) - ) - - private fun successResponse(media: MediaUploadResult): HttpResponse { - val json = org.json.JSONObject().apply { - put("id", media.id) - put("url", media.url) - put("alt", media.alt) - put("caption", media.caption) - put("title", media.title) - put("mime", media.mime) - put("type", media.type) - media.width?.let { put("width", it) } - media.height?.let { put("height", it) } - }.toString() - + private fun errorResponse(status: Int, message: String): HttpResponse { + // Emit a WordPress-REST-style error object so the JS middleware normalizes + // it (and surfaces `message`) the same way it does a relayed WordPress + // error — the local server's own errors need no special-casing. + val json = org.json.JSONObject() + .put("code", "upload_error") + .put("message", message) + .toString() return HttpResponse( - status = 200, - headers = corsHeaders + mapOf("Content-Type" to "application/json"), + status = status, + headers = 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. */ @@ -305,24 +397,33 @@ 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" - } + /** + * The WordPress media endpoint URL, built through the shared [RestUrlBuilder] + * namespacing (so it matches every other REST URL) and carrying the original + * request query (e.g. `?_embed`) through to WordPress. + */ + private fun mediaEndpointUrl(query: String): String = + RestUrlBuilder.namespaced(siteApiRoot, siteApiNamespace.firstOrNull(), "/wp/v2/media") + query - open suspend fun upload(file: File, mimeType: String, filename: String): MediaUploadResult { + open suspend fun upload( + file: File, mimeType: String, filename: String, + extraParts: List, query: String + ): MediaUploadResponse { val mediaType = mimeType.toMediaType() - val requestBody = okhttp3.MultipartBody.Builder() - .setType(okhttp3.MultipartBody.FORM) - .addFormDataPart("file", filename, file.asRequestBody(mediaType)) - .build() + val builder = okhttp3.MultipartBody.Builder().setType(okhttp3.MultipartBody.FORM) + // Preserve the non-file parts (post, additionalData) through the re-encode. + // Append each field's raw bytes (not via String) so a non-UTF-8 value is + // forwarded verbatim rather than coerced. filename=null makes it a plain + // field, matching okhttp's String overload byte-for-byte. + for (part in extraParts) { + builder.addFormDataPart(part.name, null, part.body.readBytes().toRequestBody()) + } + builder.addFormDataPart("file", filename, file.asRequestBody(mediaType)) val request = okhttp3.Request.Builder() - .url(mediaEndpointUrl) + .url(mediaEndpointUrl(query)) .addHeader("Authorization", authHeader) - .post(requestBody) + .post(builder.build()) .build() return performUpload(request) @@ -336,8 +437,9 @@ internal open class DefaultMediaUploader( */ open suspend fun passthroughUpload( body: org.wordpress.gutenberg.http.RequestBody, - contentType: String - ): MediaUploadResult { + contentType: String, + query: String + ): MediaUploadResponse { val streamBody = object : okhttp3.RequestBody() { override fun contentType() = contentType.toMediaType() override fun contentLength() = body.size @@ -347,7 +449,7 @@ internal open class DefaultMediaUploader( } val request = okhttp3.Request.Builder() - .url(mediaEndpointUrl) + .url(mediaEndpointUrl(query)) .addHeader("Authorization", authHeader) .post(streamBody) .build() @@ -355,44 +457,12 @@ internal open class DefaultMediaUploader( return performUpload(request) } - private fun performUpload(request: okhttp3.Request): MediaUploadResult { - val response = httpClient.newCall(request).execute() - val body = response.body?.string() - - if (!response.isSuccessful) { - // Try to extract the human-readable message from a WordPress error - // response ({"code":"...","message":"..."}) before falling back to - // the raw body. - val errorMessage = body?.let { - try { org.json.JSONObject(it).optString("message", null) } catch (_: org.json.JSONException) { null } - } ?: body ?: response.message - throw MediaUploadException(errorMessage) + private fun performUpload(request: okhttp3.Request): MediaUploadResponse { + // Relay WordPress's response verbatim — including non-2xx statuses — so + // the editor sees WordPress's real status and error body, exactly as a + // direct upload would. + return httpClient.newCall(request).execute().use { response -> + MediaUploadResponse(response.code, response.body?.bytes() ?: ByteArray(0)) } - - 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/RESTAPIRepository.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt index 692ca79ca..14375f39b 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt @@ -23,10 +23,6 @@ class RESTAPIRepository( ) { private val json = Json { ignoreUnknownKeys = true } - private val apiRoot = configuration.siteApiRoot.trimEnd('/') - private val namespace = configuration.siteApiNamespace.firstOrNull()?.let { - it.trimEnd('/') + "/" - } private val editorSettingsUrl = buildNamespacedUrl(EDITOR_SETTINGS_PATH) private val activeThemeUrl = buildNamespacedUrl(ACTIVE_THEME_PATH) private val siteSettingsUrl = buildNamespacedUrl(SITE_SETTINGS_PATH) @@ -217,25 +213,13 @@ class RESTAPIRepository( return urlResponse } - /** - * Builds a URL from the API root and path, inserting the site API namespace - * after the version segment if one is configured. - * - * For example, with namespace `sites/123/` and path `/wp/v2/types`: - * the result is `$apiRoot/wp/v2/sites/123/types`. - */ - private fun buildNamespacedUrl(path: String): String { - if (namespace == null) { - return "$apiRoot$path" - } - - val parts = path.removePrefix("/").split("/", limit = 3) - if (parts.size < 3) { - return "$apiRoot$path" - } - - return "$apiRoot/${parts[0]}/${parts[1]}/$namespace${parts[2]}" - } + /** Builds a namespaced REST URL via the shared [RestUrlBuilder]. */ + private fun buildNamespacedUrl(path: String): String = + RestUrlBuilder.namespaced( + configuration.siteApiRoot, + configuration.siteApiNamespace.firstOrNull(), + path + ) companion object { private const val EDITOR_SETTINGS_PATH = "/wp-block-editor/v1/settings" diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RestUrlBuilder.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RestUrlBuilder.kt new file mode 100644 index 000000000..068d60141 --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RestUrlBuilder.kt @@ -0,0 +1,30 @@ +package org.wordpress.gutenberg + +/** + * Single source of truth for building namespaced WordPress REST API URLs, so the + * media endpoint and every [RESTAPIRepository] endpoint normalize the site API + * root and namespace identically (no drift). + */ +internal object RestUrlBuilder { + /** + * Builds a URL from [siteApiRoot] and [path], inserting [siteApiNamespace] + * after the version segment if one is configured. A `null` namespace appends + * the path unchanged. + * + * Trailing slashes on the root and namespace are normalized, so an unslashed + * root or namespace still joins cleanly. For example, with namespace `sites/123` + * and path `/wp/v2/types`, the result is `$root/wp/v2/sites/123/types`. + */ + fun namespaced(siteApiRoot: String, siteApiNamespace: String?, path: String): String { + val root = siteApiRoot.trimEnd('/') + val namespace = siteApiNamespace?.let { it.trimEnd('/') + "/" } + ?: return "$root$path" + + val parts = path.removePrefix("/").split("/", limit = 3) + if (parts.size < 3) { + return "$root$path" + } + + return "$root/${parts[0]}/${parts[1]}/$namespace${parts[2]}" + } +} 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 0157386e4..004ad53aa 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 @@ -229,11 +229,13 @@ class HTTPRequestParser( fun parseRequest(): ParsedHTTPRequest? = synchronized(lock) { if (!_state.hasHeaders) return null - // 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) } + // Recoverable errors (e.g. payloadTooLarge — valid headers, rejected + // body) are surfaced to the caller so the handler can build a response. + // Fatal errors indicate genuinely malformed requests and are thrown, + // closing the connection before the handler runs. + parseError?.let { + if (it.disposition == HTTPRequestParseError.Disposition.FATAL) throw HTTPRequestParseException(it) + } if (parsedHeaders == null) { val headerData = buffer.read(0, minOf(bytesWritten, MAX_HEADER_SIZE.toLong()).toInt()) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/http/HTTPRequestSerializer.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/http/HTTPRequestSerializer.kt index 5a56493ea..be00ffba2 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/http/HTTPRequestSerializer.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/http/HTTPRequestSerializer.kt @@ -7,25 +7,43 @@ enum class HTTPRequestParseError( /** The HTTP status code that should be sent for this error. */ val httpStatus: Int, /** A camelCase identifier matching the Swift error case names and JSON fixture keys. */ - val errorId: String + val errorId: String, + /** + * Whether this error aborts the connection ([Disposition.FATAL], thrown so the + * handler never runs) or is surfaced to the handler via `pendingParseError` + * ([Disposition.RECOVERABLE]) so it can build a response. Only genuinely + * recoverable errors — request line and headers well-formed — may be + * RECOVERABLE; anything smuggling-relevant (framing, Content-Length) must stay + * FATAL so the request never reaches the handler. + */ + val disposition: Disposition ) { - EMPTY_HEADER_SECTION(400, "emptyHeaderSection"), - MALFORMED_REQUEST_LINE(400, "malformedRequestLine"), - OBS_FOLD_DETECTED(400, "obsFoldDetected"), - WHITESPACE_BEFORE_COLON(400, "whitespaceBeforeColon"), - INVALID_CONTENT_LENGTH(400, "invalidContentLength"), - CONFLICTING_CONTENT_LENGTH(400, "conflictingContentLength"), - UNSUPPORTED_TRANSFER_ENCODING(400, "unsupportedTransferEncoding"), - INVALID_HTTP_VERSION(400, "invalidHTTPVersion"), - INVALID_FIELD_NAME(400, "invalidFieldName"), - INVALID_FIELD_VALUE(400, "invalidFieldValue"), - MISSING_HOST_HEADER(400, "missingHostHeader"), - MULTIPLE_HOST_HEADERS(400, "multipleHostHeaders"), - PAYLOAD_TOO_LARGE(413, "payloadTooLarge"), - HEADERS_TOO_LARGE(431, "headersTooLarge"), - TOO_MANY_HEADERS(431, "tooManyHeaders"), - INVALID_ENCODING(400, "invalidEncoding"), - BUFFER_IO_ERROR(500, "bufferIOError"); + EMPTY_HEADER_SECTION(400, "emptyHeaderSection", Disposition.FATAL), + MALFORMED_REQUEST_LINE(400, "malformedRequestLine", Disposition.FATAL), + OBS_FOLD_DETECTED(400, "obsFoldDetected", Disposition.FATAL), + WHITESPACE_BEFORE_COLON(400, "whitespaceBeforeColon", Disposition.FATAL), + INVALID_CONTENT_LENGTH(400, "invalidContentLength", Disposition.FATAL), + CONFLICTING_CONTENT_LENGTH(400, "conflictingContentLength", Disposition.FATAL), + UNSUPPORTED_TRANSFER_ENCODING(400, "unsupportedTransferEncoding", Disposition.FATAL), + INVALID_HTTP_VERSION(400, "invalidHTTPVersion", Disposition.FATAL), + INVALID_FIELD_NAME(400, "invalidFieldName", Disposition.FATAL), + INVALID_FIELD_VALUE(400, "invalidFieldValue", Disposition.FATAL), + MISSING_HOST_HEADER(400, "missingHostHeader", Disposition.FATAL), + MULTIPLE_HOST_HEADERS(400, "multipleHostHeaders", Disposition.FATAL), + PAYLOAD_TOO_LARGE(413, "payloadTooLarge", Disposition.RECOVERABLE), + HEADERS_TOO_LARGE(431, "headersTooLarge", Disposition.FATAL), + TOO_MANY_HEADERS(431, "tooManyHeaders", Disposition.FATAL), + INVALID_ENCODING(400, "invalidEncoding", Disposition.FATAL), + BUFFER_IO_ERROR(500, "bufferIOError", Disposition.FATAL); + + /** How the parser disposes of a parse error. */ + enum class Disposition { + /** Abort the connection; the malformed request never reaches the handler. */ + FATAL, + + /** Surface to the handler via `pendingParseError` so it can build a response. */ + RECOVERABLE + } } /** diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/HttpRequestTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/HttpRequestTest.kt new file mode 100644 index 000000000..6aae33ebd --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/HttpRequestTest.kt @@ -0,0 +1,47 @@ +package org.wordpress.gutenberg + +import org.junit.Assert.assertEquals +import org.junit.Test + +class HttpRequestTest { + + private fun request(target: String) = HttpRequest( + method = "POST", + target = target, + headers = emptyMap() + ) + + @Test + fun `path is the whole target when there is no query`() { + assertEquals("/upload", request("/upload").path) + assertEquals("", request("/upload").query) + } + + @Test + fun `path and query split on the first question mark`() { + val parsed = request("/upload?_embed=wp:featuredmedia") + assertEquals("/upload", parsed.path) + assertEquals("?_embed=wp:featuredmedia", parsed.query) + } + + @Test + fun `a trailing question mark yields an empty query`() { + val parsed = request("/upload?") + assertEquals("/upload", parsed.path) + assertEquals("", parsed.query) + } + + @Test + fun `later question marks belong to the query`() { + val parsed = request("/search?q=a?b") + assertEquals("/search", parsed.path) + assertEquals("?q=a?b", parsed.query) + } + + @Test + fun `multiple query parameters are preserved`() { + val parsed = request("/wp/v2/posts?per_page=10&page=2") + assertEquals("/wp/v2/posts", parsed.path) + assertEquals("?per_page=10&page=2", parsed.query) + } +} 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 86ad0c51d..7ce925f0f 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt @@ -1,6 +1,7 @@ package org.wordpress.gutenberg import com.google.gson.JsonParser +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -100,6 +101,38 @@ class MediaUploadServerTest { assertTrue(response.statusLine.contains("404")) } + @Test + fun `routes upload with a query string and relays the query`() { + val delegate = ProcessOnlyDelegate() + val mockUploader = MockDefaultUploader() + server.stop() + server = MediaUploadServer(uploadDelegate = delegate, defaultUploader = mockUploader, cacheDir = tempFolder.root) + + // `@wordpress/media-utils` uploads to `/wp/v2/media?_embed=wp:featuredmedia`, + // so the middleware forwards that query on to the native server. Routing must + // match on the path alone, and the query must reach WordPress unchanged. + val boundary = "test-boundary-query" + val body = buildMultipartBody(boundary, "photo.jpg", "image/jpeg", "fake image data".toByteArray()) + + val response = sendRawRequest( + method = "POST", + path = "/upload?_embed=wp:featuredmedia", + headers = mapOf( + "Relay-Authorization" to "Bearer ${server.token}", + "Content-Type" to "multipart/form-data; boundary=$boundary" + ), + body = body + ) + + assertTrue("Expected 201 but got: ${response.statusLine}", response.statusLine.contains("201")) + // The delegate returns Original, so this is the passthrough branch. + // Pin which branch ran — `lastQuery` is recorded by both, so without this + // the query assertion would pass even if routing collapsed onto one path. + assertTrue(mockUploader.passthroughUploadCalled) + assertFalse(mockUploader.uploadCalled) + assertEquals("?_embed=wp:featuredmedia", mockUploader.lastQuery) + } + // MARK: - Upload with delegate @Test @@ -121,16 +154,97 @@ class MediaUploadServerTest { body = body ) - assertTrue("Expected 200 but got: ${response.statusLine}", response.statusLine.contains("200")) + assertTrue("Expected 201 but got: ${response.statusLine}", response.statusLine.contains("201")) assertTrue(delegate.processFileCalled) assertTrue(delegate.uploadFileCalled) assertEquals("image/jpeg", delegate.lastMimeType) assertEquals("photo.jpg", delegate.lastFilename) + // The server relays WordPress's raw response body verbatim. 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) + assertEquals("https://example.com/photo.jpg", json.get("source_url").asString) + assertEquals("image", json.get("media_type").asString) + } + + @Test + fun `forwards the delegate's processed metadata to the uploader`() { + val delegate = TranscodingDelegate() + val mockUploader = MockDefaultUploader() + server.stop() + server = MediaUploadServer(uploadDelegate = delegate, defaultUploader = mockUploader, cacheDir = tempFolder.root) + + val boundary = "test-boundary-meta" + val body = buildMultipartBody(boundary, "clip.mov", "video/quicktime", "movie".toByteArray()) + + sendRawRequest( + method = "POST", + path = "/upload", + headers = mapOf( + "Relay-Authorization" to "Bearer ${server.token}", + "Content-Type" to "multipart/form-data; boundary=$boundary" + ), + body = body + ) + + // The delegate changed the format, so the uploader must receive the new + // metadata — not the original video/quicktime + clip.mov. + assertTrue(mockUploader.uploadCalled) + assertEquals("video/mp4", mockUploader.lastUploadMimeType) + assertEquals("clip.mp4", mockUploader.lastUploadFilename) + } + + @Test + fun `deletes the delegate's processed file after upload`() { + val delegate = TranscodingDelegate() + val mockUploader = MockDefaultUploader() + server.stop() + server = MediaUploadServer(uploadDelegate = delegate, defaultUploader = mockUploader, cacheDir = tempFolder.root) + + val boundary = "test-boundary-cleanup" + val body = buildMultipartBody(boundary, "clip.mov", "video/quicktime", "movie".toByteArray()) + + sendRawRequest( + method = "POST", + path = "/upload", + headers = mapOf( + "Relay-Authorization" to "Bearer ${server.token}", + "Content-Type" to "multipart/form-data; boundary=$boundary" + ), + body = body + ) + + // The server owns the file the delegate produced and must delete it once the + // upload finishes — the finally in processAndUpload covers success and throw + // paths alike. A leaked processed file is a full-size temp per upload. + val processed = requireNotNull(delegate.producedFile) { "processFile was not called" } + assertFalse("Processed temp file should be deleted after upload", processed.exists()) + } + + @Test + fun `startup sweep deletes stale upload temps but preserves fresh ones`() { + val uploadsDir = File(tempFolder.root, "gutenbergkit-uploads").apply { mkdirs() } + val stale = File(uploadsDir, "stale.tmp").apply { writeText("x") } + val fresh = File(uploadsDir, "fresh.tmp").apply { writeText("y") } + // Backdate the stale file well past the 1-hour cutoff. + assertTrue( + "Could not backdate the stale file", + stale.setLastModified(System.currentTimeMillis() - 2 * 60 * 60 * 1000L) + ) + + // The sweep runs via the injected dispatcher; Unconfined runs it synchronously + // so we can assert immediately. It must delete the aged file and keep the fresh + // one — a flipped comparison would do the opposite and wipe an in-flight upload. + server.stop() + server = MediaUploadServer( + uploadDelegate = null, + defaultUploader = null, + cacheDir = tempFolder.root, + ioDispatcher = Dispatchers.Unconfined + ) + + assertFalse("Stale temp should have been swept", stale.exists()) + assertTrue("Fresh temp should be preserved", fresh.exists()) } // MARK: - Fallback to default uploader @@ -156,7 +270,7 @@ class MediaUploadServerTest { body = body ) - assertTrue("Expected 200 but got: ${response.statusLine}", response.statusLine.contains("200")) + assertTrue("Expected 201 but got: ${response.statusLine}", response.statusLine.contains("201")) assertTrue(delegate.processFileCalled) // Passthrough: original body forwarded directly, not re-encoded. assertTrue(mockUploader.passthroughUploadCalled) @@ -169,20 +283,15 @@ class MediaUploadServerTest { // MARK: - DefaultMediaUploader @Test - fun `DefaultMediaUploader sends correct request to WP REST API`() { + fun `DefaultMediaUploader relays the WordPress response`() { 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. + val wpBody = + """{"id":1,"source_url":"https://example.com/u.jpg","media_type":"image"}""" mockWpServer.enqueue( MockResponse() - .setResponseCode(200) + .setResponseCode(201) .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"}""" - ) + .setBody(wpBody) ) mockWpServer.start() @@ -196,13 +305,11 @@ class MediaUploadServerTest { 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 response = runBlocking { uploader.upload(file, "image/jpeg", "image.jpg", emptyList(), "") } + + // The uploader relays WordPress's exact status and body — no parsing. + assertEquals(201, response.statusCode) + assertEquals(wpBody, String(response.body)) val request = mockWpServer.takeRequest() assertEquals("POST", request.method) @@ -214,7 +321,7 @@ class MediaUploadServerTest { } @Test - fun `DefaultMediaUploader throws on server error`() { + fun `DefaultMediaUploader relays a WordPress error response instead of throwing`() { val mockWpServer = MockWebServer() mockWpServer.enqueue(MockResponse().setResponseCode(500).setBody("Internal error")) mockWpServer.start() @@ -229,13 +336,104 @@ class MediaUploadServerTest { val file = tempFolder.newFile("fail.jpg") file.writeBytes("data".toByteArray()) - try { - runBlocking { uploader.upload(file, "image/jpeg", "fail.jpg") } - throw AssertionError("Expected exception") - } catch (e: MediaUploadException) { - assertTrue(e.message!!.contains("Internal error")) + // WordPress's error status + body flow through to the editor, which + // surfaces the real message — the uploader does not throw. + val response = runBlocking { uploader.upload(file, "image/jpeg", "fail.jpg", emptyList(), "") } + assertEquals(500, response.statusCode) + assertEquals("Internal error", String(response.body)) + + mockWpServer.shutdown() + } + + @Test + fun `DefaultMediaUploader normalizes an unslashed root and namespace`() { + val mockWpServer = MockWebServer() + mockWpServer.enqueue(MockResponse().setResponseCode(201).setBody("{}")) + mockWpServer.start() + + val uploader = DefaultMediaUploader( + httpClient = okhttp3.OkHttpClient(), + siteApiRoot = mockWpServer.url("/wp-json").toString(), // no trailing slash + authHeader = "Bearer test-token", + siteApiNamespace = listOf("sites/123") // no trailing slash + ) + val file = tempFolder.newFile("image.jpg") + file.writeBytes("x".toByteArray()) + + runBlocking { uploader.upload(file, "image/jpeg", "image.jpg", emptyList(), "") } + + assertEquals("/wp-json/wp/v2/sites/123/media", mockWpServer.takeRequest().path) + + mockWpServer.shutdown() + } + + @Test + fun `DefaultMediaUploader re-encode preserves extra parts and query`() { + val mockWpServer = MockWebServer() + mockWpServer.enqueue(MockResponse().setResponseCode(201).setBody("{}")) + mockWpServer.start() + + val uploader = DefaultMediaUploader( + httpClient = okhttp3.OkHttpClient(), + siteApiRoot = mockWpServer.url("/wp-json/").toString(), + authHeader = "Bearer test-token" + ) + val file = tempFolder.newFile("image.jpg") + file.writeBytes("fake image".toByteArray()) + + val postPart = org.wordpress.gutenberg.http.MultipartPart( + name = "post", + filename = null, + contentType = "text/plain", + body = org.wordpress.gutenberg.http.RequestBody.InMemory("123".toByteArray()) + ) + + runBlocking { + uploader.upload(file, "image/jpeg", "image.jpg", listOf(postPart), "?_embed=wp:featuredmedia") + } + + val request = mockWpServer.takeRequest() + // The query and the non-file part must both reach WordPress. + assertTrue(request.path!!.contains("_embed")) + val bodyText = request.body.readUtf8() + assertTrue("Expected post field in multipart body", bodyText.contains("name=\"post\"")) + assertTrue(bodyText.contains("123")) + + mockWpServer.shutdown() + } + + @Test + fun `re-encode forwards a non-UTF-8 field value verbatim`() { + val mockWpServer = MockWebServer() + mockWpServer.enqueue(MockResponse().setResponseCode(201).setBody("{}")) + mockWpServer.start() + + val uploader = DefaultMediaUploader( + httpClient = okhttp3.OkHttpClient(), + siteApiRoot = mockWpServer.url("/wp-json/").toString(), + authHeader = "Bearer test-token" + ) + val file = tempFolder.newFile("image.jpg") + file.writeBytes("fake image".toByteArray()) + + // A value that is not valid UTF-8 (a lone 0xFF byte between two ASCII bytes). + val binaryValue = byteArrayOf(0x61, 0xFF.toByte(), 0x62) + val blobPart = org.wordpress.gutenberg.http.MultipartPart( + name = "blob", + filename = null, + contentType = "application/octet-stream", + body = org.wordpress.gutenberg.http.RequestBody.InMemory(binaryValue) + ) + + runBlocking { + uploader.upload(file, "image/jpeg", "image.jpg", listOf(blobPart), "") } + // The raw 0xFF byte survives verbatim — not coerced to a replacement char. + val bodyBytes = mockWpServer.takeRequest().body.readByteArray() + val found = bodyBytes.toList().windowed(binaryValue.size).any { it == binaryValue.toList() } + assertTrue("Non-UTF-8 field value should pass through verbatim", found) + mockWpServer.shutdown() } @@ -355,31 +553,39 @@ class MediaUploadServerTest { @Volatile var lastMimeType: String? = null @Volatile var lastFilename: String? = null - override suspend fun processFile(file: File, mimeType: String): File { + override suspend fun processFile(file: File, mimeType: String, filename: String): ProcessedProxyFile { processFileCalled = true lastMimeType = mimeType - return file + return ProcessedProxyFile.Original } - override suspend fun uploadFile(file: File, mimeType: String, filename: String): MediaUploadResult? { + override suspend fun uploadFile(file: File, mimeType: String, filename: String): MediaUploadResponse? { uploadFileCalled = true lastFilename = filename - return MediaUploadResult( - id = 42, - url = "https://example.com/photo.jpg", - title = "photo", - mime = "image/jpeg", - type = "image" - ) + val json = """{"id":42,"source_url":"https://example.com/photo.jpg","media_type":"image"}""" + return MediaUploadResponse(201, json.toByteArray()) } } private class ProcessOnlyDelegate : MediaUploadDelegate { @Volatile var processFileCalled = false - override suspend fun processFile(file: File, mimeType: String): File { + override suspend fun processFile(file: File, mimeType: String, filename: String): ProcessedProxyFile { processFileCalled = true - return file + return ProcessedProxyFile.Original + } + } + + /** A delegate that produces a new file with changed metadata (e.g. a transcode). */ + private class TranscodingDelegate : MediaUploadDelegate { + /** The processed file this delegate wrote, for cleanup assertions. */ + @Volatile var producedFile: File? = null + + override suspend fun processFile(file: File, mimeType: String, filename: String): ProcessedProxyFile { + val newFile = File(file.parentFile, "processed-${file.name}") + newFile.writeBytes("processed".toByteArray()) + producedFile = newFile + return ProcessedProxyFile.Processed(newFile, "video/mp4", "clip.mp4") } } @@ -390,26 +596,34 @@ class MediaUploadServerTest { ) { @Volatile var uploadCalled = false @Volatile var passthroughUploadCalled = false - - override suspend fun upload(file: File, mimeType: String, filename: String): MediaUploadResult { + @Volatile var lastUploadMimeType: String? = null + @Volatile var lastUploadFilename: String? = null + @Volatile var lastQuery: String? = null + + override suspend fun upload( + file: File, mimeType: String, filename: String, + extraParts: List, query: String + ): MediaUploadResponse { uploadCalled = true - return mockResult() + lastUploadMimeType = mimeType + lastUploadFilename = filename + lastQuery = query + return mockResponse() } override suspend fun passthroughUpload( body: org.wordpress.gutenberg.http.RequestBody, - contentType: String - ): MediaUploadResult { + contentType: String, + query: String + ): MediaUploadResponse { passthroughUploadCalled = true - return mockResult() + lastQuery = query + return mockResponse() } - private fun mockResult() = MediaUploadResult( - id = 99, - url = "https://example.com/doc.pdf", - title = "doc", - mime = "application/pdf", - type = "file" + private fun mockResponse() = MediaUploadResponse( + 201, + """{"id":99,"source_url":"https://example.com/doc.pdf","media_type":"file"}""".toByteArray() ) } diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RestUrlBuilderTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RestUrlBuilderTest.kt new file mode 100644 index 000000000..73065c5fb --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RestUrlBuilderTest.kt @@ -0,0 +1,47 @@ +package org.wordpress.gutenberg + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RestUrlBuilderTest { + + @Test + fun `appends the path when no namespace is configured`() { + assertEquals( + "https://example.com/wp-json/wp/v2/media", + RestUrlBuilder.namespaced("https://example.com/wp-json", null, "/wp/v2/media") + ) + } + + @Test + fun `inserts the namespace after the version segment`() { + assertEquals( + "https://example.com/wp-json/wp/v2/sites/123/media", + RestUrlBuilder.namespaced("https://example.com/wp-json", "sites/123/", "/wp/v2/media") + ) + } + + @Test + fun `normalizes an unslashed root and namespace`() { + assertEquals( + "https://example.com/wp-json/wp/v2/sites/123/media", + RestUrlBuilder.namespaced("https://example.com/wp-json", "sites/123", "/wp/v2/media") + ) + } + + @Test + fun `does not double the slash when the root already ends in one`() { + assertEquals( + "https://example.com/wp-json/wp/v2/sites/123/media", + RestUrlBuilder.namespaced("https://example.com/wp-json/", "sites/123", "/wp/v2/media") + ) + } + + @Test + fun `inserts the namespace after a non-wp-v2 version segment`() { + assertEquals( + "https://example.com/wp-json/wp-block-editor/v1/sites/123/settings", + RestUrlBuilder.namespaced("https://example.com/wp-json", "sites/123", "/wp-block-editor/v1/settings") + ) + } +} 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 012ba42a3..d7459e684 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 @@ -158,6 +158,11 @@ class FixtureTests { pendingError.errorId, "$description: expected $expectedError but got ${pendingError.errorId}" ) + assertEquals( + HTTPRequestParseError.Disposition.RECOVERABLE, + pendingError.disposition, + "$description: ${pendingError.errorId} surfaced via pendingParseError but is not RECOVERABLE" + ) } else { fail("$description: expected error $expectedError but parsing succeeded") } @@ -167,6 +172,11 @@ class FixtureTests { e.error.errorId, "$description: expected $expectedError but got ${e.error.errorId}" ) + assertEquals( + HTTPRequestParseError.Disposition.FATAL, + e.error.disposition, + "$description: ${e.error.errorId} was thrown but is not FATAL" + ) } } } 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 abdc9c2e1..060a211e5 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 @@ -15,6 +15,21 @@ import org.junit.Test */ class HTTPRequestParserTests { + // MARK: - Error Disposition + + /** + * Locks the fatal/recoverable classification so a refactor can't silently + * make a smuggling-relevant error recoverable — which would let a malformed + * request reach the handler before auth. + */ + @Test + fun `only payloadTooLarge is recoverable`() { + val recoverable = HTTPRequestParseError.entries.filter { + it.disposition == HTTPRequestParseError.Disposition.RECOVERABLE + } + assertEquals(listOf(HTTPRequestParseError.PAYLOAD_TOO_LARGE), recoverable) + } + // MARK: - Duplicate Header Key Casing (Internal Dict Representation) @Test diff --git a/android/app/src/main/java/com/example/gutenbergkit/DemoMediaUploadDelegate.kt b/android/app/src/main/java/com/example/gutenbergkit/DemoMediaUploadDelegate.kt index 7f390546a..9524bb278 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/DemoMediaUploadDelegate.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/DemoMediaUploadDelegate.kt @@ -2,9 +2,13 @@ package com.example.gutenbergkit import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.media.ExifInterface import android.util.Log import org.wordpress.gutenberg.MediaUploadDelegate +import org.wordpress.gutenberg.ProcessedProxyFile import java.io.File +import java.io.IOException /** * Demo media upload delegate that resizes images to a maximum dimension of 2000px. @@ -16,9 +20,9 @@ class DemoMediaUploadDelegate : MediaUploadDelegate { private const val TAG = "DemoMediaUploadDelegate" } - override suspend fun processFile(file: File, mimeType: String): File { + override suspend fun processFile(file: File, mimeType: String, filename: String): ProcessedProxyFile { if (!mimeType.startsWith("image/") || mimeType == "image/gif") { - return file + return ProcessedProxyFile.Original } val maxDimension = 2000 @@ -30,17 +34,17 @@ class DemoMediaUploadDelegate : MediaUploadDelegate { val width = options.outWidth val height = options.outHeight - if (width <= 0 || height <= 0) return file + if (width <= 0 || height <= 0) return ProcessedProxyFile.Original val longestSide = maxOf(width, height) - if (longestSide <= maxDimension) return file + if (longestSide <= maxDimension) return ProcessedProxyFile.Original // 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 + val sampled = BitmapFactory.decodeFile(file.absolutePath, decodeOptions) ?: return ProcessedProxyFile.Original // Scale to exact target dimensions val scale = maxDimension.toFloat() / longestSide.toFloat() @@ -49,16 +53,57 @@ class DemoMediaUploadDelegate : MediaUploadDelegate { 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 + // Bake the EXIF orientation into the pixels. Re-encoding via compress() + // writes no EXIF, so without this a portrait photo (stored landscape plus + // an orientation tag) would upload rotated. + val oriented = applyExifOrientation(scaled, file) + + // Re-encoding normalizes everything but PNG to JPEG (Bitmap.compress can't + // round-trip WebP/HEIC/etc.), so report the ACTUAL output type and extension. + // Otherwise a WebP/HEIC upload would be JPEG bytes labeled image/webp, and + // WordPress would reject the content/extension mismatch. + val (format, outputMimeType, outputExtension) = + if (mimeType == "image/png") { + Triple(Bitmap.CompressFormat.PNG, "image/png", "png") + } else { + Triple(Bitmap.CompressFormat.JPEG, "image/jpeg", "jpg") + } + val outputFile = File(file.parent, "resized-${file.name}") outputFile.outputStream().use { out -> - scaled.compress(format, 85, out) + oriented.compress(format, 85, out) } - scaled.recycle() + oriented.recycle() + val outputFilename = filename.substringBeforeLast('.', filename) + ".$outputExtension" Log.d(TAG, "Resized image from ${width}×${height} to ${targetWidth}×${targetHeight}") - return outputFile + return ProcessedProxyFile.Processed(outputFile, outputMimeType, outputFilename) + } + + private fun applyExifOrientation(bitmap: Bitmap, sourceFile: File): Bitmap { + val orientation = try { + ExifInterface(sourceFile.absolutePath).getAttributeInt( + ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL + ) + } catch (e: IOException) { + Log.w(TAG, "Failed to read EXIF orientation", e) + ExifInterface.ORIENTATION_NORMAL + } + + val matrix = Matrix() + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f) + ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f) + ExifInterface.ORIENTATION_TRANSPOSE -> matrix.apply { postRotate(90f); postScale(-1f, 1f) } + ExifInterface.ORIENTATION_TRANSVERSE -> matrix.apply { postRotate(270f); postScale(-1f, 1f) } + else -> return bitmap + } + + val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + if (rotated !== bitmap) bitmap.recycle() + return rotated } } diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index d40fb1aaa..47dd0755f 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -298,9 +298,9 @@ private struct _EditorView: UIViewControllerRepresentable { // MARK: - MediaUploadDelegate /// Resizes images to a maximum dimension of 2000px before upload. - nonisolated func processFile(at url: URL, mimeType: String) async throws -> URL { + nonisolated func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile { guard mimeType.hasPrefix("image/"), mimeType != "image/gif" else { - return url + return .original } let maxDimension: CGFloat = 2000 @@ -309,12 +309,12 @@ private struct _EditorView: UIViewControllerRepresentable { let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], let width = properties[kCGImagePropertyPixelWidth] as? CGFloat, let height = properties[kCGImagePropertyPixelHeight] as? CGFloat else { - return url + return .original } let longestSide = max(width, height) guard longestSide > maxDimension else { - return url + return .original } let options: [CFString: Any] = [ @@ -324,7 +324,7 @@ private struct _EditorView: UIViewControllerRepresentable { ] guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { - return url + return .original } let outputURL = url.deletingLastPathComponent() @@ -337,16 +337,17 @@ private struct _EditorView: UIViewControllerRepresentable { 1, nil ) else { - return url + return .original } CGImageDestinationAddImage(destination, thumbnail, nil) guard CGImageDestinationFinalize(destination) else { - return url + return .original } Logger.demo.info("Resized image from \(Int(width))x\(Int(height)) to fit \(Int(maxDimension))px") - return outputURL + // Same format, so the original mimeType/filename carry over. + return .processed(outputURL, mimeType: mimeType, filename: filename) } } diff --git a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift index eba61c3cb..ee3c384b4 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift @@ -4,9 +4,24 @@ import OSLog /// A protocol for making authenticated HTTP requests to the WordPress REST API. public protocol EditorHTTPClientProtocol: Sendable { func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) + + /// Like ``perform(_:)`` but does **not** throw on a non-2xx status — returns + /// the raw response so the caller can relay WordPress's exact status and body. + /// Used by the media upload server, which forwards WordPress's response (and + /// its errors) to the editor unchanged. + func performRaw(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) + func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse) } +public extension EditorHTTPClientProtocol { + /// Default implementation validates the status like ``perform(_:)``. Only + /// clients that need to relay non-2xx responses override this. + func performRaw(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) { + try await perform(urlRequest) + } +} + /// A delegate for observing HTTP requests made by the editor. /// /// Implement this protocol to inspect or log all network requests. @@ -99,6 +114,13 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol { return (data, httpResponse) } + public func performRaw(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) { + let configuredRequest = self.configureRequest(urlRequest) + let (data, response) = try await self.urlSession.data(for: configuredRequest) + self.delegate?.didPerformRequest(configuredRequest, response: response, data: .bytes(data)) + return (data, response as! HTTPURLResponse) + } + public func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse) { let configuredRequest = self.configureRequest(urlRequest) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 5b4c20544..eb8eea996 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -272,7 +272,17 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro public override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) self.dependencyTaskHandle?.cancel() - self.uploadServer?.stop() + } + + deinit { + // Stop the upload server when the editor is permanently torn down. + // + // This deliberately does NOT happen in `viewDidDisappear`, which also + // fires when another view controller is merely pushed or presented over + // the editor. `HTTPServer.stop()` cancels the `NWListener`, which is + // terminal and has no restart path — stopping on disappear left uploads + // permanently broken once the user returned to the editor. + uploadServer?.stop() } /// Fetches all required dependencies and then loads the editor. diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift index 08947ca22..ab4c809b9 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift @@ -1,32 +1,39 @@ import Foundation -/// Result of a successful media upload to the remote WordPress server. +/// A raw response from the WordPress REST API media endpoint. /// -/// 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 +/// GutenbergKit relays this to the editor verbatim — it does not interpret the +/// body. The editor therefore receives the exact attachment object (on success) +/// or WordPress REST error object (on failure) it would get from a direct +/// upload, so every consumer — image sub-sizes, attachment links, error notices — +/// behaves identically to a non-native upload. +public struct MediaUploadResponse: Sendable { + /// The HTTP status code WordPress (or the host's upload service) returned. + public let statusCode: Int + + /// The raw response body — a WordPress REST attachment on success, or a + /// WordPress REST error object (`{ "code", "message", "data" }`) on failure. + public let body: Data + + public init(statusCode: Int, body: Data) { + self.statusCode = statusCode + self.body = body } } +/// The result of a delegate's ``MediaUploadDelegate/processFile(at:mimeType:filename:)``. +public enum ProcessedProxyFile: Sendable { + /// The delegate did not modify the file; the original upload is forwarded + /// to WordPress unchanged. + case original + + /// The delegate produced a file to upload, along with its MIME type and + /// filename. Both are used verbatim, so a format change (e.g. transcoding + /// MOV to MP4, or an in-place EXIF strip) must report the resulting type and + /// filename for WordPress to store the file correctly. + case processed(URL, mimeType: String, filename: String) +} + /// Protocol for customizing media upload behavior. /// /// The native host app can provide an implementation to resize images, @@ -34,21 +41,29 @@ public struct MediaUploadResult: Codable, Sendable { /// 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 + /// + /// Return ``ProcessedProxyFile/original`` to upload the file unchanged, or + /// ``ProcessedProxyFile/processed(_:mimeType:filename:)`` with the processed + /// file and its metadata. When the format changes, report the new mimeType + /// and filename so WordPress stores it with the correct extension and type. + func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile /// 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? + /// + /// Return the raw WordPress response (status code + body), which GutenbergKit + /// relays to the editor unchanged, or `nil` to use the default uploader. A + /// host that uploads to WordPress should return the exact response it + /// received so the editor sees a complete attachment object. + func uploadFile(at url: URL, mimeType: String, filename: String) async throws -> MediaUploadResponse? } /// Default implementations. extension MediaUploadDelegate { - public func processFile(at url: URL, mimeType: String) async throws -> URL { - url + public func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile { + .original } - public func uploadFile(at url: URL, mimeType: String, filename: String) async throws -> MediaUploadResult? { + public func uploadFile(at url: URL, mimeType: String, filename: String) async throws -> MediaUploadResponse? { nil } } diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift index c06a0fa79..53795d79f 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -34,12 +34,16 @@ final class MediaUploadServer: Sendable { defaultUploader: DefaultMediaUploader? = nil, maxRequestBodySize: Int64 = HTTPRequestParser.defaultMaxBodySize ) async throws -> MediaUploadServer { + // Sweep temp files orphaned by a prior crash before starting. + cleanOrphanedUploads() + let context = UploadContext(uploadDelegate: uploadDelegate, defaultUploader: defaultUploader) let server = try await HTTPServer.start( name: "media-upload", requiresAuthentication: true, maxRequestBodySize: maxRequestBodySize, + cors: .permissive, handler: { request in await Self.handleRequest(request, context: context) } @@ -71,18 +75,15 @@ final class MediaUploadServer: Sendable { 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" { - return corsPreflightResponse() + return errorResponse(status: serverError.httpStatus, message: message) } - // Route: only POST /upload is handled. - guard parsed.method.uppercased() == "POST", parsed.target == "/upload" else { - return errorResponse(status: 404, body: "Not found") + // Route: only POST /upload is handled. (OPTIONS preflight is answered by + // the HTTP library under its permissive CORS policy.) Match on the path + // alone — the target carries a query string (e.g. `?_embed`) that the + // upload handler relays on to WordPress. + guard parsed.method.uppercased() == "POST", parsed.path == "/upload" else { + return errorResponse(status: 404, message: "Not found") } return await handleUpload(request, context: context) @@ -94,14 +95,19 @@ final class MediaUploadServer: Sendable { parts = try request.parsed.multipartParts() } catch { Logger.uploadServer.error("Multipart parse failed: \(error)") - return errorResponse(status: 400, body: "Expected multipart/form-data") + return errorResponse(status: 400, message: "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") + return errorResponse(status: 400, message: "No file found in request") } + // The non-file parts (post, additionalData) and the original query + // (e.g. ?_embed) must reach WordPress too — relay them alongside the file. + let extraParts = parts.filter { $0.filename == nil } + let query = request.parsed.query + // 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 @@ -110,8 +116,7 @@ final class MediaUploadServer: Sendable { let filename = sanitizeFilename(filePart.filename ?? "upload") let mimeType = filePart.contentType - let tempDir = FileManager.default.temporaryDirectory - .appending(component: "GutenbergKit-uploads", directoryHint: .isDirectory) + let tempDir = uploadsTempDirectory try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) let fileURL = tempDir.appending(component: "\(UUID().uuidString)-\(filename)") @@ -119,22 +124,26 @@ final class MediaUploadServer: Sendable { let inputStream = try filePart.body.makeInputStream() try writeStream(inputStream, to: fileURL) } catch { + try? FileManager.default.removeItem(at: fileURL) Logger.uploadServer.error("Failed to write upload to disk: \(error)") - return errorResponse(status: 500, body: "Failed to save file") + return errorResponse(status: 500, message: "Failed to save file") } - // Process and upload through the delegate pipeline. - let result: Result - var processedURL: URL? + // From here on always clean up the original temp file. The processed + // file (if the delegate produced a new one) is cleaned up inside + // processAndUpload so its throw paths are covered too. + defer { try? FileManager.default.removeItem(at: fileURL) } + do { let uploadResult = try await processAndUpload( - fileURL: fileURL, mimeType: mimeType, filename: filePart.filename ?? "upload", context: context + fileURL: fileURL, mimeType: mimeType, filename: filePart.filename ?? "upload", + extraParts: extraParts, query: query, context: context ) + let response: MediaUploadResponse switch uploadResult { - case .uploaded(let media, let processed): - processedURL = processed - Logger.uploadServer.debug("Uploading processed file to WordPress") - result = .success(media) + case .uploaded(let uploaded): + Logger.uploadServer.debug("Uploaded file to WordPress") + response = uploaded case .passthrough: // Delegate didn't modify the file — forward the original // request body to WordPress without re-encoding. @@ -142,37 +151,20 @@ final class MediaUploadServer: Sendable { guard let body = request.parsed.body, let contentType = request.parsed.header("Content-Type"), let defaultUploader = context.defaultUploader else { - result = .failure(UploadError.noUploader) - break + return errorResponse(status: 500, message: UploadError.noUploader.localizedDescription) } - let media = try await defaultUploader.passthroughUpload(body: body, contentType: contentType) - result = .success(media) + response = try await defaultUploader.passthroughUpload(body: body, contentType: contentType, query: query) } + // Relay WordPress's exact status and body to the editor so it sees + // the same attachment object (or error) as a direct upload. + return HTTPResponse( + status: response.statusCode, + headers: [("Content-Type", "application/json")], + body: response.body + ) } 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) + return errorResponse(status: 500, message: error.localizedDescription) } } @@ -180,69 +172,107 @@ final class MediaUploadServer: Sendable { /// 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 (or default uploader) completed the upload; carries the + /// raw WordPress response to relay. + case uploaded(MediaUploadResponse) /// The delegate didn't modify the file and `uploadFile` returned nil. /// The caller should forward the original request body to WordPress. case passthrough } private static func processAndUpload( - fileURL: URL, mimeType: String, filename: String, context: UploadContext + fileURL: URL, mimeType: String, filename: String, + extraParts: [MultipartPart], query: String, context: UploadContext ) async throws -> UploadResult { // Step 1: Process (resize, transcode, etc.) - let processedURL: URL + let processed: ProcessedProxyFile if let delegate = context.uploadDelegate { - processedURL = try await delegate.processFile(at: fileURL, mimeType: mimeType) + processed = try await delegate.processFile(at: fileURL, mimeType: mimeType, filename: filename) } else { - processedURL = fileURL + processed = .original + } + + // Resolve the file to upload and its metadata. `.processed` uses the + // delegate's values verbatim, so a format change is reported to WordPress. + let uploadURL: URL + let uploadMimeType: String + let uploadFilename: String + switch processed { + case .original: + uploadURL = fileURL + uploadMimeType = mimeType + uploadFilename = filename + case let .processed(url, processedMimeType, processedFilename): + uploadURL = url + uploadMimeType = processedMimeType + uploadFilename = processedFilename + } + + // The processed file (if the delegate produced a new one) is ours to + // clean up — on success it has been uploaded, on failure it is abandoned. + // Cleaning up here rather than in the caller covers the throw paths too. + defer { + if uploadURL != fileURL { + try? FileManager.default.removeItem(at: uploadURL) + } } // Step 2: Upload to remote WordPress if let delegate = context.uploadDelegate, - let result = try await delegate.uploadFile(at: processedURL, mimeType: mimeType, filename: filename) { - return .uploaded(result, processedURL: processedURL) + let result = try await delegate.uploadFile(at: uploadURL, mimeType: uploadMimeType, filename: uploadFilename) { + return .uploaded(result) } else if let defaultUploader = context.defaultUploader { - // If the delegate didn't modify the file, the original request - // body can be forwarded directly — skip multipart re-encoding. - if processedURL == fileURL { + // Unmodified — forward the original request body directly, skipping + // multipart re-encoding. + if case .original = processed { return .passthrough } - let result = try await defaultUploader.upload(fileURL: processedURL, mimeType: mimeType, filename: filename) - return .uploaded(result, processedURL: processedURL) + let result = try await defaultUploader.upload(fileURL: uploadURL, mimeType: uploadMimeType, filename: uploadFilename, extraParts: extraParts, query: query) + return .uploaded(result) } else { throw UploadError.noUploader } } - // MARK: - CORS - - private static let corsHeaders: [(String, String)] = [ - ("Access-Control-Allow-Origin", "*"), - ("Access-Control-Allow-Headers", "Relay-Authorization, Content-Type"), - ] - - private static func corsPreflightResponse() -> HTTPResponse { - HTTPResponse( - status: 204, - headers: corsHeaders + [ - ("Access-Control-Allow-Methods", "POST, OPTIONS"), - ("Access-Control-Max-Age", "86400"), - ], - body: Data() - ) - } - - private static func errorResponse(status: Int, body: String) -> HTTPResponse { - HTTPResponse( + private static func errorResponse(status: Int, message: String) -> HTTPResponse { + // Emit a WordPress-REST-style error object so the JS middleware normalizes + // it (and surfaces `message`) the same way it does a relayed WordPress + // error — the local server's own errors need no special-casing. + let payload = ["code": "upload_error", "message": message] + let body = (try? JSONSerialization.data(withJSONObject: payload)) + ?? Data(#"{"code":"upload_error","message":"Upload failed"}"#.utf8) + return HTTPResponse( status: status, - headers: corsHeaders + [("Content-Type", "text/plain")], - body: Data(body.utf8) + headers: [("Content-Type", "application/json")], + body: body ) } // MARK: - Helpers + /// Directory for staging uploaded files, under the system temp dir. + private static var uploadsTempDirectory: URL { + FileManager.default.temporaryDirectory + .appending(component: "GutenbergKit-uploads", directoryHint: .isDirectory) + } + + /// Deletes upload temp files left behind by a prior crash. Files still in + /// flight (only seconds old) are preserved by the age threshold, so this is + /// safe even if another editor instance is mid-upload. + private static func cleanOrphanedUploads() { + let cutoff = Date(timeIntervalSinceNow: -3600) // 1 hour ago + guard let files = try? FileManager.default.contentsOfDirectory( + at: uploadsTempDirectory, + includingPropertiesForKeys: [.contentModificationDateKey] + ) else { return } + for file in files { + let modified = (try? file.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate + if let modified, modified < cutoff { + try? FileManager.default.removeItem(at: file) + } + } + } + /// Sanitizes a filename to prevent path traversal. private static func sanitizeFilename(_ name: String) -> String { let safe = (name as NSString).lastPathComponent @@ -286,30 +316,47 @@ final class MediaUploadServer: Sendable { } } - // MARK: - Errors +} - enum UploadError: Error, LocalizedError { - case noUploader - case streamReadFailed - case streamWriteFailed +// MARK: - Errors - 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" - } +/// Errors from the native media upload pipeline. +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)? +/// Container for the upload delegate and default uploader, captured by the +/// HTTPServer handler closure and re-read on each request. +/// +/// The delegate is held **weakly**. `EditorViewController.mediaUploadDelegate` is +/// declared `weak` — the host owns the delegate's lifetime. Capturing it strongly +/// here would silently defeat that contract and, worse, risk a retain cycle +/// (`EditorViewController → uploadServer → HTTPServer → handler → UploadContext → +/// delegate → EditorViewController`) that would keep the view controller — and +/// therefore the server — alive forever, so `deinit` would never stop it. +/// +/// `@unchecked Sendable`: `uploadDelegate` is assigned once at init and only read +/// afterwards; weak-reference reads are thread-safe at runtime. +private final class UploadContext: @unchecked Sendable { + weak var uploadDelegate: (any MediaUploadDelegate)? let defaultUploader: DefaultMediaUploader? + + init(uploadDelegate: (any MediaUploadDelegate)?, defaultUploader: DefaultMediaUploader?) { + self.uploadDelegate = uploadDelegate + self.defaultUploader = defaultUploader + } } // MARK: - Default Media Uploader @@ -326,24 +373,35 @@ class DefaultMediaUploader: @unchecked Sendable { self.siteApiNamespace = siteApiNamespace.first } - /// 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" - } - return siteApiRoot.appending(path: mediaPath) + /// The WordPress media endpoint URL, built through the shared + /// ``WordPressRESTURL`` namespacing (so it matches every other REST URL) and + /// carrying the original request query (e.g. `?_embed`) through to WordPress. + private func mediaEndpointURL(query: String) -> URL { + let base = WordPressRESTURL.namespaced(apiRoot: siteApiRoot, path: "/wp/v2/media", namespace: siteApiNamespace) + guard !query.isEmpty else { return base } + // `query` is the raw request query in wire form (leading "?"). Set it via + // `percentEncodedQuery` so a value that isn't URL-safe can't make + // `URL(string:)` return nil and silently drop the query. + var components = URLComponents(url: base, resolvingAgainstBaseURL: false) + components?.percentEncodedQuery = String(query.dropFirst()) + return components?.url ?? base } - func upload(fileURL: URL, mimeType: String, filename: String) async throws -> MediaUploadResult { + func upload(fileURL: URL, mimeType: String, filename: String, extraParts: [MultipartPart], query: String) async throws -> MediaUploadResponse { let boundary = UUID().uuidString + // Read the (small, text) non-file parts up front so the body builder + // stays synchronous — the file itself is still streamed from disk. + var extraFields: [(name: String, value: Data)] = [] + for part in extraParts { + extraFields.append((part.name, try await part.body.data)) + } + let (bodyStream, contentLength) = try Self.multipartBodyStream( - fileURL: fileURL, boundary: boundary, filename: filename, mimeType: mimeType + fileURL: fileURL, boundary: boundary, filename: filename, mimeType: mimeType, extraFields: extraFields ) - var request = URLRequest(url: mediaEndpointURL) + var request = URLRequest(url: mediaEndpointURL(query: query)) request.httpMethod = "POST" request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") request.setValue("\(contentLength)", forHTTPHeaderField: "Content-Length") @@ -356,8 +414,8 @@ class DefaultMediaUploader: @unchecked Sendable { /// /// 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) + func passthroughUpload(body: RequestBody, contentType: String, query: String) async throws -> MediaUploadResponse { + var request = URLRequest(url: mediaEndpointURL(query: query)) request.httpMethod = "POST" request.setValue(contentType, forHTTPHeaderField: "Content-Type") request.setValue("\(body.count)", forHTTPHeaderField: "Content-Length") @@ -366,34 +424,12 @@ class DefaultMediaUploader: @unchecked Sendable { 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 { - 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 - ) + private func performUpload(_ request: URLRequest) async throws -> MediaUploadResponse { + // Relay WordPress's response verbatim — including non-2xx statuses — so + // the editor sees WordPress's real status and error body, exactly as a + // direct upload would. `performRaw` does not throw on non-2xx. + let (data, response) = try await httpClient.performRaw(request) + return MediaUploadResponse(statusCode: response.statusCode, body: data) } // MARK: - Streaming Multipart Body @@ -409,17 +445,28 @@ class DefaultMediaUploader: @unchecked Sendable { fileURL: URL, boundary: String, filename: String, - mimeType: String + mimeType: String, + extraFields: [(name: String, value: Data)] ) 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 - ) + // Serialize the non-file parts (post, additionalData) into the preamble + // ahead of the streamed file. They are small, so keeping them in memory is + // fine; `contentLength` counts them via `preamble.count`. Field values are + // appended as raw bytes (not through String) so a non-UTF-8 value is + // forwarded verbatim rather than coerced to empty. + var preamble = Data() + for field in extraFields { + preamble.append(Data("--\(boundary)\r\n".utf8)) + preamble.append(Data("Content-Disposition: form-data; name=\"\(field.name)\"\r\n\r\n".utf8)) + preamble.append(field.value) + preamble.append(Data("\r\n".utf8)) + } + preamble.append(Data("--\(boundary)\r\n".utf8)) + preamble.append(Data("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".utf8)) + preamble.append(Data("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 + throw UploadError.streamReadFailed } let contentLength = preamble.count + fileSize + epilogue.count @@ -431,7 +478,7 @@ class DefaultMediaUploader: @unchecked Sendable { guard let inputStream = readStream, let outputStream = writeStream else { try? fileHandle.close() - throw MediaUploadError.streamReadFailed + throw UploadError.streamReadFailed } outputStream.open() @@ -483,54 +530,3 @@ class DefaultMediaUploader: @unchecked Sendable { } } -/// 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) - - /// 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" - } - } -} - -// MARK: - Helpers - -private extension Data { - mutating func append(_ string: String) { - append(Data(string.utf8)) - } -} diff --git a/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift b/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift index 91b53fd56..616c5053d 100644 --- a/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift +++ b/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift @@ -34,62 +34,30 @@ public struct RESTAPIRepository: Sendable { if let customEndpoint = configuration.editorSettingsEndpoint { self.editorSettingsUrl = customEndpoint } else { - self.editorSettingsUrl = Self.buildNamespacedURL( + self.editorSettingsUrl = WordPressRESTURL.namespaced( apiRoot: apiRoot, path: Constants.API.editorSettingsPath, namespace: configuration.siteApiNamespace.first ) } - self.activeThemeUrl = Self.buildNamespacedURL( + self.activeThemeUrl = WordPressRESTURL.namespaced( apiRoot: apiRoot, path: Constants.API.activeThemePath, namespace: configuration.siteApiNamespace.first ) - self.siteSettingsUrl = Self.buildNamespacedURL( + self.siteSettingsUrl = WordPressRESTURL.namespaced( apiRoot: apiRoot, path: Constants.API.siteSettingsPath, namespace: configuration.siteApiNamespace.first ) - self.postTypesUrl = Self.buildNamespacedURL( + self.postTypesUrl = WordPressRESTURL.namespaced( apiRoot: apiRoot, path: Constants.API.postTypesPath, namespace: configuration.siteApiNamespace.first ) } - /// Builds a URL by inserting the namespace after the version segment of the path. - /// For example: `/wp/v2/posts` with namespace `sites/123/` becomes `/wp/v2/sites/123/posts` - private static func buildNamespacedURL(apiRoot: URL, path: String, namespace: String?) -> URL { - guard let rawNamespace = namespace else { - return apiRoot.appending(rawPath: path) - } - - let namespace = rawNamespace.hasSuffix("/") ? rawNamespace : rawNamespace + "/" - - // Parse the path to find where to insert the namespace - // Path format is typically: /prefix/version/endpoint (e.g., /wp/v2/posts or /wp-block-editor/v1/settings) - let components = path.split(separator: "/", omittingEmptySubsequences: true) - guard components.count >= 2 else { - return apiRoot.appending(rawPath: path) - } - - // Insert namespace after the version segment (second component) - // e.g., /wp-block-editor/v1/settings -> /wp-block-editor/v1/sites/123/settings - let prefix = components[0] - let version = components[1] - let remainder = components.dropFirst(2).joined(separator: "/") - - let namespacedPath: String - if remainder.isEmpty { - namespacedPath = "/\(prefix)/\(version)/\(namespace)" - } else { - namespacedPath = "/\(prefix)/\(version)/\(namespace)\(remainder)" - } - - return apiRoot.appending(rawPath: namespacedPath) - } - /// Clears all cached API responses. public func purge() throws { try self.cache.clear() @@ -110,7 +78,7 @@ public struct RESTAPIRepository: Sendable { private func buildPostUrl(id: Int) -> URL { let restNamespace = configuration.postType.restNamespace let restBase = configuration.postType.restBase - return Self.buildNamespacedURL( + return WordPressRESTURL.namespaced( apiRoot: configuration.siteApiRoot, path: "/\(restNamespace)/\(restBase)/\(id)", namespace: configuration.siteApiNamespace.first @@ -155,7 +123,7 @@ public struct RESTAPIRepository: Sendable { } private func buildPostTypeUrl(type: String) -> URL { - Self.buildNamespacedURL( + WordPressRESTURL.namespaced( apiRoot: configuration.siteApiRoot, path: "/wp/v2/types/\(type)", namespace: configuration.siteApiNamespace.first diff --git a/ios/Sources/GutenbergKit/Sources/WordPressRESTURL.swift b/ios/Sources/GutenbergKit/Sources/WordPressRESTURL.swift new file mode 100644 index 000000000..60b55a998 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/WordPressRESTURL.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Single source of truth for building namespaced WordPress REST API URLs, so the +/// media endpoint and every ``RESTAPIRepository`` endpoint normalize the site API +/// root and namespace identically (no drift). +enum WordPressRESTURL { + /// Builds a URL by inserting the site API namespace after the version segment + /// of the path. For example, `/wp/v2/posts` with namespace `sites/123` becomes + /// `/wp/v2/sites/123/posts`. A `nil` namespace appends the path unchanged. + /// + /// Trailing slashes on the root and namespace are normalized, so an unslashed + /// `apiRoot` or `namespace` still joins cleanly. + static func namespaced(apiRoot: URL, path: String, namespace: String?) -> URL { + guard let rawNamespace = namespace else { + return apiRoot.appending(rawPath: path) + } + + let namespace = rawNamespace.hasSuffix("/") ? rawNamespace : rawNamespace + "/" + + // Path format is typically /prefix/version/endpoint + // (e.g. /wp/v2/posts or /wp-block-editor/v1/settings). + let components = path.split(separator: "/", omittingEmptySubsequences: true) + guard components.count >= 2 else { + return apiRoot.appending(rawPath: path) + } + + // Insert the namespace after the version segment (second component). + let prefix = components[0] + let version = components[1] + let remainder = components.dropFirst(2).joined(separator: "/") + + let namespacedPath = remainder.isEmpty + ? "/\(prefix)/\(version)/\(namespace)" + : "/\(prefix)/\(version)/\(namespace)\(remainder)" + + return apiRoot.appending(rawPath: namespacedPath) + } +} diff --git a/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift b/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift new file mode 100644 index 000000000..db48652cf --- /dev/null +++ b/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift @@ -0,0 +1,41 @@ +import Foundation + +/// CORS behavior for an ``HTTPServer``. +public enum CORSPolicy: Sendable { + /// No CORS headers are added (the default). + case none + + /// Permissive CORS for a loopback-only server serving a WebView: allows any + /// origin and the methods/headers this library's clients use. The server + /// answers OPTIONS preflight requests itself and stamps these headers on + /// every response — including ones it generates internally (timeouts, parse + /// errors) that never reach the handler. + case permissive + + /// Headers added to every response under this policy. + var responseHeaders: [(String, String)] { + switch self { + case .none: + [] + case .permissive: + [ + ("Access-Control-Allow-Origin", "*"), + ("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"), + ("Access-Control-Allow-Headers", "Authorization, Relay-Authorization, Content-Type"), + ("Access-Control-Max-Age", "86400"), + ] + } + } +} + +extension HTTPResponse { + /// Returns a copy with `newHeaders` appended, skipping any whose name + /// (case-insensitive) is already present. + func addingHeadersIfAbsent(_ newHeaders: [(String, String)]) -> HTTPResponse { + guard !newHeaders.isEmpty else { return self } + let existing = Set(headers.map { $0.0.lowercased() }) + let toAdd = newHeaders.filter { !existing.contains($0.0.lowercased()) } + guard !toAdd.isEmpty else { return self } + return HTTPResponse(status: status, statusText: statusText, headers: headers + toAdd, body: body) + } +} diff --git a/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift b/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift index 07f188ea3..ddfc46c2c 100644 --- a/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift +++ b/ios/Sources/GutenbergKitHTTP/HTTPRequestParser.swift @@ -137,11 +137,11 @@ public final class HTTPRequestParser: @unchecked Sendable { try lock.withLock { guard _state.hasHeaders else { return nil } - // 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 { + // Recoverable errors (e.g. payloadTooLarge — valid headers, rejected + // body) are surfaced to the caller so the handler can build a + // response. Fatal errors indicate genuinely malformed requests and + // are thrown, closing the connection before the handler runs. + if let error = _parseError, error.disposition == .fatal { throw error } diff --git a/ios/Sources/GutenbergKitHTTP/HTTPRequestSerializer.swift b/ios/Sources/GutenbergKitHTTP/HTTPRequestSerializer.swift index 2e90b7c89..086b89f98 100644 --- a/ios/Sources/GutenbergKitHTTP/HTTPRequestSerializer.swift +++ b/ios/Sources/GutenbergKitHTTP/HTTPRequestSerializer.swift @@ -1,7 +1,7 @@ import Foundation /// Errors thrown when parsing an HTTP/1.1 request fails due to RFC 7230/9112 violations. -public enum HTTPRequestParseError: Error, Sendable, Equatable, LocalizedError { +public enum HTTPRequestParseError: Error, Sendable, Equatable, LocalizedError, CaseIterable { /// The header section before `\r\n\r\n` is empty (e.g., `\r\n\r\n` with no request line). case emptyHeaderSection /// The request line does not contain at least a method and target (RFC 9112 §3). @@ -37,6 +37,34 @@ public enum HTTPRequestParseError: Error, Sendable, Equatable, LocalizedError { /// An I/O error occurred while buffering the request (e.g. disk full). case bufferIOError + /// How the parser disposes of a parse error. + public enum Disposition: Sendable { + /// Abort the connection; the malformed request never reaches the handler. + case fatal + /// Surface to the handler via ``HTTPRequestParser/parseError`` so it can + /// build a response. + case recoverable + } + + /// Whether this error aborts the connection (``Disposition/fatal``) or is + /// surfaced to the handler (``Disposition/recoverable``). + /// + /// Only genuinely recoverable errors — where the request line and headers are + /// well-formed — may be recoverable; anything smuggling-relevant (framing, + /// Content-Length) must stay fatal so the request never reaches the handler. + public var disposition: Disposition { + switch self { + case .payloadTooLarge: + return .recoverable + case .emptyHeaderSection, .malformedRequestLine, .obsFoldDetected, + .whitespaceBeforeColon, .invalidContentLength, .conflictingContentLength, + .unsupportedTransferEncoding, .invalidHTTPVersion, .invalidFieldName, + .invalidFieldValue, .missingHostHeader, .multipleHostHeaders, + .headersTooLarge, .tooManyHeaders, .invalidEncoding, .bufferIOError: + return .fatal + } + } + /// The HTTP status code that should be sent for this error. public var httpStatus: Int { switch self { diff --git a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift index 83bf016a2..2a85781c2 100644 --- a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift +++ b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift @@ -157,6 +157,7 @@ public final class HTTPServer: Sendable { maxConnections: Int = HTTPServer.defaultMaxConnections, readTimeout: Duration = HTTPServer.defaultReadTimeout, idleTimeout: Duration = HTTPServer.defaultIdleTimeout, + cors: CORSPolicy = .none, handler: @escaping @Sendable (HTTPServer.Request) async -> HTTPResponse ) async throws -> HTTPServer { // Sanitize to prevent path traversal — only allow safe filename characters. @@ -196,7 +197,7 @@ public final class HTTPServer: Sendable { connection, queue: queue, token: token, requiresAuthentication: requiresAuth, maxRequestBodySize: maxRequestBodySize, readTimeout: readTimeout, - idleTimeout: idleTimeout, tempDirectory: tempDirectory, + idleTimeout: idleTimeout, cors: cors, tempDirectory: tempDirectory, connectionCounter: connectionCounter, connectionTasks: connectionTasks, handler: handler ) } @@ -258,6 +259,7 @@ public final class HTTPServer: Sendable { maxRequestBodySize: Int64, readTimeout: Duration, idleTimeout: Duration, + cors: CORSPolicy, tempDirectory: URL, connectionCounter: ConnectionCounter, connectionTasks: ConnectionTasks, @@ -336,21 +338,28 @@ public final class HTTPServer: Sendable { } } - let response = await handler(Request(parsed: request, parseDuration: duration, serverError: parser.parseError)) - await send(response, on: connection) + // Under a permissive CORS policy the library answers the OPTIONS + // preflight itself; the send layer stamps the CORS headers. + let response: HTTPResponse + if cors == .permissive, request.method.uppercased() == "OPTIONS" { + response = HTTPResponse(status: 204) + } else { + response = await handler(Request(parsed: request, parseDuration: duration, serverError: parser.parseError)) + } + await send(response, on: connection, cors: cors) let (sec, atto) = duration.components let ms = Double(sec) * 1000.0 + Double(atto) / 1_000_000_000_000_000.0 Logger.httpServer.debug("\(request.method) \(request.target) → \(response.status) (\(String(format: "%.1f", ms))ms)") } catch HTTPServerError.authenticationFailed { - await send(HTTPResponse(status: 407, headers: [("Content-Type", "text/plain"), ("Proxy-Authenticate", "Bearer")]), on: connection) + await send(HTTPResponse(status: 407, headers: [("Content-Type", "text/plain"), ("Proxy-Authenticate", "Bearer")]), on: connection, cors: cors) } catch HTTPServerError.lengthRequired { - await send(HTTPResponse(status: 411, statusText: "Length Required", body: Data("Length Required".utf8)), on: connection) + await send(HTTPResponse(status: 411, statusText: "Length Required", body: Data("Length Required".utf8)), on: connection, cors: cors) } catch is CancellationError { Logger.httpServer.debug("Connection cancelled during shutdown") connection.cancel() } catch HTTPServerError.readTimeout { Logger.httpServer.warning("Read timeout, closing connection") - await send(HTTPResponse(status: 408, statusText: "Request Timeout", body: Data("Request Timeout".utf8)), on: connection) + await send(HTTPResponse(status: 408, statusText: "Request Timeout", body: Data("Request Timeout".utf8)), on: connection, cors: cors) } catch let error as HTTPRequestParseError { Logger.httpServer.error("Parse error: \(error)") let statusText = String(error.httpStatusText) @@ -359,10 +368,10 @@ public final class HTTPServer: Sendable { statusText: statusText, body: Data(statusText.utf8) ) - await send(response, on: connection) + await send(response, on: connection, cors: cors) } catch { Logger.httpServer.error("Unexpected error: \(error)") - await send(HTTPResponse(status: 400, statusText: "Bad Request", body: Data("Malformed HTTP request".utf8)), on: connection) + await send(HTTPResponse(status: 400, statusText: "Bad Request", body: Data("Malformed HTTP request".utf8)), on: connection, cors: cors) } } connectionTasks.track(taskID, task) @@ -477,9 +486,10 @@ public final class HTTPServer: Sendable { } /// Sends a response on the connection and then closes it. - private static func send(_ response: HTTPResponse, on connection: NWConnection) async { + private static func send(_ response: HTTPResponse, on connection: NWConnection, cors: CORSPolicy) async { + let decorated = response.addingHeadersIfAbsent(cors.responseHeaders) await withCheckedContinuation { (continuation: CheckedContinuation) in - connection.send(content: response.serialized(), completion: .contentProcessed { _ in + connection.send(content: decorated.serialized(), completion: .contentProcessed { _ in connection.cancel() continuation.resume() }) diff --git a/ios/Sources/GutenbergKitHTTP/ParsedHTTPRequest.swift b/ios/Sources/GutenbergKitHTTP/ParsedHTTPRequest.swift index d68774921..95222d1c0 100644 --- a/ios/Sources/GutenbergKitHTTP/ParsedHTTPRequest.swift +++ b/ios/Sources/GutenbergKitHTTP/ParsedHTTPRequest.swift @@ -32,6 +32,29 @@ extension ParsedHTTPRequest { } } + /// The path portion of ``target``, without the query component + /// (e.g., "/wp/v2/posts" for "/wp/v2/posts?per_page=10"). + /// + /// Use this for routing — matching against ``target`` fails as soon as a + /// client appends a query string. + public var path: String { + let target = target + guard let separator = target.firstIndex(of: "?") else { return target } + return String(target.prefix(upTo: separator)) + } + + /// The query component of ``target``, including the leading "?" + /// (e.g., "?per_page=10"), or an empty string when there is no query. + /// + /// A bare trailing "?" carries no parameters and yields an empty string, so + /// the value can be appended to an upstream URL unconditionally. + public var query: String { + let target = target + guard let separator = target.firstIndex(of: "?") else { return "" } + let value = String(target[target.index(after: separator)...]) + return value.isEmpty ? "" : "?\(value)" + } + /// The HTTP-version from the request line (e.g., "HTTP/1.1"), per RFC 9112 §2.3. public var httpVersion: String { switch self { diff --git a/ios/Tests/GutenbergKitHTTPTests/FixtureTests.swift b/ios/Tests/GutenbergKitHTTPTests/FixtureTests.swift index 22feadbd0..0acc1726c 100644 --- a/ios/Tests/GutenbergKitHTTPTests/FixtureTests.swift +++ b/ios/Tests/GutenbergKitHTTPTests/FixtureTests.swift @@ -255,17 +255,18 @@ struct RequestParsingFixtureTests { let expectedError = testCase.expected.error do { _ = try parser.parseRequest() - // Non-fatal errors (e.g., payloadTooLarge) are exposed via - // parseError instead of being thrown. + // A recoverable error is surfaced via `parseError` instead of thrown. if let parseError = parser.parseError { let errorName = String(describing: parseError) #expect(errorName == expectedError, "\(testCase.description): expected \(expectedError) but got \(errorName)") + #expect(parseError.disposition == .recoverable, "\(testCase.description): \(errorName) surfaced via parseError but is not recoverable") } 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)") + #expect((error as? HTTPRequestParseError)?.disposition == .fatal, "\(testCase.description): \(errorName) was thrown but is not fatal") } } diff --git a/ios/Tests/GutenbergKitHTTPTests/HTTPRequestParserTests.swift b/ios/Tests/GutenbergKitHTTPTests/HTTPRequestParserTests.swift index c4dc5366c..43b255352 100644 --- a/ios/Tests/GutenbergKitHTTPTests/HTTPRequestParserTests.swift +++ b/ios/Tests/GutenbergKitHTTPTests/HTTPRequestParserTests.swift @@ -5,6 +5,17 @@ import Testing @Suite("HTTPRequestParser") struct HTTPRequestParserTests { + // MARK: - Error Disposition + + /// Locks the fatal/recoverable classification so a refactor can't silently + /// make a smuggling-relevant error recoverable — which would let a malformed + /// request reach the handler before auth. + @Test("only payloadTooLarge is recoverable") + func onlyPayloadTooLargeIsRecoverable() { + let recoverable = HTTPRequestParseError.allCases.filter { $0.disposition == .recoverable } + #expect(recoverable == [.payloadTooLarge]) + } + // MARK: - Basic Request Parsing @Test("parses a simple GET request") diff --git a/ios/Tests/GutenbergKitHTTPTests/ParsedHTTPRequestTests.swift b/ios/Tests/GutenbergKitHTTPTests/ParsedHTTPRequestTests.swift index 2d37a9abe..82e17b2da 100644 --- a/ios/Tests/GutenbergKitHTTPTests/ParsedHTTPRequestTests.swift +++ b/ios/Tests/GutenbergKitHTTPTests/ParsedHTTPRequestTests.swift @@ -5,6 +5,47 @@ import Testing @Suite("ParsedHTTPRequest") struct ParsedHTTPRequestTests { + // MARK: - path / query + + @Test( + "path and query split the target", + arguments: [ + ("/upload", "/upload", ""), + ("/upload?_embed=wp:featuredmedia", "/upload", "?_embed=wp:featuredmedia"), + // A bare "?" carries no parameters, so the query is empty. + ("/upload?", "/upload", ""), + ("/wp/v2/posts?per_page=10&page=2", "/wp/v2/posts", "?per_page=10&page=2"), + // Only the first "?" separates path from query; later ones belong to it. + ("/search?q=a?b", "/search", "?q=a?b"), + ("/", "/", ""), + ] + ) + func pathAndQuery(target: String, expectedPath: String, expectedQuery: String) { + let request = ParsedHTTPRequest.complete( + method: "POST", + target: target, + httpVersion: "HTTP/1.1", + headers: [:], + body: nil + ) + + #expect(request.path == expectedPath) + #expect(request.query == expectedQuery) + } + + @Test("path and query are available on a partial request") + func pathAndQueryOnPartial() { + let request = ParsedHTTPRequest.partial( + method: "POST", + target: "/upload?_embed=wp:featuredmedia", + httpVersion: "HTTP/1.1", + headers: [:] + ) + + #expect(request.path == "/upload") + #expect(request.query == "?_embed=wp:featuredmedia") + } + // MARK: - urlRequest(relativeTo:) @Test("urlRequest resolves path against base URL") diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index 02f2f93e7..1d29e4ddc 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -100,6 +100,37 @@ struct MediaUploadServerTests { #expect(httpResponse.statusCode == 404) } + @Test("routes /upload with a query string and relays the query") + func uploadWithQueryString() async throws { + let delegate = ProcessOnlyDelegate() + let mockUploader = MockDefaultUploader() + let server = try await MediaUploadServer.start(uploadDelegate: delegate, defaultUploader: mockUploader) + defer { server.stop() } + + // `@wordpress/media-utils` uploads to `/wp/v2/media?_embed=wp:featuredmedia`, + // so the middleware forwards that query on to the native server. Routing must + // match on the path alone, and the query must reach WordPress unchanged. + let boundary = UUID().uuidString + let body = buildMultipartBody(boundary: boundary, filename: "photo.jpg", mimeType: "image/jpeg", data: Data("fake image data".utf8)) + + let url = URL(string: "http://127.0.0.1:\(server.port)/upload?_embed=wp:featuredmedia")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.httpBody = body + + let (_, response) = try await URLSession.shared.data(for: request) + let httpResponse = try #require(response as? HTTPURLResponse) + #expect(httpResponse.statusCode == 201) + // The delegate returns `.original`, so this is the passthrough branch. + // Pin which branch ran — `lastQuery` is recorded by both, so without this + // the query assertion would pass even if routing collapsed onto one path. + #expect(mockUploader.passthroughUploadCalled) + #expect(!mockUploader.uploadCalled) + #expect(mockUploader.lastQuery == "?_embed=wp:featuredmedia") + } + @Test("calls delegate and returns upload result") func delegateProcessAndUpload() async throws { let delegate = MockUploadDelegate() @@ -119,17 +150,19 @@ struct MediaUploadServerTests { let (data, response) = try await URLSession.shared.data(for: request) let httpResponse = try #require(response as? HTTPURLResponse) - #expect(httpResponse.statusCode == 200) + #expect(httpResponse.statusCode == 201) #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") + // The server relays WordPress's raw response body verbatim. + let object = try JSONSerialization.jsonObject(with: data) + let json = try #require(object as? [String: Any]) + #expect(json["id"] as? Int == 42) + #expect(json["source_url"] as? String == "https://example.com/photo.jpg") + #expect(json["media_type"] as? String == "image") } @Test("uses passthrough when delegate does not modify file") @@ -152,15 +185,69 @@ struct MediaUploadServerTests { let (data, response) = try await URLSession.shared.data(for: request) let httpResponse = try #require(response as? HTTPURLResponse) - #expect(httpResponse.statusCode == 200) + #expect(httpResponse.statusCode == 201) #expect(delegate.processFileCalled) // 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) + // The server relays WordPress's raw response body verbatim. + let object = try JSONSerialization.jsonObject(with: data) + let json = try #require(object as? [String: Any]) + #expect(json["id"] as? Int == 99) + } + + @Test("forwards the delegate's processed metadata to the uploader") + func processedMetadataForwarded() async throws { + let delegate = ResizingDelegate() + let mockUploader = MockDefaultUploader() + let server = try await MediaUploadServer.start(uploadDelegate: delegate, defaultUploader: mockUploader) + defer { server.stop() } + + let boundary = UUID().uuidString + let body = buildMultipartBody(boundary: boundary, filename: "clip.mov", mimeType: "video/quicktime", data: Data("movie".utf8)) + + let url = URL(string: "http://127.0.0.1:\(server.port)/upload")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.httpBody = body + + _ = try await URLSession.shared.data(for: request) + + // The delegate changed the format, so the uploader must receive the new + // metadata — not the original video/quicktime + clip.mov. + #expect(mockUploader.uploadCalled) + #expect(mockUploader.lastUploadMimeType == "video/mp4") + #expect(mockUploader.lastUploadFilename == "clip.mp4") + } + + @Test("deletes the delegate's processed file after upload") + func deletesProcessedFile() async throws { + let delegate = ResizingDelegate() + let mockUploader = MockDefaultUploader() + let server = try await MediaUploadServer.start(uploadDelegate: delegate, defaultUploader: mockUploader) + defer { server.stop() } + + let boundary = UUID().uuidString + let body = buildMultipartBody(boundary: boundary, filename: "clip.mov", mimeType: "video/quicktime", data: Data("movie".utf8)) + + let url = URL(string: "http://127.0.0.1:\(server.port)/upload")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.httpBody = body + + _ = try await URLSession.shared.data(for: request) + + // The server owns the file the delegate produced and must delete it once the + // upload finishes — the defer in processAndUpload covers the success and throw + // paths alike. A leaked processed file is a full-size temp per upload. + let processedURL = try #require(delegate.producedURL) + #expect(!FileManager.default.fileExists(atPath: processedURL.path(percentEncoded: false))) } @Test("returns 413 with CORS headers when request body exceeds max size") @@ -188,6 +275,54 @@ struct MediaUploadServerTests { #expect(responseBody.contains("too large")) } + @Test("startup sweep deletes stale upload temps but preserves fresh ones") + func cleanOrphanedUploadsAgeThreshold() async throws { + let dir = FileManager.default.temporaryDirectory + .appending(component: "GutenbergKit-uploads", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + let stale = dir.appending(component: "stale-\(UUID().uuidString)") + let fresh = dir.appending(component: "fresh-\(UUID().uuidString)") + try Data("x".utf8).write(to: stale) + try Data("y".utf8).write(to: fresh) + defer { + try? FileManager.default.removeItem(at: stale) + try? FileManager.default.removeItem(at: fresh) + } + // Backdate the stale file well past the 1-hour cutoff. + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSinceNow: -7200)], + ofItemAtPath: stale.path(percentEncoded: false) + ) + + // start() runs cleanOrphanedUploads(). The sweep must delete the aged file and + // keep the fresh one — a flipped comparison would do the opposite and wipe an + // in-flight upload. + let server = try await MediaUploadServer.start() + server.stop() + + #expect(!FileManager.default.fileExists(atPath: stale.path(percentEncoded: false))) + #expect(FileManager.default.fileExists(atPath: fresh.path(percentEncoded: false))) + } + + @Test("does not strongly retain the upload delegate (weak — preserves deinit teardown)") + func doesNotStronglyRetainDelegate() async throws { + weak var weakDelegate: MockUploadDelegate? + let server: MediaUploadServer + do { + let delegate = MockUploadDelegate() + weakDelegate = delegate + server = try await MediaUploadServer.start(uploadDelegate: delegate) + } + defer { server.stop() } + + // UploadContext holds the delegate weakly, so releasing the host's strong + // reference deallocates it. A strong reference here would reintroduce the + // EditorViewController → uploadServer → … → delegate → EditorViewController + // cycle, so deinit would never fire and the server would never stop. + #expect(weakDelegate == nil) + } + private func buildMultipartBody(boundary: String, filename: String, mimeType: String, data: Data) -> Data { var body = Data() body.append("--\(boundary)\r\n") @@ -225,7 +360,7 @@ struct MultipartBodyStreamTests { // Build streaming output. let (stream, contentLength) = try DefaultMediaUploader.multipartBodyStream( - fileURL: tempFile, boundary: boundary, filename: filename, mimeType: mimeType + fileURL: tempFile, boundary: boundary, filename: filename, mimeType: mimeType, extraFields: [] ) #expect(contentLength == expected.count) @@ -233,6 +368,67 @@ struct MultipartBodyStreamTests { #expect(result == expected) } + @Test("includes non-file parts (e.g. post) ahead of the file") + func multipartBodyIncludesExtraParts() throws { + let boundary = "boundary" + let filename = "photo.jpg" + let mimeType = "image/jpeg" + let fileContent = Data("image bytes".utf8) + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("stream-extra-\(UUID().uuidString)") + try fileContent.write(to: tempFile) + defer { try? FileManager.default.removeItem(at: tempFile) } + + var expected = Data() + expected.append(Data("--\(boundary)\r\n".utf8)) + expected.append(Data("Content-Disposition: form-data; name=\"post\"\r\n\r\n".utf8)) + expected.append(Data("123\r\n".utf8)) + 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)) + + let (stream, contentLength) = try DefaultMediaUploader.multipartBodyStream( + fileURL: tempFile, boundary: boundary, filename: filename, mimeType: mimeType, + extraFields: [("post", Data("123".utf8))] + ) + #expect(contentLength == expected.count) + #expect(readAllFromStream(stream) == expected) + } + + @Test("forwards a non-UTF-8 field value verbatim") + func multipartBodyPreservesNonUTF8FieldValue() throws { + let boundary = "boundary" + let filename = "photo.jpg" + let mimeType = "image/jpeg" + let fileContent = Data("image bytes".utf8) + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("stream-binary-\(UUID().uuidString)") + try fileContent.write(to: tempFile) + defer { try? FileManager.default.removeItem(at: tempFile) } + + // A field value that is not valid UTF-8 (a lone 0xFF byte between ASCII bytes). + let binaryValue = Data([0x61, 0xFF, 0x62]) + + var expected = Data() + expected.append(Data("--\(boundary)\r\n".utf8)) + expected.append(Data("Content-Disposition: form-data; name=\"blob\"\r\n\r\n".utf8)) + expected.append(binaryValue) + expected.append(Data("\r\n".utf8)) + 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)) + + let (stream, contentLength) = try DefaultMediaUploader.multipartBodyStream( + fileURL: tempFile, boundary: boundary, filename: filename, mimeType: mimeType, + extraFields: [("blob", binaryValue)] + ) + #expect(contentLength == expected.count) + // The raw 0xFF byte survives — it was not coerced through String. + #expect(readAllFromStream(stream) == expected) + } + @Test("content length matches actual stream output for larger files") func contentLengthAccurate() throws { let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("stream-test-\(UUID().uuidString)") @@ -241,7 +437,7 @@ struct MultipartBodyStreamTests { defer { try? FileManager.default.removeItem(at: tempFile) } let (stream, contentLength) = try DefaultMediaUploader.multipartBodyStream( - fileURL: tempFile, boundary: "boundary", filename: "big.bin", mimeType: "application/octet-stream" + fileURL: tempFile, boundary: "boundary", filename: "big.bin", mimeType: "application/octet-stream", extraFields: [] ) let result = readAllFromStream(stream) @@ -249,6 +445,104 @@ struct MultipartBodyStreamTests { } } +// MARK: - DefaultMediaUploader Relay Tests + +@Suite("DefaultMediaUploader relay") +struct DefaultMediaUploaderRelayTests { + + @Test("relays a non-2xx WordPress response instead of throwing") + func relaysErrorResponseVerbatim() async throws { + // A WordPress REST error body, returned with a non-2xx status. + let errorBody = Data(#"{"code":"rest_cannot_create","message":"Sorry, you are not allowed to upload this file type."}"#.utf8) + let client = RelayStubHTTPClient(statusCode: 403, body: errorBody) + let uploader = DefaultMediaUploader(httpClient: client, siteApiRoot: URL(string: "https://example.com/wp-json/")!) + + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("relay-\(UUID().uuidString).jpg") + try Data("fake image".utf8).write(to: tempFile) + defer { try? FileManager.default.removeItem(at: tempFile) } + + // performUpload must route through performRaw, which does NOT validate status, + // so WordPress's 403 + body flow through verbatim. A revert to perform() would + // throw on the non-2xx (RelayStubHTTPClient.perform mirrors that), failing here. + let response = try await uploader.upload( + fileURL: tempFile, mimeType: "image/jpeg", filename: "photo.jpg", extraParts: [], query: "" + ) + + #expect(response.statusCode == 403) + #expect(response.body == errorBody) + } + + @Test("carries the namespace and request query through to the media endpoint") + func forwardsNamespaceAndQuery() async throws { + let client = URLCapturingHTTPClient() + let uploader = DefaultMediaUploader( + httpClient: client, + siteApiRoot: URL(string: "https://example.com/wp-json")!, + siteApiNamespace: ["sites/123"] + ) + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("query-\(UUID().uuidString).jpg") + try Data("img".utf8).write(to: tempFile) + defer { try? FileManager.default.removeItem(at: tempFile) } + + _ = try await uploader.upload( + fileURL: tempFile, mimeType: "image/jpeg", filename: "photo.jpg", + extraParts: [], query: "?_embed=wp:featuredmedia" + ) + + // Namespace inserted via the shared builder, and the query preserved verbatim — + // including the `:`, which would make URL(string:) return nil and drop it (#6). + let url = try #require(client.lastURL) + #expect(url.absoluteString == "https://example.com/wp-json/wp/v2/sites/123/media?_embed=wp:featuredmedia") + } +} + +/// An HTTP client whose `performRaw` relays a canned response without validating +/// status, while `perform` throws on a non-2xx — mirroring the real +/// `EditorHTTPClient`. Lets a test prove `DefaultMediaUploader` routes uploads +/// through `performRaw` (relay) rather than `perform` (throw). +private struct RelayStubHTTPClient: EditorHTTPClientProtocol { + let statusCode: Int + let body: Data + + func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) { + let response = HTTPURLResponse(url: urlRequest.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)! + guard (200...299).contains(statusCode) else { + throw NSError(domain: "RelayStubHTTPClient", code: statusCode) + } + return (body, response) + } + + func performRaw(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) { + let response = HTTPURLResponse(url: urlRequest.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)! + return (body, response) + } + + func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse) { + let response = HTTPURLResponse(url: urlRequest.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)! + return (FileManager.default.temporaryDirectory, response) + } +} + +/// Captures the URL of the last request so a test can assert the media endpoint +/// URL (namespace + query) the uploader built. +private final class URLCapturingHTTPClient: EditorHTTPClientProtocol, @unchecked Sendable { + private let lock = NSLock() + private var _lastURL: URL? + var lastURL: URL? { lock.withLock { _lastURL } } + + private func ok(_ urlRequest: URLRequest) -> (Data, HTTPURLResponse) { + lock.withLock { _lastURL = urlRequest.url } + return (Data("{}".utf8), HTTPURLResponse(url: urlRequest.url!, statusCode: 201, httpVersion: nil, headerFields: nil)!) + } + + func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) { ok(urlRequest) } + func performRaw(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) { ok(urlRequest) } + func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse) { + let (_, response) = ok(urlRequest) + return (FileManager.default.temporaryDirectory, response) + } +} + // MARK: - Helpers /// Reads all bytes from an InputStream using `read()` return value as @@ -284,26 +578,21 @@ private final class MockUploadDelegate: MediaUploadDelegate, @unchecked Sendable var lastMimeType: String? { lock.withLock { _lastMimeType } } var lastFilename: String? { lock.withLock { _lastFilename } } - func processFile(at url: URL, mimeType: String) async throws -> URL { + func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile { lock.withLock { _processFileCalled = true _lastMimeType = mimeType } - return url + return .original } - func uploadFile(at url: URL, mimeType: String, filename: String) async throws -> MediaUploadResult? { + func uploadFile(at url: URL, mimeType: String, filename: String) async throws -> MediaUploadResponse? { lock.withLock { _uploadFileCalled = true _lastFilename = filename } - return MediaUploadResult( - id: 42, - url: "https://example.com/photo.jpg", - title: "photo", - mime: "image/jpeg", - type: "image" - ) + let json = #"{"id":42,"source_url":"https://example.com/photo.jpg","media_type":"image"}"# + return MediaUploadResponse(statusCode: 201, body: Data(json.utf8)) } } @@ -313,9 +602,25 @@ private final class ProcessOnlyDelegate: MediaUploadDelegate, @unchecked Sendabl var processFileCalled: Bool { lock.withLock { _processFileCalled } } - func processFile(at url: URL, mimeType: String) async throws -> URL { + func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile { lock.withLock { _processFileCalled = true } - return url + return .original + } +} + +/// A delegate that produces a new file with changed metadata (e.g. a transcode). +private final class ResizingDelegate: MediaUploadDelegate, @unchecked Sendable { + private let lock = NSLock() + private var _producedURL: URL? + + /// The URL of the processed file this delegate wrote, for cleanup assertions. + var producedURL: URL? { lock.withLock { _producedURL } } + + func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile { + let newURL = url.deletingLastPathComponent().appending(component: "processed-\(UUID().uuidString)") + try Data("processed".utf8).write(to: newURL) + lock.withLock { _producedURL = newURL } + return .processed(newURL, mimeType: "video/mp4", filename: "clip.mp4") } } @@ -323,32 +628,41 @@ private final class MockDefaultUploader: DefaultMediaUploader, @unchecked Sendab private let lock = NSLock() private var _uploadCalled = false private var _passthroughUploadCalled = false + private var _lastUploadMimeType: String? + private var _lastUploadFilename: String? + private var _lastQuery: String? var uploadCalled: Bool { lock.withLock { _uploadCalled } } var passthroughUploadCalled: Bool { lock.withLock { _passthroughUploadCalled } } + var lastUploadMimeType: String? { lock.withLock { _lastUploadMimeType } } + var lastUploadFilename: String? { lock.withLock { _lastUploadFilename } } + var lastQuery: String? { lock.withLock { _lastQuery } } 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 mockResult() + override func upload(fileURL: URL, mimeType: String, filename: String, extraParts: [MultipartPart], query: String) async throws -> MediaUploadResponse { + lock.withLock { + _uploadCalled = true + _lastUploadMimeType = mimeType + _lastUploadFilename = filename + _lastQuery = query + } + return mockResponse() } - override func passthroughUpload(body: RequestBody, contentType: String) async throws -> MediaUploadResult { - lock.withLock { _passthroughUploadCalled = true } - return mockResult() + override func passthroughUpload(body: RequestBody, contentType: String, query: String) async throws -> MediaUploadResponse { + lock.withLock { + _passthroughUploadCalled = true + _lastQuery = query + } + return mockResponse() } - private func mockResult() -> MediaUploadResult { - MediaUploadResult( - id: 99, - url: "https://example.com/doc.pdf", - title: "doc", - mime: "application/pdf", - type: "file" - ) + private func mockResponse() -> MediaUploadResponse { + let json = #"{"id":99,"source_url":"https://example.com/doc.pdf","media_type":"file"}"# + return MediaUploadResponse(statusCode: 201, body: Data(json.utf8)) } } diff --git a/ios/Tests/GutenbergKitTests/WordPressRESTURLTests.swift b/ios/Tests/GutenbergKitTests/WordPressRESTURLTests.swift new file mode 100644 index 000000000..290d90a8a --- /dev/null +++ b/ios/Tests/GutenbergKitTests/WordPressRESTURLTests.swift @@ -0,0 +1,57 @@ +import Foundation +import Testing +@testable import GutenbergKit + +@Suite("WordPressRESTURL") +struct WordPressRESTURLTests { + + @Test("appends the path when no namespace is configured") + func noNamespace() { + let url = WordPressRESTURL.namespaced( + apiRoot: URL(string: "https://example.com/wp-json")!, + path: "/wp/v2/media", + namespace: nil + ) + #expect(url.absoluteString == "https://example.com/wp-json/wp/v2/media") + } + + @Test("inserts the namespace after the version segment") + func insertsNamespace() { + let url = WordPressRESTURL.namespaced( + apiRoot: URL(string: "https://example.com/wp-json")!, + path: "/wp/v2/media", + namespace: "sites/123/" + ) + #expect(url.absoluteString == "https://example.com/wp-json/wp/v2/sites/123/media") + } + + @Test("normalizes an unslashed root and namespace") + func normalizesUnslashed() { + let url = WordPressRESTURL.namespaced( + apiRoot: URL(string: "https://example.com/wp-json")!, // no trailing slash + path: "/wp/v2/media", + namespace: "sites/123" // no trailing slash + ) + #expect(url.absoluteString == "https://example.com/wp-json/wp/v2/sites/123/media") + } + + @Test("does not double the slash when the root already ends in one") + func trailingSlashRoot() { + let url = WordPressRESTURL.namespaced( + apiRoot: URL(string: "https://example.com/wp-json/")!, // trailing slash + path: "/wp/v2/media", + namespace: "sites/123" + ) + #expect(url.absoluteString == "https://example.com/wp-json/wp/v2/sites/123/media") + } + + @Test("inserts the namespace after a non-wp/v2 version segment") + func otherVersionSegment() { + let url = WordPressRESTURL.namespaced( + apiRoot: URL(string: "https://example.com/wp-json")!, + path: "/wp-block-editor/v1/settings", + namespace: "sites/123" + ) + #expect(url.absoluteString == "https://example.com/wp-json/wp-block-editor/v1/sites/123/settings") + } +} diff --git a/src/utils/api-fetch-upload-middleware.test.js b/src/utils/api-fetch-upload-middleware.test.js index 952a5baa8..07bac0062 100644 --- a/src/utils/api-fetch-upload-middleware.test.js +++ b/src/utils/api-fetch-upload-middleware.test.js @@ -202,89 +202,138 @@ describe( 'nativeMediaUploadMiddleware', () => { expect( options.body ).toBeInstanceOf( FormData ); } ); - it( 'transforms native response to WordPress REST API shape', async () => { + it( 'forwards the original body and query to the native server', async () => { getGBKit.mockReturnValue( { - nativeUploadPort: 8080, - nativeUploadToken: 'token', + nativeUploadPort: 12345, + nativeUploadToken: 'test-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', - } ), + json: () => Promise.resolve( { id: 1 } ), } ) ); - const result = await nativeMediaUploadMiddleware( - makePostMediaOptions( makeFile() ), + const body = new FormData(); + body.append( 'file', makeFile(), 'photo.jpg' ); + body.append( 'post', '123' ); + + await nativeMediaUploadMiddleware( + { + method: 'POST', + path: '/wp/v2/media?_embed=wp:featuredmedia', + body, + }, 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', + const [ url, fetchOptions ] = global.fetch.mock.calls[ 0 ]; + // The original query is appended so ?_embed reaches WordPress. + expect( url ).toBe( + 'http://localhost:12345/upload?_embed=wp:featuredmedia' + ); + // The original body is forwarded verbatim, so `post` survives. + expect( fetchOptions.body ).toBe( body ); + expect( fetchOptions.body.get( 'post' ) ).toBe( '123' ); + } ); + + it.each( [ + // A bare trailing `?` carries no parameters. The native `query` + // accessors normalize it away, so this side must too — otherwise the + // upload server receives a `?` the two platforms agree cannot exist. + [ '/wp/v2/media?', 'http://localhost:12345/upload' ], + [ '/wp/v2/media', 'http://localhost:12345/upload' ], + ] )( 'normalizes the query of %s to %s', async ( path, expectedUrl ) => { + getGBKit.mockReturnValue( { + nativeUploadPort: 12345, + nativeUploadToken: 'test-token', } ); + + global.fetch = vi.fn( () => + Promise.resolve( { + ok: true, + json: () => Promise.resolve( { id: 1 } ), + } ) + ); + + const body = new FormData(); + body.append( 'file', makeFile(), 'photo.jpg' ); + + await nativeMediaUploadMiddleware( + { method: 'POST', path, body }, + makeNext() + ); + + const [ url ] = global.fetch.mock.calls[ 0 ]; + expect( url ).toBe( expectedUrl ); } ); - it( 'handles missing optional fields in native response', async () => { + it( 'returns the relayed WordPress attachment unchanged', async () => { getGBKit.mockReturnValue( { nativeUploadPort: 8080, nativeUploadToken: 'token', } ); + // The native server relays WordPress's raw attachment response; the + // middleware must return it verbatim, not reshape it — so consumers get + // the real media_details.sizes, link, and raw/rendered fields. + const attachment = { + 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: 4032, + height: 3024, + sizes: { + large: { + source_url: 'https://example.com/image-1024x768.jpg', + }, + }, + }, + link: 'https://example.com/image/', + }; + 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', - } ), + json: () => Promise.resolve( attachment ), } ) ); const result = await nativeMediaUploadMiddleware( - makePostMediaOptions( makeFile( 'file.pdf', 'application/pdf' ) ), + makePostMediaOptions( makeFile() ), makeNext() ); - expect( result.alt_text ).toBe( '' ); - expect( result.caption ).toEqual( { raw: '', rendered: '' } ); + expect( result ).toEqual( attachment ); } ); // MARK: - Error handling - it( 'throws on non-ok response from local server', async () => { + it( 'rejects with the WordPress error body on a non-ok response', async () => { getGBKit.mockReturnValue( { nativeUploadPort: 8080, nativeUploadToken: 'token', } ); + // The native server relays WordPress's error status + JSON body; the + // middleware rejects with that body as-is (like @wordpress/api-fetch) so + // media-utils surfaces WordPress's real message. global.fetch = vi.fn( () => Promise.resolve( { ok: false, - status: 500, - statusText: 'Internal Server Error', - text: () => Promise.resolve( 'Server crashed' ), + status: 403, + json: () => + Promise.resolve( { + code: 'rest_cannot_create', + message: + 'Sorry, you are not allowed to upload this file type.', + } ), } ) ); @@ -294,19 +343,24 @@ describe( 'nativeMediaUploadMiddleware', () => { makeNext() ) ).rejects.toMatchObject( { - code: 'upload_failed', - message: expect.stringContaining( '500' ), + code: 'rest_cannot_create', + message: expect.stringContaining( 'not allowed' ), } ); } ); - it( 'throws on fetch network error', async () => { + it( 'rejects with invalid_json when the error body is not JSON', async () => { getGBKit.mockReturnValue( { nativeUploadPort: 8080, nativeUploadToken: 'token', } ); global.fetch = vi.fn( () => - Promise.reject( new Error( 'Failed to fetch' ) ) + Promise.resolve( { + ok: false, + status: 502, + json: () => + Promise.reject( new SyntaxError( 'Unexpected token' ) ), + } ) ); await expect( @@ -314,7 +368,111 @@ describe( 'nativeMediaUploadMiddleware', () => { makePostMediaOptions( makeFile() ), makeNext() ) - ).rejects.toBeDefined(); + ).rejects.toMatchObject( { code: 'invalid_json' } ); + } ); + + it( 'rejects with invalid_json when a 2xx response body is not JSON', async () => { + getGBKit.mockReturnValue( { + nativeUploadPort: 8080, + nativeUploadToken: 'token', + } ); + + // A successful status but a non-JSON body (e.g. an HTML error page from + // an intermediary). json() rejects; the middleware must normalize it, not + // surface a raw SyntaxError. + global.fetch = vi.fn( () => + Promise.resolve( { + ok: true, + json: () => + Promise.reject( new SyntaxError( 'Unexpected token' ) ), + } ) + ); + + await expect( + nativeMediaUploadMiddleware( + makePostMediaOptions( makeFile() ), + makeNext() + ) + ).rejects.toMatchObject( { code: 'invalid_json' } ); + } ); + + it( 'surfaces a transport failure instead of retrying (no silent duplicate)', async () => { + getGBKit.mockReturnValue( { + nativeUploadPort: 8080, + nativeUploadToken: 'token', + } ); + const next = makeNext(); + + // A connection-level failure — the loopback server died out-of-band after + // a valid start (reachability is otherwise gated proactively upstream, so + // an unreachable server never advertises a port to fetch). + const connectionError = new TypeError( 'Failed to fetch' ); + global.fetch = vi.fn( () => Promise.reject( connectionError ) ); + + await expect( + nativeMediaUploadMiddleware( + makePostMediaOptions( makeFile() ), + next + ) + ).rejects.toBe( connectionError ); + + // No silent fallback to a direct re-upload — retrying a non-idempotent + // POST could duplicate the attachment. + expect( next ).not.toHaveBeenCalled(); + } ); + + it( 'propagates an abort instead of falling back', async () => { + getGBKit.mockReturnValue( { + nativeUploadPort: 8080, + nativeUploadToken: 'token', + } ); + const next = makeNext(); + + // A real aborted signal — `fetch` rejects with the signal's reason. + const controller = new AbortController(); + controller.abort(); + const options = { + ...makePostMediaOptions( makeFile() ), + signal: controller.signal, + }; + global.fetch = vi.fn( () => + Promise.reject( controller.signal.reason ) + ); + + // The middleware rethrows the signal's canonical reason (not the fetch + // rejection) and does not retry. + await expect( + nativeMediaUploadMiddleware( options, next ) + ).rejects.toBe( controller.signal.reason ); + + // An explicit cancellation must not be retried via the default path. + expect( next ).not.toHaveBeenCalled(); + } ); + + it( 'propagates a timeout cancellation (aborted signal, non-AbortError) instead of falling back', async () => { + getGBKit.mockReturnValue( { + nativeUploadPort: 8080, + nativeUploadToken: 'token', + } ); + const next = makeNext(); + + // `AbortSignal.timeout()` aborts its signal and rejects with a + // TimeoutError (not an AbortError). A `name === 'AbortError'` check would + // miss it and wrongly fall back; keying off `signal.aborted` catches it. + const timeoutError = new Error( 'The operation timed out.' ); + timeoutError.name = 'TimeoutError'; + const options = { + ...makePostMediaOptions( makeFile() ), + signal: { aborted: true, reason: timeoutError }, + }; + global.fetch = vi.fn( () => Promise.reject( timeoutError ) ); + + await expect( + nativeMediaUploadMiddleware( options, next ) + ).rejects.toBe( timeoutError ); + + // A timeout is a cancellation, not an unreachable server — do not retry. + expect( next ).not.toHaveBeenCalled(); } ); // MARK: - Signal forwarding diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js index 395bb9541..e08d03220 100644 --- a/src/utils/api-fetch.js +++ b/src/utils/api-fetch.js @@ -202,59 +202,115 @@ export function nativeMediaUploadMiddleware( options, next ) { `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`, { + // Forward the original request body — the file plus every sibling field + // (`post`, additionalData) — and the original query string (e.g. `?_embed`) + // so the native server can relay them to WordPress unchanged. Rebuilding the + // body with only `file` would drop the post association and additionalData. + const query = requestQuery( options.path ); + + // Use the two-argument form of `.then()` so the rejection handler catches + // *only* a connection-level failure of the `fetch()` itself — not errors + // thrown while handling a response (those must surface as real failures). + return fetch( `http://localhost:${ nativeUploadPort }/upload${ query }`, { method: 'POST', headers: { 'Relay-Authorization': `Bearer ${ nativeUploadToken }`, }, - body: formData, + body: options.body, signal: options.signal, - } ) - .then( ( response ) => { + } ).then( + ( response ) => { + // The native server relays WordPress's response verbatim. On a + // non-2xx, mirror @wordpress/api-fetch: reject with the parsed WP + // error body ({ code, message, data }) so @wordpress/media-utils + // surfaces WordPress's real message. On success, return WordPress's + // attachment object unchanged so every consumer behaves exactly as + // it would for a non-native upload. if ( ! response.ok ) { - return response.text().then( ( body ) => { - const error = new Error( - `Upload failed (${ response.status }): ${ - body || response.statusText - }` - ); - error.code = 'upload_failed'; - throw error; - } ); + return response + .json() + .catch( invalidUploadResponseError ) + .then( ( body ) => { + logError( 'Native upload failed', body ); + throw body; + } ); } - 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; - } ); + // A 2xx with a non-JSON body (e.g. an HTML error page injected by an + // intermediary) rejects json(); normalize it the same way as the + // non-ok path rather than surfacing a raw SyntaxError. + return response.json().catch( () => { + const error = invalidUploadResponseError(); + logError( 'Native upload returned an invalid response', error ); + throw error; + } ); + }, + ( connectionError ) => { + // A caller-initiated cancellation must propagate as the cancellation, + // never be retried. Detect it via `signal.aborted` — the cancellation + // *state* — rather than `connectionError.name === 'AbortError'`: the + // state check also catches `AbortSignal.timeout()` (which rejects with + // a TimeoutError, not an AbortError) and custom abort reasons, which a + // name match would miss and wrongly fall back on. Rethrow the signal's + // `reason` (the canonical abort error), not `connectionError`: if a + // network failure and the abort race, `fetch` can reject with a network + // TypeError even though the signal aborted, and rethrowing that would + // make upstream treat a cancelled upload as a real failure — surfacing + // a spurious error notice instead of a silent cancel. + if ( options.signal?.aborted ) { + throw options.signal.reason; + } + // Otherwise the loopback upload server is unreachable at the transport + // layer. We deliberately do NOT fall back to a direct re-upload: + // reachability is gated proactively upstream — this middleware's guard + // skips the native path when no port is advertised, and the native side + // only advertises a port the WebView can actually reach (server running + // + cleartext-to-localhost permitted, cleared on stop). So reaching here + // means the server died out-of-band after a valid start; retrying a + // non-idempotent POST /wp/v2/media could duplicate the attachment if the + // native server had already relayed it to WordPress. + logError( + 'Native upload failed at the transport layer', + connectionError + ); + throw connectionError; + } + ); +} + +/** + * The query component of a request path, including the leading `?`, or an empty + * string when there is no query. + * + * Mirrors the `query` accessors on the native request types (`HttpRequest` on + * Android, `ParsedHTTPRequest` on iOS): the split is on the first `?`, and a + * bare trailing `?` carries no parameters so it yields an empty string. Keeping + * the three in agreement means the value can be appended to an upstream URL + * unconditionally, whichever side derived it. + * + * @param {string} path The request path, e.g. `/wp/v2/media?_embed`. + * @return {string} The query, e.g. `?_embed`, or `''`. + */ +function requestQuery( path ) { + const separator = path.indexOf( '?' ); + if ( separator === -1 ) { + return ''; + } + const value = path.slice( separator + 1 ); + return value ? `?${ value }` : ''; +} + +/** + * The error rejected when the upload server's response body can't be parsed as + * JSON. Shaped like a WordPress REST error so `@wordpress/media-utils` surfaces + * it the same way as a real one, on both the non-2xx and 2xx paths. + * + * @return {{ code: string, message: string }} The normalized error. + */ +function invalidUploadResponseError() { + return { + code: 'invalid_json', + message: 'The upload server returned an invalid response.', + }; } /** From 93aa2d1296c216b44751561fe72b056a57f141f5 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 22 Jul 2026 09:34:02 -0400 Subject: [PATCH 14/21] fix: harden native upload middleware guard and post sentinel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes to `nativeMediaUploadMiddleware`, both aligning it with the existing `mediaUploadMiddleware` it short-circuits past. Strip the `post` field when it is the auto-draft sentinel (`-1`). WordPress rejects attaching media to post `-1`, and because this middleware returns the relay fetch without calling `next()`, `mediaUploadMiddleware` (which normally strips the sentinel) never runs — so the value reached WordPress verbatim and broke uploads on the common new-post flow. Require `nativeUploadToken` in the activation guard. The server always requires authentication, so a port-without-token state would send `Bearer undefined` and 407 instead of falling through to the default upload path. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/utils/api-fetch-upload-middleware.test.js | 39 +++++++++++++++++++ src/utils/api-fetch.js | 10 +++++ 2 files changed, 49 insertions(+) diff --git a/src/utils/api-fetch-upload-middleware.test.js b/src/utils/api-fetch-upload-middleware.test.js index 07bac0062..e898b3ba8 100644 --- a/src/utils/api-fetch-upload-middleware.test.js +++ b/src/utils/api-fetch-upload-middleware.test.js @@ -238,6 +238,45 @@ describe( 'nativeMediaUploadMiddleware', () => { expect( fetchOptions.body.get( 'post' ) ).toBe( '123' ); } ); + it( 'strips the auto-draft sentinel `post` value of -1 before forwarding', async () => { + getGBKit.mockReturnValue( { + nativeUploadPort: 12345, + nativeUploadToken: 'test-token', + } ); + + global.fetch = vi.fn( () => + Promise.resolve( { + ok: true, + json: () => Promise.resolve( { id: 1 } ), + } ) + ); + + const body = new FormData(); + body.append( 'file', makeFile(), 'photo.jpg' ); + body.append( 'post', '-1' ); + + await nativeMediaUploadMiddleware( + { method: 'POST', path: '/wp/v2/media', body }, + makeNext() + ); + + // WordPress rejects attaching media to post `-1`. Because this + // middleware short-circuits, `mediaUploadMiddleware` never runs, so the + // stripping must happen here or the sentinel reaches WordPress. + const [ , fetchOptions ] = global.fetch.mock.calls[ 0 ]; + expect( fetchOptions.body.get( 'post' ) ).toBeNull(); + } ); + + it( 'passes through when nativeUploadToken is not configured', () => { + getGBKit.mockReturnValue( { nativeUploadPort: 8080 } ); + const next = makeNext(); + + nativeMediaUploadMiddleware( makePostMediaOptions( makeFile() ), next ); + + expect( next ).toHaveBeenCalled(); + expect( global.fetch ).not.toHaveBeenCalled(); + } ); + it.each( [ // A bare trailing `?` carries no parameters. The native `query` // accessors normalize it away, so this side must too — otherwise the diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js index e08d03220..f74d33414 100644 --- a/src/utils/api-fetch.js +++ b/src/utils/api-fetch.js @@ -184,6 +184,7 @@ export function nativeMediaUploadMiddleware( options, next ) { if ( ! nativeUploadPort || + ! nativeUploadToken || ! options.method || options.method.toUpperCase() !== 'POST' || ! options.path || @@ -198,6 +199,15 @@ export function nativeMediaUploadMiddleware( options, next ) { return next( options ); } + // Strip the `post` field when it's the auto-draft sentinel (`-1`), matching + // `mediaUploadMiddleware`. WordPress rejects attaching media to post `-1`, and + // because this middleware short-circuits (never calls `next()`), + // `mediaUploadMiddleware` would otherwise not run and the sentinel would reach + // WordPress verbatim through the relay. + if ( options.body.get( 'post' ) === '-1' ) { + options.body.delete( 'post' ); + } + info( `Routing upload of ${ file.name } through native server on port ${ nativeUploadPort }` ); From a08d7d51e4795a0ff73d17087f21493656287381 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 22 Jul 2026 09:34:11 -0400 Subject: [PATCH 15/21] fix(ios): don't start upload server without an auth header `startUploadServer` built `DefaultMediaUploader` whenever a media upload delegate was set, ignoring whether an auth header was present. It uploads to `/wp/v2/media` with the host's `Authorization` header (the WebView has none of its own), so with an empty header every relayed upload would 401 instead of falling back to the default WebView path. Guard on a non-empty auth header before starting the server, matching the Android guard in `GutenbergView.startUploadServer`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../GutenbergKit/Sources/EditorViewController.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 1fa83b822..a200c512e 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -392,6 +392,15 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro return } + // The native upload server relays through DefaultMediaUploader, which needs a + // site root and an auth header (every host provides one — the editor injects + // it because the WebView has no auth cookies). Without both there is nothing + // to upload through, so leave the server down and let uploads fall to the + // default WebView path rather than start a server that could only fail. + guard !configuration.authHeader.isEmpty else { + return + } + let defaultUploader = DefaultMediaUploader( httpClient: httpClient, siteApiRoot: configuration.siteApiRoot, From 9edc3ec9c2db82e3b337a3fa7f30713073b0798d Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 22 Jul 2026 09:42:41 -0400 Subject: [PATCH 16/21] fix(ios): throw instead of trapping when the upload temp file can't open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `writeStream` force-unwrapped `OutputStream(url:append:)`, which returns nil if the file can't be opened for writing (uploads directory removed after creation, or a permissions/sandbox failure). That trapped and crashed the process instead of returning the clean 500 the caller already builds. Throw `streamWriteFailed` so it flows into the existing do/catch — temp file removed, 500 relayed to the editor. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../GutenbergKit/Sources/Media/MediaUploadServer.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift index 53795d79f..a271865ce 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -286,7 +286,13 @@ final class MediaUploadServer: Sendable { inputStream.open() defer { inputStream.close() } - let outputStream = OutputStream(url: url, append: false)! + // `OutputStream(url:append:)` returns nil if the file can't be opened for + // writing (e.g. the uploads directory was removed after it was created, or + // a permissions/sandbox failure). Throw rather than force-unwrap so the + // caller returns a clean 500 instead of trapping the process. + guard let outputStream = OutputStream(url: url, append: false) else { + throw UploadError.streamWriteFailed + } outputStream.open() defer { outputStream.close() } From e639b78935b16fd841e06f817bcb611c7d189309 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 22 Jul 2026 10:31:04 -0400 Subject: [PATCH 17/21] fix: remove redundant post sentinel strip from native upload middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Partially reverts 93aa2d12. api-fetch runs middleware in reverse registration order, so `mediaUploadMiddleware` (registered later) runs first and has already stripped the auto-draft `post: -1` sentinel by the time `nativeMediaUploadMiddleware` runs — the strip was dead code based on an inverted ordering assumption. The `nativeUploadToken` guard from that commit remains. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/utils/api-fetch-upload-middleware.test.js | 29 ------------------- src/utils/api-fetch.js | 9 ------ 2 files changed, 38 deletions(-) diff --git a/src/utils/api-fetch-upload-middleware.test.js b/src/utils/api-fetch-upload-middleware.test.js index e898b3ba8..2bdae9c13 100644 --- a/src/utils/api-fetch-upload-middleware.test.js +++ b/src/utils/api-fetch-upload-middleware.test.js @@ -238,35 +238,6 @@ describe( 'nativeMediaUploadMiddleware', () => { expect( fetchOptions.body.get( 'post' ) ).toBe( '123' ); } ); - it( 'strips the auto-draft sentinel `post` value of -1 before forwarding', async () => { - getGBKit.mockReturnValue( { - nativeUploadPort: 12345, - nativeUploadToken: 'test-token', - } ); - - global.fetch = vi.fn( () => - Promise.resolve( { - ok: true, - json: () => Promise.resolve( { id: 1 } ), - } ) - ); - - const body = new FormData(); - body.append( 'file', makeFile(), 'photo.jpg' ); - body.append( 'post', '-1' ); - - await nativeMediaUploadMiddleware( - { method: 'POST', path: '/wp/v2/media', body }, - makeNext() - ); - - // WordPress rejects attaching media to post `-1`. Because this - // middleware short-circuits, `mediaUploadMiddleware` never runs, so the - // stripping must happen here or the sentinel reaches WordPress. - const [ , fetchOptions ] = global.fetch.mock.calls[ 0 ]; - expect( fetchOptions.body.get( 'post' ) ).toBeNull(); - } ); - it( 'passes through when nativeUploadToken is not configured', () => { getGBKit.mockReturnValue( { nativeUploadPort: 8080 } ); const next = makeNext(); diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js index f74d33414..3a5e4d079 100644 --- a/src/utils/api-fetch.js +++ b/src/utils/api-fetch.js @@ -199,15 +199,6 @@ export function nativeMediaUploadMiddleware( options, next ) { return next( options ); } - // Strip the `post` field when it's the auto-draft sentinel (`-1`), matching - // `mediaUploadMiddleware`. WordPress rejects attaching media to post `-1`, and - // because this middleware short-circuits (never calls `next()`), - // `mediaUploadMiddleware` would otherwise not run and the sentinel would reach - // WordPress verbatim through the relay. - if ( options.body.get( 'post' ) === '-1' ) { - options.body.delete( 'post' ); - } - info( `Routing upload of ${ file.name } through native server on port ${ nativeUploadPort }` ); From acea3f54dfdff0bf4fee4e76d5cca5b469bf904f Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 22 Jul 2026 10:44:30 -0400 Subject: [PATCH 18/21] fix: check upload server auth before draining oversized bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The oversized-payload path (drain plus serverError handler dispatch) ran before the bearer-token check, so unauthenticated clients could make the server read an arbitrarily large declared body and reach the handler. Reorder the connection flow on both platforms so auth is validated on headers alone first: unauthenticated oversized requests are rejected with an immediate 407, while authenticated ones keep the full drain and CORS-stamped 413 (RFC 9110 §15.5.14). Add regression tests pinning the ordering — an unauthenticated oversized request must return 407, not the handler's 413. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../org/wordpress/gutenberg/HttpServer.kt | 47 ++++++------ .../HttpServerAuthenticationTests.kt | 75 +++++++++++++++++++ ios/Sources/GutenbergKitHTTP/HTTPServer.swift | 34 +++++---- .../Media/MediaUploadServerTests.swift | 25 +++++++ 4 files changed, 144 insertions(+), 37 deletions(-) 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 8d0885539..8b610614f 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/HttpServer.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/HttpServer.kt @@ -341,12 +341,6 @@ class HttpServer( // Phase 1: receive headers only. 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). val partial = try { parser.parseRequest() @@ -373,6 +367,31 @@ class HttpServer( return } + // Check auth on headers alone, before draining or consuming any + // body bytes — an unauthenticated client must not be able to make + // the server read (and discard) an arbitrarily large body, and the + // handler must never see an unauthenticated request. + // OPTIONS is exempt because CORS preflight requests + // never include credentials (Fetch spec §3.3.5). + if (requiresAuthentication && partial.method.uppercase() != "OPTIONS") { + val proxyAuth = partial.header("Proxy-Authorization") + ?: partial.header("Relay-Authorization") + if (!authenticate(proxyAuth, token)) { + sendResponse(socket, HttpResponse( + status = 407, + headers = mapOf("Content-Type" to "text/plain", "Proxy-Authenticate" to "Bearer") + )) + return + } + } + + // Drain the oversized body before responding so the (authenticated) + // client receives the 413 instead of a connection reset + // (RFC 9110 §15.5.14). + if (parser.state == HTTPRequestParser.State.DRAINING) { + readUntil(parser, input, buffer, deadlineNanos) { it.isComplete } + } + // 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 -> @@ -398,22 +417,6 @@ class HttpServer( return } - // Check auth before consuming body to avoid buffering up to - // maxBodySize for unauthenticated clients. - // OPTIONS is exempt because CORS preflight requests - // never include credentials (Fetch spec §3.3.5). - if (requiresAuthentication && partial.method.uppercase() != "OPTIONS") { - val proxyAuth = partial.header("Proxy-Authorization") - ?: partial.header("Relay-Authorization") - if (!authenticate(proxyAuth, token)) { - sendResponse(socket, HttpResponse( - status = 407, - headers = mapOf("Content-Type" to "text/plain", "Proxy-Authenticate" to "Bearer") - )) - return - } - } - // Reject body-bearing methods without Content-Length. // We don't support Transfer-Encoding: chunked, so // Content-Length is the only way to determine body size. diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/HttpServerAuthenticationTests.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/HttpServerAuthenticationTests.kt index 3c0a27ed2..c7d882dc1 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/HttpServerAuthenticationTests.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/HttpServerAuthenticationTests.kt @@ -255,6 +255,76 @@ class HttpServerAuthenticationTests { } } + // Oversized Payloads (auth precedes drain) + + @Test + fun `oversized request without token returns 407, not 413`() { + val smallServer = oversizedTestServer() + try { + val conn = oversizedPost(smallServer) + try { + // Auth is checked on headers alone, before the oversized body is + // drained or the handler runs — so the request is rejected with + // 407, not answered with the handler's 413. An unauthenticated + // client must not be able to make the server read (and discard) + // an arbitrarily large body. + assertEquals(407, conn.responseCode) + } finally { + conn.disconnect() + } + } finally { + smallServer.stop() + } + } + + @Test + fun `oversized request with valid token reaches handler as serverError 413`() { + val smallServer = oversizedTestServer() + try { + val conn = oversizedPost(smallServer) { + it.setRequestProperty("Proxy-Authorization", "Bearer ${smallServer.token}") + } + try { + assertEquals(413, conn.responseCode) + } finally { + conn.disconnect() + } + } finally { + smallServer.stop() + } + } + + /** A server whose 1 KB body limit lets a 2 KB POST exercise the drain path. */ + private fun oversizedTestServer(): HttpServer { + val smallServer = HttpServer( + name = "auth-drain-test", + externallyAccessible = false, + requiresAuthentication = true, + maxBodySize = 1024L, + handler = { request -> + request.serverError?.let { error -> + HttpResponse(status = error.httpStatus, body = "too large".toByteArray()) + } ?: HttpResponse(body = "OK\n".toByteArray()) + } + ) + smallServer.start() + return smallServer + } + + /** Sends a 2 KB POST to [smallServer], applying [configure] before writing the body. */ + private fun oversizedPost( + smallServer: HttpServer, + configure: (HttpURLConnection) -> Unit = {} + ): HttpURLConnection { + val conn = URL("http://127.0.0.1:${smallServer.port}/test").openConnection() as HttpURLConnection + conn.requestMethod = "POST" + configure(conn) + conn.doOutput = true + conn.setFixedLengthStreamingMode(OVERSIZED_BODY_SIZE) + conn.outputStream.use { it.write(ByteArray(OVERSIZED_BODY_SIZE)) } + return conn + } + // Auth Disabled @Test @@ -279,4 +349,9 @@ class HttpServerAuthenticationTests { noAuthServer.stop() } } + + companion object { + /** Twice the oversized test server's 1 KB `maxBodySize`. */ + private const val OVERSIZED_BODY_SIZE = 2048 + } } diff --git a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift index 2a85781c2..2c6ca8d89 100644 --- a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift +++ b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift @@ -282,26 +282,16 @@ 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. + // Check auth on headers alone, before draining or + // consuming any body bytes — an unauthenticated client + // must not be able to make the server read (and + // discard) an arbitrarily large body, and the handler + // must never see an unauthenticated request. // OPTIONS is exempt because CORS preflight requests // never include credentials (Fetch spec §3.3.5). if requiresAuthentication && partial.method.uppercased() != "OPTIONS" { @@ -310,6 +300,20 @@ public final class HTTPServer: Sendable { } } + // Drain the oversized body before responding so the + // (authenticated) client receives the 413 instead of + // a connection reset (RFC 9110 §15.5.14). + if parser.state == .draining { + try await Self.receiveUntil(\.isComplete, parser: parser, on: connection, idleTimeout: idleTimeout) + } + + // 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 + } + // Reject body-bearing methods without Content-Length. // We don't support Transfer-Encoding: chunked, so // Content-Length is the only way to determine body size. diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index 1d29e4ddc..be3c6cbf3 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -275,6 +275,31 @@ struct MediaUploadServerTests { #expect(responseBody.contains("too large")) } + @Test("unauthenticated oversized request returns 407, not 413 (auth precedes drain)") + func oversizedUploadWithoutTokenReturns407() 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" + // Deliberately no Relay-Authorization header. + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.httpBody = body + + // Auth is checked on headers alone, before the oversized body is drained + // or the handler runs — so the request is rejected with 407, not answered + // with the handler's 413. An unauthenticated client must not be able to + // make the server read (and discard) an arbitrarily large body. + let (_, response) = try await URLSession.shared.data(for: request) + let httpResponse = try #require(response as? HTTPURLResponse) + #expect(httpResponse.statusCode == 407) + } + @Test("startup sweep deletes stale upload temps but preserves fresh ones") func cleanOrphanedUploadsAgeThreshold() async throws { let dir = FileManager.default.temporaryDirectory From 7da893b350f8bf37beb3717a3365a427273e4d85 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 22 Jul 2026 11:06:49 -0400 Subject: [PATCH 19/21] fix(ios): bound the HTTP server's listener readiness wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTPServer.start awaited NWListener readiness with no bound and treated .waiting as "keep waiting" — a listener stuck in that state (which emits no further updates) suspended the caller forever. Since the editor awaits the upload server start before loading the WebView, a stuck listener blocked editor startup indefinitely behind the activity spinner. Race the readiness wait against a startTimeout (default 5s; loopback binds normally complete in milliseconds) and throw failedToStart on expiry, cancelling the half-started listener. Callers already degrade gracefully on failedToStart, falling back to the default upload path. The wait is extracted into an internal firstTerminalState helper because a stuck listener cannot be reproduced deterministically with a real NWListener — tests feed it a hand-built state stream instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- ios/Sources/GutenbergKitHTTP/HTTPServer.swift | 97 ++++++++++++++----- .../HTTPServerStartTests.swift | 75 ++++++++++++++ 2 files changed, 150 insertions(+), 22 deletions(-) create mode 100644 ios/Tests/GutenbergKitHTTPTests/HTTPServerStartTests.swift diff --git a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift index 2c6ca8d89..162a5ad80 100644 --- a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift +++ b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift @@ -120,6 +120,12 @@ public final class HTTPServer: Sendable { /// If no data arrives within this interval, the connection is closed with a 408 response. public static let defaultIdleTimeout: Duration = .seconds(5) + /// The default maximum time to wait for the listener to become ready (5 seconds). + /// Binding to loopback normally completes in milliseconds; the bound exists so a + /// listener stuck in the `.waiting` state (which emits no further updates) + /// cannot suspend its caller indefinitely. + public static let defaultStartTimeout: Duration = .seconds(5) + /// The maximum number of bytes to read from the network in a single receive call. private static let readChunkSize: Int = 65536 @@ -144,10 +150,14 @@ public final class HTTPServer: Sendable { /// the connection. Defaults to 30 seconds. /// - idleTimeout: The maximum time to wait between consecutive reads before closing /// the connection. Prevents slow-loris attacks. Defaults to 5 seconds. + /// - startTimeout: The maximum time to wait for the listener to become ready + /// before giving up with ``HTTPServerError/failedToStart``. Bounds a listener + /// stuck in the `.waiting` state. Defaults to 5 seconds. /// - handler: A closure invoked for each fully-parsed request. Return an ``HTTPResponse`` /// to send back to the client. /// - Returns: A running ``HTTPServer`` instance. - /// - Throws: ``HTTPServerError/failedToStart`` if the listener cannot bind to the port. + /// - Throws: ``HTTPServerError/failedToStart`` if the listener cannot bind to the port + /// or does not become ready within `startTimeout`. public static func start( name: String, port: UInt16? = nil, @@ -157,6 +167,7 @@ public final class HTTPServer: Sendable { maxConnections: Int = HTTPServer.defaultMaxConnections, readTimeout: Duration = HTTPServer.defaultReadTimeout, idleTimeout: Duration = HTTPServer.defaultIdleTimeout, + startTimeout: Duration = HTTPServer.defaultStartTimeout, cors: CORSPolicy = .none, handler: @escaping @Sendable (HTTPServer.Request) async -> HTTPResponse ) async throws -> HTTPServer { @@ -204,34 +215,76 @@ public final class HTTPServer: Sendable { // Bridge listener state callbacks to an AsyncStream so we can await readiness. // The listener is started synchronously — only the wait is async. - let states = AsyncStream { continuation in - listener.stateUpdateHandler = { state in - continuation.yield(state) - } + let (states, statesContinuation) = AsyncStream.makeStream(of: NWListener.State.self) + listener.stateUpdateHandler = { state in + statesContinuation.yield(state) } listener.start(queue: queue) - for await state in states { - switch state { - case .ready: - listener.stateUpdateHandler = nil - guard let p = listener.port else { - throw HTTPServerError.failedToStart - } - let server = HTTPServer(listener: listener, port: p.rawValue, queue: queue, token: token, connectionTasks: connectionTasks) - Logger.httpServer.info("HTTP server started on port \(p.rawValue)") - return server - case .failed(let error): - Logger.httpServer.error("Listener failed: \(error)") - throw HTTPServerError.failedToStart - case .cancelled: + guard let terminalState = await firstTerminalState(in: states, timeout: startTimeout) else { + // No terminal state within the timeout: the listener is stuck in + // `.setup` or `.waiting` (which emit no further state updates), or + // the surrounding task was cancelled. Cancel the half-started + // listener so it doesn't leak. + listener.stateUpdateHandler = nil + listener.cancel() + Logger.httpServer.error("Listener not ready within \(startTimeout); giving up") + throw HTTPServerError.failedToStart + } + + listener.stateUpdateHandler = nil + switch terminalState { + case .ready: + guard let p = listener.port else { + listener.cancel() throw HTTPServerError.failedToStart - default: - continue } + let server = HTTPServer(listener: listener, port: p.rawValue, queue: queue, token: token, connectionTasks: connectionTasks) + Logger.httpServer.info("HTTP server started on port \(p.rawValue)") + return server + case .failed(let error): + Logger.httpServer.error("Listener failed: \(error)") + listener.cancel() + throw HTTPServerError.failedToStart + default: // .cancelled + throw HTTPServerError.failedToStart } + } - throw HTTPServerError.failedToStart + /// Awaits the first terminal listener state (`.ready`, `.failed`, or + /// `.cancelled`) on `states`, skipping non-terminal states (`.setup`, + /// `.waiting`), or returns nil once `timeout` elapses without one. + /// + /// Internal for testability: a listener stuck in `.waiting` cannot be + /// reproduced deterministically with a real `NWListener`, but the wait's + /// behavior can be verified by feeding this a hand-built state stream. + static func firstTerminalState( + in states: AsyncStream, + timeout: Duration + ) async -> NWListener.State? { + await withTaskGroup(of: NWListener.State?.self) { group in + group.addTask { + for await state in states { + switch state { + case .ready, .failed, .cancelled: + return state + default: + continue + } + } + // The stream finished (or the task was cancelled) without a + // terminal state. + return nil + } + group.addTask { + try? await Task.sleep(for: timeout) + return nil + } + // First child to finish wins: a terminal state, or nil on timeout. + let first = await group.next() ?? nil + group.cancelAll() + return first + } } /// Stops the server and releases resources. diff --git a/ios/Tests/GutenbergKitHTTPTests/HTTPServerStartTests.swift b/ios/Tests/GutenbergKitHTTPTests/HTTPServerStartTests.swift new file mode 100644 index 000000000..7c90f0ed0 --- /dev/null +++ b/ios/Tests/GutenbergKitHTTPTests/HTTPServerStartTests.swift @@ -0,0 +1,75 @@ +#if canImport(Network) + +import Foundation +import Network +import Testing +@testable import GutenbergKitHTTP + +@Suite("HTTPServer Start") +struct HTTPServerStartTests { + + @Test("readiness wait returns nil after the timeout when only non-terminal states arrive") + func readinessWaitTimesOut() async { + // A listener stuck in `.setup`/`.waiting` emits no further state + // updates. Without the bound, the wait — and whatever startup path + // awaits `HTTPServer.start` — would suspend forever. + let (states, continuation) = AsyncStream.makeStream(of: NWListener.State.self) + continuation.yield(.setup) + continuation.yield(.waiting(.posix(.EADDRINUSE))) + + let clock = ContinuousClock() + let started = clock.now + let result = await HTTPServer.firstTerminalState(in: states, timeout: .milliseconds(200)) + let elapsed = clock.now - started + continuation.finish() + + #expect(result == nil) + // Waited out the timeout, then returned promptly instead of hanging. + #expect(elapsed >= .milliseconds(150)) + #expect(elapsed < .seconds(5)) + } + + @Test("readiness wait skips non-terminal states and returns the first terminal one") + func readinessWaitReturnsTerminalState() async { + let (states, continuation) = AsyncStream.makeStream(of: NWListener.State.self) + continuation.yield(.setup) + continuation.yield(.waiting(.posix(.EADDRINUSE))) + continuation.yield(.ready) + + let result = await HTTPServer.firstTerminalState(in: states, timeout: .seconds(5)) + continuation.finish() + + guard case .ready = result else { + Issue.record("Expected .ready, got \(String(describing: result))") + return + } + } + + @Test("start fails promptly when the port is already taken (no hang)") + func startFailsPromptlyOnPortConflict() async throws { + let first = try await HTTPServer.start(name: "start-conflict-test-a") { _ in + HTTPResponse(status: 200) + } + defer { first.stop() } + + let clock = ContinuousClock() + let started = clock.now + await #expect(throws: HTTPServerError.self) { + _ = try await HTTPServer.start( + name: "start-conflict-test-b", + port: first.port, + startTimeout: .milliseconds(500) + ) { _ in + HTTPResponse(status: 200) + } + } + let elapsed = clock.now - started + + // Whether the conflict surfaces as an immediate `.failed` or parks the + // listener in `.waiting`, start() must give up within the bounded wait + // rather than suspending its caller indefinitely. + #expect(elapsed < .seconds(5)) + } +} + +#endif From f1ce3cccfe76273082a5aff57399817abce8d499 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 22 Jul 2026 11:51:42 -0400 Subject: [PATCH 20/21] fix(ios): don't cancel the fast-path editor load on view disappearance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fast path (dependencies provided at init) became an async Task when loadEditor started awaiting the upload server, and it was tracked in dependencyTaskHandle — which viewDidDisappear cancels. A transient disappearance (e.g. a full-screen modal presented right after the editor) cancelled the load mid startUploadServer(), surfacing as failedToStart and silently disabling native uploads for the session with no retry. Run the fast path untracked: the disappear-cancel exists to abort the async dependency *fetch*, while the fast path is cheap local work that must run to completion — restoring the guarantee it had when it was synchronous. The weak self capture still makes it a no-op after teardown. Leaving the task untracked is safe because the preceding commit bounds the upload server's listener readiness wait (startTimeout) — the only await here that could hang — so the task always terminates. Reverting that commit alone would reintroduce an unbounded, now-uncancellable suspension on this path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../GutenbergKit/Sources/EditorViewController.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index a200c512e..87d86a703 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -243,8 +243,16 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } if let dependencies { - // FAST PATH: Dependencies were provided at init() - load immediately - self.dependencyTaskHandle = Task(priority: .userInitiated) { [weak self] in + // FAST PATH: Dependencies were provided at init() - load immediately. + // + // Deliberately NOT tracked in `dependencyTaskHandle`: `viewDidDisappear` + // cancels that handle to abort the async dependency *fetch*, but the + // fast path is cheap local work that must run to completion — a + // transient disappearance (e.g. a modal presented over the editor) + // cancelling it mid `startUploadServer()` silently disabled native + // uploads for the session. `[weak self]` still makes it a no-op once + // the controller is torn down. + Task(priority: .userInitiated) { [weak self] in do { try await self?.loadEditor(dependencies: dependencies) } catch { From 45219181e3aa60b5ce3119367758d90e6b6beb0b Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 22 Jul 2026 11:10:10 -0400 Subject: [PATCH 21/21] perf(ios): move orphan temp file sweeps off the editor-startup path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both startup sweeps — cleanOrphanedUploads in MediaUploadServer.start and cleanOrphanedTempFiles in HTTPServer.start — ran synchronous directory scans on the editor-load critical path. Detach them to a utility-priority task instead, mirroring Android's cleanupJob; each server exposes the task so tests can await completion. Detaching cleanOrphanedTempFiles required giving it the same one-hour age threshold the upload sweep already uses: it previously deleted every file in the server's temp subdirectory, which a detached sweep could race against a live instance's in-flight temp buffers. The threshold also removes the documented hazard of same-name instances wiping each other's files. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/Media/MediaUploadServer.swift | 17 +++++-- ios/Sources/GutenbergKitHTTP/HTTPServer.swift | 44 ++++++++++++------- .../RFC9112ConformanceTests.swift | 23 ++++++++-- .../Media/MediaUploadServerTests.swift | 7 +-- 4 files changed, 66 insertions(+), 25 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift index a271865ce..ec813fd11 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -22,6 +22,10 @@ final class MediaUploadServer: Sendable { private let server: HTTPServer + /// Sweeps crash-orphaned upload temp files off the editor-startup path. + /// Exposed so tests can await completion. (Mirrors Android's `cleanupJob`.) + let cleanupTask: Task + /// Creates and starts a new upload server. /// /// - Parameters: @@ -34,8 +38,12 @@ final class MediaUploadServer: Sendable { defaultUploader: DefaultMediaUploader? = nil, maxRequestBodySize: Int64 = HTTPRequestParser.defaultMaxBodySize ) async throws -> MediaUploadServer { - // Sweep temp files orphaned by a prior crash before starting. - cleanOrphanedUploads() + // Sweep temp files orphaned by a prior crash, off the editor-startup + // path — the sweep only deletes stale files (>1 hour old), so it cannot + // race this server's own in-flight uploads and nothing below depends on it. + let cleanupTask = Task.detached(priority: .utility) { + cleanOrphanedUploads() + } let context = UploadContext(uploadDelegate: uploadDelegate, defaultUploader: defaultUploader) @@ -49,13 +57,14 @@ final class MediaUploadServer: Sendable { } ) - return MediaUploadServer(server: server) + return MediaUploadServer(server: server, cleanupTask: cleanupTask) } - private init(server: HTTPServer) { + private init(server: HTTPServer, cleanupTask: Task) { self.server = server self.port = server.port self.token = server.token + self.cleanupTask = cleanupTask } /// Stops the server and releases resources. diff --git a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift index 162a5ad80..0860da1d4 100644 --- a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift +++ b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift @@ -102,12 +102,17 @@ public final class HTTPServer: Sendable { private let queue: DispatchQueue private let connectionTasks: ConnectionTasks - private init(listener: NWListener, port: UInt16, queue: DispatchQueue, token: String, connectionTasks: ConnectionTasks) { + /// Sweeps crash-orphaned temp files off the caller's startup path. + /// Exposed so tests can await completion. + let cleanupTask: Task + + private init(listener: NWListener, port: UInt16, queue: DispatchQueue, token: String, connectionTasks: ConnectionTasks, cleanupTask: Task) { self.listener = listener self.port = port self.queue = queue self.token = token self.connectionTasks = connectionTasks + self.cleanupTask = cleanupTask } /// The default maximum number of concurrent connections. @@ -180,10 +185,14 @@ public final class HTTPServer: Sendable { let tempDirectory = FileManager.default.temporaryDirectory .appendingPathComponent("GutenbergKitHTTP-\(safeName)") - // Clean up temp files left behind by previous runs (e.g., crash or process kill). - // Swift's ARC guarantees deterministic cleanup during normal operation, but a - // crash can leave orphaned files in the system temp directory. - cleanOrphanedTempFiles(in: tempDirectory) + // Clean up temp files left behind by previous runs (e.g., crash or process + // kill), off the caller's startup path. Swift's ARC guarantees deterministic + // cleanup during normal operation, but a crash can leave orphaned files in + // the system temp directory. The sweep's one-hour age threshold means it + // cannot race temp files written by this (or any live) server instance. + let cleanupTask = Task.detached(priority: .utility) { + cleanOrphanedTempFiles(in: tempDirectory) + } try? FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) let parameters = NWParameters.tcp @@ -239,7 +248,7 @@ public final class HTTPServer: Sendable { listener.cancel() throw HTTPServerError.failedToStart } - let server = HTTPServer(listener: listener, port: p.rawValue, queue: queue, token: token, connectionTasks: connectionTasks) + let server = HTTPServer(listener: listener, port: p.rawValue, queue: queue, token: token, connectionTasks: connectionTasks, cleanupTask: cleanupTask) Logger.httpServer.info("HTTP server started on port \(p.rawValue)") return server case .failed(let error): @@ -636,19 +645,24 @@ public final class HTTPServer: Sendable { /// to a single server instance and will not affect files belonging to other /// servers running concurrently. /// - /// **Important:** Two server instances with the same `name` must not run - /// concurrently. On startup, this method deletes **all** files in the - /// server's temp subdirectory. If another instance with the same name is - /// still handling requests, its in-flight temp files will be removed, - /// causing `bufferIOError` failures. Callers must ensure each running - /// server uses a unique name, or that the previous instance is fully - /// stopped before starting a new one. + /// Only files older than one hour are deleted. Fresh files are preserved so + /// the sweep — which runs detached from `start()` — cannot race in-flight + /// temp files, whether they belong to this instance or another live server + /// sharing the same `name`. private static func cleanOrphanedTempFiles(in directory: URL) { + // Only delete files past the age threshold. Fresh files may belong to a + // live server instance — the sweep runs detached from start(), so + // without the threshold it could race and delete an in-flight + // request's temp buffer. + let cutoff = Date(timeIntervalSinceNow: -3600) // 1 hour ago guard let contents = try? FileManager.default.contentsOfDirectory( - at: directory, includingPropertiesForKeys: nil + at: directory, includingPropertiesForKeys: [.contentModificationDateKey] ) else { return } for url in contents { - try? FileManager.default.removeItem(at: url) + let modified = (try? url.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate + if let modified, modified < cutoff { + try? FileManager.default.removeItem(at: url) + } } } } diff --git a/ios/Tests/GutenbergKitHTTPTests/RFC9112ConformanceTests.swift b/ios/Tests/GutenbergKitHTTPTests/RFC9112ConformanceTests.swift index 8127f8ad8..7dd55a0f3 100644 --- a/ios/Tests/GutenbergKitHTTPTests/RFC9112ConformanceTests.swift +++ b/ios/Tests/GutenbergKitHTTPTests/RFC9112ConformanceTests.swift @@ -568,7 +568,7 @@ struct RFC9112ConformanceTests { // MARK: - L5: Orphaned temp file cleanup - @Test("cleanOrphanedTempFiles removes files in the server-specific temp directory") + @Test("cleanOrphanedTempFiles removes stale files in the server-specific temp directory") func orphanedTempFilesCleanedOnStart() async throws { let serverName = "orphan-cleanup-test" let serverTempDir = FileManager.default.temporaryDirectory @@ -577,20 +577,37 @@ struct RFC9112ConformanceTests { let orphan1 = serverTempDir.appendingPathComponent("GutenbergKitHTTP-\(UUID().uuidString)") let orphan2 = serverTempDir.appendingPathComponent("GutenbergKitHTTP-\(UUID().uuidString)") + let fresh = serverTempDir.appendingPathComponent("GutenbergKitHTTP-\(UUID().uuidString)") let unrelated = FileManager.default.temporaryDirectory .appendingPathComponent("SomeOtherFile-\(UUID().uuidString)") FileManager.default.createFile(atPath: orphan1.path, contents: Data("test".utf8)) FileManager.default.createFile(atPath: orphan2.path, contents: Data("test".utf8)) + FileManager.default.createFile(atPath: fresh.path, contents: Data("test".utf8)) FileManager.default.createFile(atPath: unrelated.path, contents: Data("test".utf8)) - defer { try? FileManager.default.removeItem(at: unrelated) } + defer { + try? FileManager.default.removeItem(at: fresh) + try? FileManager.default.removeItem(at: unrelated) + } + // Backdate the orphans well past the 1-hour cutoff. The fresh file + // stays recent — the sweep runs detached from start(), so it must + // preserve files that could belong to a live server instance. + for orphan in [orphan1, orphan2] { + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSinceNow: -7200)], + ofItemAtPath: orphan.path + ) + } - // Start and immediately stop a server — start() calls cleanOrphanedTempFiles() + // start() kicks off cleanOrphanedTempFiles() off the startup path; + // await it before asserting. let server = try await HTTPServer.start(name: serverName, handler: { _ in HTTPResponse(status: 200) }) + await server.cleanupTask.value server.stop() #expect(!FileManager.default.fileExists(atPath: orphan1.path)) #expect(!FileManager.default.fileExists(atPath: orphan2.path)) + #expect(FileManager.default.fileExists(atPath: fresh.path)) #expect(FileManager.default.fileExists(atPath: unrelated.path)) } } diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index be3c6cbf3..3f8ffeb79 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -320,10 +320,11 @@ struct MediaUploadServerTests { ofItemAtPath: stale.path(percentEncoded: false) ) - // start() runs cleanOrphanedUploads(). The sweep must delete the aged file and - // keep the fresh one — a flipped comparison would do the opposite and wipe an - // in-flight upload. + // start() kicks off cleanOrphanedUploads() off the editor-startup path. + // The sweep must delete the aged file and keep the fresh one — a flipped + // comparison would do the opposite and wipe an in-flight upload. let server = try await MediaUploadServer.start() + await server.cleanupTask.value server.stop() #expect(!FileManager.default.fileExists(atPath: stale.path(percentEncoded: false)))