Skip to content

fix(theming): allow request theme override#61095

Closed
giacomoarru wants to merge 171 commits into
nextcloud:masterfrom
giacomoarru:fix/theme-querystring
Closed

fix(theming): allow request theme override#61095
giacomoarru wants to merge 171 commits into
nextcloud:masterfrom
giacomoarru:fix/theme-querystring

Conversation

@giacomoarru

Copy link
Copy Markdown

Summary

Adds a request-scoped query string override for light/dark theming:

  • ?theme=light
  • ?theme=dark

The override is not persisted to the user's settings.

Details

  • Accepts only light and dark.
  • Ignores invalid and non-string values.
  • Preserves enabled themes of other types, such as OpenDyslexic.
  • Keeps admin-configured enforce_theme behavior unchanged.
  • Leaves getThemes() unchanged; the override is applied only in getEnabledThemes().

Testing

  • php -l apps/theming/lib/Service/ThemesService.php
  • php -l apps/theming/tests/Service/ThemesServiceTest.php
  • composer test -- apps/theming/tests/Service/ThemesServiceTest.php

Manual testing

  • Opened /login?theme=dark and verified data-theme-dark.
  • Opened /login?theme=light and verified data-theme-light.
  • Opened /login?theme=sepia and verified no override is applied.

@giacomoarru giacomoarru requested a review from a team as a code owner June 9, 2026 08:55
@giacomoarru giacomoarru requested review from come-nc, leftybournes, provokateurin and salmart-dev and removed request for a team June 9, 2026 08:55
@giacomoarru

Copy link
Copy Markdown
Author

Justification

This pull request introduces a query string override to control the visual theme of embedded Nextcloud Forms.

The main use case is embedding public forms inside external websites via iframe. In this scenario, the surrounding website may have a fixed light design, while the embedded Nextcloud form can automatically switch to dark mode depending on the visitor’s device or browser preference, for example when the user has system-wide dark mode enabled.

This creates an inconsistent user experience: the external page remains white, but the embedded form is rendered with a dark background. In some cases, this also affects readability, branding consistency, and accessibility, especially when the form is intended to visually integrate with a light-themed public website.

A global configuration such as forcing the whole Nextcloud instance to light mode is not a suitable solution, because administrators may still want the rest of Nextcloud to respect user preferences or system theme settings. Similarly, applying custom CSS globally can have unintended side effects on other forms or other parts of the instance.

The proposed query string override solves this in a narrow and explicit way. It allows the embedding page to request a specific theme only for that embedded form view, without changing the global Nextcloud theme behavior and without affecting other users, other forms, or the rest of the instance.

Example use case:

<iframe
  src="https://cloud.example.com/index.php/apps/forms/embed/FORM_ID?theme=light"
  style="width:100%; border:0;">
</iframe>

This makes embedding Nextcloud Forms in external websites more predictable and easier to integrate, while preserving the existing default behavior when no override is provided.

The change is especially useful for organizations that use Nextcloud Forms as part of public-facing websites, intranets, landing pages, or customer portals where the visual appearance of the embedded form needs to match the host page.

@giacomoarru giacomoarru force-pushed the fix/theme-querystring branch from b816150 to b7ca7d0 Compare June 9, 2026 09:34
@susnux susnux added this to the Nextcloud 35 milestone Jun 9, 2026

@come-nc come-nc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Won’t that conflict for routes that have a "theme" named parameter?

@susnux susnux added the community pull requests from community label Jun 11, 2026
@giacomoarru

Copy link
Copy Markdown
Author

Won’t that conflict for routes that have a "theme" named parameter?

You are correct - thanks for spotting this.
I will submit a new revision soon.

@giacomoarru

Copy link
Copy Markdown
Author

Add a request-scoped light/dark theme override via the query string

Summary

Adds a request-scoped query string override for light/dark theming:

  • ?theme=light
  • ?theme=dark

The override is not persisted to the user's settings — it only affects the
themes resolved for the current request.

Why this approach (addressing the review feedback)

A previous iteration read the value with IRequest::getParam('theme'). As pointed
out in review, that is unsafe: getParam() does not read the query string only — it
returns a value from a merged bag built in lib/private/AppFramework/Http/Request.php:

