Skip to content

fix: trap when a mediaUploader is set without site credentials - #687

Open
jkmassel wants to merge 1 commit into
refactor/media-upload-handler-objectfrom
fix/media-uploader-credentials-trap
Open

jkmassel wants to merge 1 commit into
refactor/media-upload-handler-objectfrom
fix/media-uploader-credentials-trap

Conversation

@jkmassel

@jkmassel jkmassel commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Stacked on #686. Eighth of nine PRs splitting #621.

What?

Setting a mediaUploader without site credentials now fails immediately, at the line that sets it, instead of loading an editor whose uploader is never called.

Two problems surfaced while making that check dependable, and are fixed here too:

  • iOS and Android disagreed about what counts as a usable site root, so one configuration failed on iOS and ran a server with broken deletes on Android.
  • Nothing told Android integrators that native media handling needs cleartext permitted to localhost, and it silently does nothing without it.

Why?

Setting a mediaUploader means the host is taking over uploads. With no site credentials the server would previously just not start, silently dropping the uploader — and its media deletes still need the internal media client to reach the configured site, since every attachment lives there no matter who delivered it.

How?

The behavior forks by intent

  • A mediaProcessor with no credentials leaves the server down and uploads fall to the default WebView path — there is nothing to deliver through, so nothing to process.
  • A mediaUploader with no credentials fails: precondition on iOS, check on Android.

The check runs where the host supplies the handler, not at page load

iOS takes its media handlers at init and holds them private(set), so a non-nil uploader at load time was necessarily passed at construction — checking there puts the caller's own line in the stack trace, rather than surfacing the mistake later from inside a page-load callback that names only GutenbergKit. Android still takes its handlers as mutable properties, so the earliest equivalent point is the mediaUploader setter, beside the existing set-before-load check.

That mirrors how the set-before-load contract is already enforced: take the handler at construction where the platform allows it, check at assignment where it doesn't.

iOS and Android now agree on what a usable site root is

siteApiRoot = "example.com/wp-json/" — no scheme, which is what someone types when asked for their site address — failed on iOS and started a server on Android. iOS required scheme and host; Android checked only isEmpty().

On Android every relayed media delete then built a scheme-less URL, which OkHttp rejects with IllegalArgumentException. That is not an IOException, so the delete handler's catch missed it and it fell through to a plain-text 500 the editor cannot parse into an error — the editor's orphan-attachment cleanup failing with no signal.

Android gains a MediaServerCredentials mirroring the iOS one, and both now test scheme and host. Emptiness is tested alongside nullity because Uri and URL report a missing authority differently: file:///tmp/wp-json yields a null host on iOS and an empty one on Android. The two test suites assert matching cases on purpose, so the next divergence is a failing test rather than a crash on one platform and a broken server on the other.

Android hosts must permit cleartext to localhost

GutenbergKit serves media over a loopback HTTP server, and apps targeting API 28+ deny cleartext by default, so without an entry the WebView blocks every upload request before it leaves the page. docs/integration.md now covers this, including why the library cannot ship the config itself: networkSecurityConfig is a single-valued <application> attribute, so a library declaring it fails the manifest merge against the host's and against other libraries that declare one — rs.wordpress.api already does.

docs/code/physical-device-setup.md said cleartext configuration was needed only for non-localhost addresses, which describes Android 16's implicit localhost config rather than the devices below it, and its example omitted the loopback entries a reader would have overwritten. Both corrected.

What else was considered

Starting the server with no internal media client. MediaUploadServer already accepts a null one and degrades its delete route to a 500, so host uploads would work and only deletes would fail. Rejected: the delete relay is not optional, because every attachment lives on the configured site regardless of who delivered it. That trades one loud failure for silent orphan accumulation.

Failing when cleartext to localhost is blocked. Same symptom — the uploader is silently dropped — but a different cause. Missing credentials is an incoherent configuration: no site root and auth header exist under which that uploader could have worked. Blocked cleartext is a sound configuration that the app's network policy blocks, and permitting cleartext makes the same setup work unchanged. So that path logs and degrades, and the requirement is documented instead.

