Skip to content

[2.x] Implement content negotiation for forum error handling - #4677

Merged
imorland merged 6 commits into
2.xfrom
ds/2.x-forum-error-negotiation
Jun 2, 2026
Merged

[2.x] Implement content negotiation for forum error handling#4677
imorland merged 6 commits into
2.xfrom
ds/2.x-forum-error-negotiation

Conversation

@dsevillamartin

@dsevillamartin dsevillamartin commented May 31, 2026

Copy link
Copy Markdown
Member

Fixes #3850

Changes proposed in this pull request:

  • Create a error handler for the forum that determines whether to forward to JSON or HTML view
    • Use JSON API Formatter for forum errors if the request did not explicitly state text/html in Accepts header
    • Fixes /login route not returning JSON from forum
    • Fixes HighMaintenanceModeHandler not returning JSON for requests initiated by forum (uses isApiRequest, and our requests do not set the header)

Reviewers should focus on:
I added the bitworking/mimeparse package (last updated end of 2025) to parse more complex Accepts headers (eg. with quality priority). It looks like browsers basically always (wasn't able to find concrete evidence of this) have text/html in their Accepts. While our XHR requests have Accepts: */*.

However, this did make isHtmlRequest a weaker check that could be true at the same time as isApiRequest (due to the quality priorities). I think properly parsing with this package makes sense, however, perhaps isHtmlRequest should just be the negation of the stronger isApiRequest check (made this change)? Additionally, isHtmlRequest is only used in LogInController for the maintenance login error validaiton.

I also thought that adding this check to the forum-wide error handler made more sense than just the login route alone.
Plus, I didn't think just slapping an Accepts header to our JS requests was the proper solution.

Screenshot
image
image

Necessity

  • Has the problem that is being solved here been clearly explained?
  • If applicable, have various options for solving this problem been considered?
  • For core PRs, does this need to be in core, or could it be in an extension?
  • Are we willing to maintain this for years / potentially forever?

Confirmed

  • Frontend changes: tested on a local Flarum installation.
  • Backend changes: tests are green (run composer test).
  • Core developer confirmed locally this works as intended.
  • Tests have been added, or are not appropriate here.

@dsevillamartin
dsevillamartin requested a review from a team as a code owner May 31, 2026 22:27
dsevillamartin and others added 4 commits May 31, 2026 18:38
getPreferredContentType() runs inside the forum error handler, so it must
never throw. Two ways it could:
- Mimeparse::bestMatch() returns null when the Accept header matches none of
  the offered types (e.g. "application/xml"), violating the ": string" return
  type with a TypeError.
- A malformed media range (e.g. one without a subtype) makes bestMatch() emit
  a warning and throw UnexpectedValueException.

Both now fall back to an empty string (treated as "not an API request"), so a
hostile or unusual Accept header can't turn an error response into a second,
fatal error.

Add coverage:
- RequestUtilTest: isApiRequest/isHtmlRequest/getPreferredContentType across
  JSON:API, JSON, */*, empty, browser, unrelated and malformed headers.
- ContentNegotiationFormatterTest: API requests format as JSON, browser
  requests as HTML.
- ErrorNegotiationTest: forum errors are negotiated through the full stack
  (JSON for */* — the #3850 regression — and HTML for browser requests).
@imorland

imorland commented Jun 2, 2026

Copy link
Copy Markdown
Member

Pushed some hardening and test coverage on top of this (9e85a3bc9) ahead of considering it for 2.0.0-rc.3. 👍

Hardening RequestUtil::getPreferredContentType()

While adding tests I found that this helper — now reached on the error-handling path via ContentNegotiationFormatter — can blow up in two ways, turning an error response into a second, fatal error:

  1. null return → TypeError. Mimeparse::bestMatch() is typed ?string and returns null when the Accept header matches none of the offered types (e.g. Accept: application/xml or text/plain). The method is declared : string, so that's an uncaught TypeError. This is easy to trigger from the wild (bots, proxies, odd clients).
  2. Malformed Accept → warning + UnexpectedValueException. A media range without a subtype makes bestMatch() emit an E_WARNING and throw.

Both now fall back to an empty string (treated as "not an API request" → HTML), so a hostile or unusual Accept header can't escalate. The @ suppresses the library's warning and the catch swallows the throw — deliberately, since this is defensive code in the error handler.

Tests added

  • RequestUtilTest (unit) — isApiRequest / isHtmlRequest / getPreferredContentType across JSON:API, plain JSON, */*, empty, typical-browser, unrelated, and malformed headers (explicitly pinning the never-throw and null'' behaviour, plus documenting that an empty Accept is treated as API).
  • ContentNegotiationFormatterTest (unit) — API requests delegate to the JSON formatter, browser requests to the HTML formatter.
  • ErrorNegotiationTest (integration) — a forum route that errors is negotiated through the full middleware stack: JSON for */* (the LogInController doesn't handle validation exceptions properly #3850 regression) and application/vnd.api+json, HTML for browser Accept.

All green locally (13 unit + 3 integration) and PHPStan is clean on the changed files. This checks the "Tests have been added" box.

@imorland imorland added this to the 2.0.0-rc.3 milestone Jun 2, 2026
@imorland
imorland merged commit b28610c into 2.x Jun 2, 2026
24 checks passed
@imorland
imorland deleted the ds/2.x-forum-error-negotiation branch June 2, 2026 10:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LogInController doesn't handle validation exceptions properly

3 participants