$this->items['parameters'] = array_merge(
    $this->items['get'],       // ?theme=...  (query string)
    $this->items['post'],      // POST body
    $this->items['urlParams'], // route placeholders such as {theme}
    $this->items['params']
);

The documented precedence is 1. URL/route parameters → 2. POST → 3. GET, so a
route placeholder named theme (e.g. a route URL like /foo/{theme}) — or a POST
field named theme — would win over the query string. Because getEnabledThemes()
runs on virtually every rendered page across all apps (not just theming routes), any
such parameter anywhere (including in third-party apps) could hijack the override, or
conversely have its own theme segment reinterpreted as a light/dark request.

To eliminate the conflict, the override now reads exclusively from the URL query
string
, by parsing the query part of IRequest::getRequestUri(). Route placeholders
and POST bodies can no longer interfere with — or be hijacked by — this feature.

Details

  • Accepts only light and dark.
  • Reads the value only from the query string (never route placeholders or POST).
  • Ignores invalid values, missing values, array-style values (?theme[]=dark), and
    requests without a query string.
  • Preserves enabled themes of other types, such as OpenDyslexic.
  • Keeps admin-configured enforce_theme behavior unchanged — the enforced theme always
    takes precedence over this request-scoped override.
  • Leaves getThemes() unchanged; the override is applied only in getEnabledThemes().

Testing

Static checks:

php -l apps/theming/lib/Service/ThemesService.php
php -l apps/theming/tests/Service/ThemesServiceTest.php
composer test -- apps/theming/tests/Service/ThemesServiceTest.php

Unit tests added/updated in ThemesServiceTest:

  • ?theme=dark applies the dark theme for a guest.
  • ?theme=light overrides the user's stored theme while keeping OpenDyslexic.
  • ?theme=sepia (invalid) applies no override.
  • ?theme[]=dark (non-string) applies no override.
  • No query string applies no override.
  • A route placeholder named theme (e.g. /apps/example/dark) applies no override
    — this is the regression test for the conflict raised in review.
  • enforce_theme still takes precedence and the override is not even consulted.

Manual testing on a live instance:

  • Opened /login?theme=dark and verified data-theme-dark.
  • Opened /login?theme=light and verified data-theme-light.
  • Opened /login?theme=sepia and verified no override is applied.

Notes for reviewers

IRequest intentionally exposes no query-string-only getter (getParam/getParams
both use the merged bag), so the query string is obtained by splitting
getRequestUri() on ? and parsing it with parse_str(). This keeps the change within
the public interface and fully unit-testable.

giacomoarru and others added 16 commits June 11, 2026 17:37
Support ?theme=light and ?theme=dark as a request-scoped override that is
not persisted to user settings and never bypasses an admin-configured
enforce_theme. Invalid, empty, non-string or unknown values are ignored,
and non-visual themes such as the OpenDyslexic font theme are preserved.
The override is applied only in ThemesService::getEnabledThemes() so that
getThemes() and theme registration remain unchanged.

Assisted-by: Cline
Signed-off-by: Jack Arru <giacomo@beta.srl>
Signed-off-by: Jack Arru <jack@x2x.cloud>
The request-scoped ?theme=light / ?theme=dark override set the
data-theme-* body attribute but ThemeInjectionService still injected the
OS prefers-color-scheme stylesheets on :root and a combined color-scheme
meta. On a dark-OS machine the root element and native controls stayed
dark, so the override appeared not to work.

When an override is active (and no enforce_theme is set), force the
chosen theme unconditionally on :root, drop the prefers-color-scheme
auto-switching stylesheets, and expose only the override color-scheme
meta. Behavior without an override and admin enforce_theme precedence are
unchanged.

Assisted-by: Cline
Signed-off-by: Jack Arru <giacomo@beta.srl>
Signed-off-by: Jack Arru <jack@x2x.cloud>
Signed-off-by: Joas Schilling <coding@schilljs.com>
Signed-off-by: Jack Arru <jack@x2x.cloud>
Signed-off-by: Andrey Dyakov <adduxa@gmail.com>
Signed-off-by: Jack Arru <jack@x2x.cloud>
Signed-off-by: Josh <josh.t.richards@gmail.com>
Signed-off-by: Jack Arru <jack@x2x.cloud>
…Manager

No longer part of the public interface. Just an internal utility function.

Apps/etc register in other ways and still end up here appropriately.

Signed-off-by: Josh <josh.t.richards@gmail.com>
Signed-off-by: Jack Arru <jack@x2x.cloud>
Signed-off-by: Josh <josh.t.richards@gmail.com>
Signed-off-by: Jack Arru <jack@x2x.cloud>
…back

When decrypting a v3 ciphertext with a mismatched secret, the first
attempt throws an Exception (HMAC mismatch). The fallback then calls
decryptWithoutSecret() with an empty string, which causes hash_hkdf()
to throw a ValueError. Since ValueError extends \Error rather than
\Exception, it bypassed the catch block and propagated as an unhandled
error, crashing the whole request.

Wrap the fallback in its own try/catch(\Throwable) and rethrow the
original Exception so callers get a meaningful HMAC mismatch error.

Signed-off-by: Anna Larch <anna@nextcloud.com>
AI-Assisted-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Jack Arru <jack@x2x.cloud>
If an object is passed from one app to another app through a web
component, then only reference changes (new objects) are marked as
reactive changes in the receiving app.
In this case e.g. `mtime` changes were not properly propagated,
so to fix this we explicitly clone the objects before passing to the
sidebar tab.

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
Signed-off-by: Jack Arru <jack@x2x.cloud>
Signed-off-by: nextcloud-command <nextcloud-command@users.noreply.github.com>
Signed-off-by: Jack Arru <jack@x2x.cloud>
saves ~50s while running tests

Signed-off-by: Robin Appelman <robin@icewind.nl>
Signed-off-by: Jack Arru <jack@x2x.cloud>
saves ~5s

Signed-off-by: Robin Appelman <robin@icewind.nl>
Signed-off-by: Jack Arru <jack@x2x.cloud>
Signed-off-by: Robin Appelman <robin@icewind.nl>
Signed-off-by: Jack Arru <jack@x2x.cloud>
Signed-off-by: Robin Appelman <robin@icewind.nl>
Signed-off-by: Jack Arru <jack@x2x.cloud>
Signed-off-by: Robin Appelman <robin@icewind.nl>
Signed-off-by: Jack Arru <jack@x2x.cloud>
Signed-off-by: Robin Appelman <robin@icewind.nl>
Signed-off-by: Jack Arru <jack@x2x.cloud>
susnux and others added 13 commits June 11, 2026 17:37
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
Signed-off-by: Jack Arru <jack@x2x.cloud>
… client

Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
Signed-off-by: Jack Arru <jack@x2x.cloud>
Signed-off-by: John Molakvoæ <skjnldsv@users.noreply.github.com>
Signed-off-by: nextcloud-command <nextcloud-command@users.noreply.github.com>
Signed-off-by: Jack Arru <jack@x2x.cloud>
Signed-off-by: nextcloud-command <nextcloud-command@users.noreply.github.com>
Signed-off-by: Jack Arru <jack@x2x.cloud>
… groups

Signed-off-by: krazyhell <hellwarrior@riseup.net>
Signed-off-by: Jack Arru <jack@x2x.cloud>
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
Signed-off-by: Jack Arru <jack@x2x.cloud>
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
Signed-off-by: Jack Arru <jack@x2x.cloud>
Signed-off-by: Louis Chmn <louis@chmn.me>
Signed-off-by: Jack Arru <jack@x2x.cloud>
Signed-off-by: Louis Chmn <louis@chmn.me>
Signed-off-by: Jack Arru <jack@x2x.cloud>
Signed-off-by: Louis Chmn <louis@chmn.me>
Signed-off-by: Jack Arru <jack@x2x.cloud>
Signed-off-by: Josh <josh.t.richards@gmail.com>
Signed-off-by: Jack Arru <jack@x2x.cloud>
Signed-off-by: Josh <josh.t.richards@gmail.com>
Signed-off-by: Jack Arru <jack@x2x.cloud>
Signed-off-by: Robin Appelman <robin@icewind.nl>
Signed-off-by: Jack Arru <jack@x2x.cloud>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community pull requests from community

Projects

None yet

Development

Successfully merging this pull request may close these issues.