Testing Instructions

  • iOS MediaServerCredentials — 9 tests, including 2 exit tests that run the trap in a child process; neutering the precondition fails both

  • Android MediaServerCredentialsTest — 10 tests, mirroring the iOS cases one for one

  • Android GutenbergViewUploadServerTest — 13 tests; neutering the check fails the four uploader arms

  • swift test host suite — 396 tests

  • Android :Gutenberg:testDebugUnitTest — 689 tests

  • iOS Simulator xcodebuild -scheme GutenbergKit — required, since EditorViewController is #if canImport(UIKit) and the host build compiles it as empty

  • SwiftLint and Detekt clean

  • Build a GutenbergView with an empty authHeader, then assign a mediaUploader: the assignment throws IllegalStateException naming siteApiRoot and the auth header. Before this PR the editor loaded and the uploader was never called.

  • Do the same with siteApiRoot = "example.com/wp-json/" and a valid auth header: it throws for the same reason. Before this PR the server started and every media delete returned 500.

  • On an Android host with no localhost entry in its network security config, set a mediaUploader and open the editor: uploads still succeed by the WebView's own path, the uploader is never called, and logcat carries the cleartext warning. This is the documented behavior, not a regression.

@jkmassel jkmassel added [Type] Bug An existing feature does not function as intended Android iOS labels Sep 17, 2026
@jkmassel
jkmassel added this pull request to stack #690 September 17, 2026 18:33
Setting a `mediaUploader` means the host is taking over uploads. With no
site credentials the server would previously just not start, silently
dropping the uploader — and its media deletes still need the internal
media client to reach the configured site, since every attachment lives
there no matter who delivered it.

So the behavior forks by intent. A `mediaProcessor` with no credentials
leaves the server down and uploads fall to the default WebView path —
there is nothing to deliver through, so nothing to process. A
`mediaUploader` with no credentials is a configuration error and fails
fast: `precondition` on iOS, `check` on Android.

The check runs where the host states its intent, not at page load. iOS
takes its handlers at `init` and holds them `private(set)`, so a non-nil
uploader at load time was necessarily passed at construction — checking
there puts the caller's own line in the stack trace instead of surfacing
the mistake from inside a page-load callback that names only GutenbergKit.
Android still takes its handlers as mutable properties, so the earliest
equivalent point is the `mediaUploader` setter, beside the existing
set-before-load `check`. This is the shape `359d89ad` already established
for the set-before-load contract: enforce the rule where the host states
its intent.

Android gains a `MediaServerCredentials` of its own, mirroring iOS's.
The two predicates had diverged — iOS required an absolute site root while
Android checked only `isEmpty()` — so `siteApiRoot = "example.com/wp-json/"`,
which is what a user types when asked for their site address, trapped on
iOS and started a doomed server on Android. Every relayed delete then threw
`IllegalArgumentException` out of OkHttp's `.url()`, which is not an
`IOException`, so it escaped `handleDelete` and degraded to a plain-text
500 the editor cannot parse — orphan cleanup failing silently. Both sides
now test scheme and host. Emptiness is tested alongside nullity because
`Uri` and `URL` disagree on a missing authority: `file:///tmp/wp-json`
yields a null host on iOS and an empty one on Android.

The policies live outside the view types on both platforms so they are
reachable from the host test suites. On iOS that is load-bearing:
`EditorViewController` is `#if canImport(UIKit)` and therefore absent from
the macOS host, the one platform that can run Swift Testing's exit tests,
so the trap itself is testable rather than only the predicate. The two
suites assert matching cases on purpose — this policy has diverged silently
once, and matching cases make the next divergence a failing test rather
than a crash on one platform and a broken server on the other.

A `mediaUploader` can still be dropped without failing, at the cleartext
guard: an app that has not permitted cleartext to localhost never reaches
the loopback server, so the uploader is never called. That one logs and
degrades rather than failing, and the distinction is the cause rather than
the symptom — missing credentials is an incoherent configuration, while
blocked cleartext is a sound configuration the app's network policy blocks,
and permitting cleartext makes the same setup work unchanged. Nothing had
told integrators to permit it, so `docs/integration.md` now does, including
why the library cannot ship the config itself: `networkSecurityConfig` is a
single-valued `<application>` attribute, so a library declaring it fails the
manifest merge against the host's and against other libraries that declare
one — `rs.wordpress.api` already does.
@jkmassel
jkmassel force-pushed the fix/media-uploader-credentials-trap branch from 16a9557 to bcbdea5 Compare September 17, 2026 20:09
@wpmobilebot

Copy link
Copy Markdown

XCFramework Build

This PR's XCFramework is available for testing. Add the following to your Package.swift:

.package(url: "https://github.com/wordpress-mobile/GutenbergKit", branch: "pr-build/687")

Built from bcbdea5

@jkmassel
jkmassel marked this pull request as ready for review September 17, 2026 20:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Android iOS [Type] Bug An existing feature does not function as intended

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants