Skip to content

fix(templates): respect reverse-proxy path prefix in hardcoded JS/URLs - #80

Open
jeromelefeuvre wants to merge 319 commits into
Shoshuo:mainfrom
jeromelefeuvre:fix/hardcoded-prefix-paths
Open

fix(templates): respect reverse-proxy path prefix in hardcoded JS/URLs#80
jeromelefeuvre wants to merge 319 commits into
Shoshuo:mainfrom
jeromelefeuvre:fix/hardcoded-prefix-paths

Conversation

@jeromelefeuvre

@jeromelefeuvre jeromelefeuvre commented Jul 22, 2026

Copy link
Copy Markdown

Summary

When Prismarr is served behind a reverse proxy that strips a path prefix (e.g. Traefik routing https://host/prismarr/... to the container root, with X-Forwarded-Prefix: /prismarr forwarded), server-generated URLs are already prefix-safe via instance_path() / path() (which go through the Symfony router, itself driven by Request::getBasePath() — already trusted via trusted_headers: [..., x-forwarded-prefix] in config/packages/framework.yaml).

However, ~276 places across 67 Twig templates build absolute paths as raw string literals inside inline <script> blocks or href/action attributes (fetch('/medias/' + slug + ...), href="/jellyseerr/...", etc.). These literals ignore the prefix entirely, since X-Forwarded-Prefix only affects server-side URL generation, not raw JS strings.

Fix

  • Standard case (raw text inside a <script> block or HTML attribute): prefix the literal with {{ app.request.getBasePath() }}.
  • Edge case (~9 occurrences already inside an open Twig expression, e.g. {% include ... with { save_url_edit: ... } %} or a ternary): use the Twig concat operator app.request.getBasePath() ~ '...' instead, since {{ }} doesn't interpolate inside an already-parsed Twig expression.
  • One occurrence (/api/health/services) was switched to the named route via {{ path('api_health_services') }} instead, which is cleaner and needed no raw-path handling.

Request::getBasePath() already reflects X-Forwarded-Prefix (Symfony 5.3+), so this resolves to /prismarr behind the proxy and '' on a root deployment — no extra config needed.

Affected roots: /medias, /decouverte, /jellyseerr, /prowlarr, /setup.

Test plan

  • make lint-twig (php bin/console lint:twig templates) — all 149 Twig files pass syntax validation
  • Swept the repo for any remaining hardcoded absolute paths on the 5 affected roots — none left
  • Manually verified each of the 9 "already inside a Twig expression" cases renders correctly with ~ instead of {{ }}

ndandan and others added 30 commits June 15, 2026 19:42
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses final code-review notes: rename the in-process cache wrapper
key 'status' -> 'result' (it holds the whole {status, latencyMs} struct,
not just the word), and cover the CACHE_TTL cache-hit path with a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lth chips

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stream summary strip, Recently Added section, and live-card polish
(HDR/SDR badge + codec-transition transcode detail).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7 tasks: stream-summary strip (DRY extraction), Recently Added
(client+normalizer+endpoint+section), and live-card polish
(HDR/SDR badge + codec-transition transcode detail).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the dashboard widget's stream-summary block into a shared
partial and render it above Now Playing on the Tautulli page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Read-only get_recently_added wrapper with a strict allow-list
normalizer (count clamped 1..50; no ids/guids/paths/timestamps).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Match the sanitization test to the normalizer docblock, which lists
added_at among the intentionally-omitted fields.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fail-open GET /tautulli/api/recently-added rendering clickable rows
that open the existing info modal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New stacked section hydrated once on load, with en/fr strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… skeleton

The injected fragment already wraps rows in .plex-sessions; keep the
class on the inner div only, matching the History section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Allow-list stream/source video+audio codecs and dynamic range for the
card HDR badge and codec-transition transcode detail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the absent-stream fallback case for dynamicRange and align the
sanitization assertion with testStripsPrivateFields.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stream cards (page + dashboard widget) now show the dynamic range
and, when transcoding, the source→target codec instead of just the
decision word.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary strip, Recently Added section, and HDR/SDR + codec-transition
card polish.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…test

Remove the now-unused _icons import from _plex_activity (its only use
moved to the shared _plex_summary partial), and add the spec-required
controller smoke test for the recently-added fragment endpoint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove Recently Added, dense History grid, slim Libraries, drop Total
series; add Media/Stream plays toggle, hour+day-of-week activity, and
platform x stream-type problem-clients graphs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
6 tasks: remove Recently Added; client chart feeds + drop Total;
chart endpoints; dense History grid + slim Libraries; Media/Stream
toggle + activity & problem-client graphs; docs + CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The *arr apps already surface recently added; drop the section, its
endpoint, client method + normalizer, tests, and i18n keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…otal

normalizePlaysByDate now drops any aggregate "Total" series. New
read-only chart commands share a playsChart() helper and the existing
normalizer; getPlaysByDate refactored onto it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…aysByDate fail-open

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
apiPlays gains a media|stream mode; three new fail-open JSON endpoints
feed the hour-of-day, day-of-week, and platform x stream-type charts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
History renders as a responsive multi-column grid (poster + title +
user/when, no progress bar); Libraries becomes a slim single-column
list. Both are now full-width sections.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… graphs

Generalize the chart JS into drawStackedBar; add a Media Type/Stream
Type toggle on the plays chart and new hour-of-day, day-of-week, and
platform x stream-type charts, all driven by the range toggle. Fix the
history grid item-count regex.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the un-shipped Recently Added note; record the declutter (dense
History grid, slim Libraries) and the new graphs (Media/Stream toggle,
hour-of-day, day-of-week, platform x stream-type).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
These are brainstorming specs and implementation plans — internal
scaffolding, same category as the already-ignored CLAUDE.md /
PROGRESSION.md. Removed from the public repo (kept locally) and added
to .gitignore so they no longer clutter the fork or a future PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Was '63 files / ~8,900 insertions' (stale, and inflated by the now-untracked docs/superpowers specs). Actual code footprint vs upstream/main is 56 files / ~4,250 insertions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ndandan and others added 5 commits July 25, 2026 11:19
The device block was five nested cards, each a .datagrid that collapses to a
single column inside col-xxl-3 — seven stacked label/value rows per card, so
five devices took ~1100px to show 35 values, with the fifth card orphaned on
its own row. These are homogeneous records with identical fields, which is a
table: same data in ~200px, columns that can be compared down their length
(scanning a RAM column is the actual job), one less card nesting level, and
the same vocabulary as the two RF tables directly below it.

Model and role move onto a muted sub-line under the device name, which buys
back two columns. Numeric columns are right-aligned with tabular figures.
CPU/RAM/retry colour only above a threshold, so a quiet console doesn't
render as a wall of green.

Drops the Satisfaction column: the console reports -1 per radio on this
firmware, so it was a permanently empty column paying for itself in width on
the widest table of the page. The reader still maps the field, so restoring
the column is a one-line change if firmware ever fixes it. RF split goes 8/4
since the radio table carries nearly twice the columns.

Neighbour table: signal no longer wraps mid-value, a hidden SSID says so
instead of showing a bare dash that reads as a failed parse, and a truncated
vendor keeps a title= so it stays inspectable.

Also prefers a gateway's lan_ip over ip. Gateways report their WAN address
there, so the inventory was showing a public address beside four 192.168.x
ones — wrong for a LAN table and needless exposure in a screenshot. Devices
without lan_ip are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chart axis labels were .62rem (~9.9px) with no small-screen bump, which
breaks this project's own mobile floor of nothing under 11px below the lg
breakpoint. The dashboard bumps its equivalent axis to .6875rem in a media
query; the inline-style copy in the history fragment couldn't, because a style
attribute can't carry one.

Moves the chart rules into a page-scoped <style> in the shell's stylesheets
block — the same pattern dashboard/index.html.twig already uses, so still no
new CSS file — and adds the missing media query. The fragment goes from six
repeated inline style attributes to none. It has to live in the shell rather
than the fragment: fragments are re-injected on every poll, so a <style> tag
inside one would accumulate duplicates.

Also DRYs the repeated tabular-figure rule into .unifi-nums, and drops the two
translation keys the table redesign orphaned (unifi.infra.model and
unifi.infra.satisfaction) rather than leaving them for translators to
translate into a UI that never shows them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RF split goes back to 7/5. The 8/4 I'd just introduced clipped the neighbour
table's Vendor column clean off at desktop width — the reasoning was backwards,
since the radio table had simultaneously LOST its satisfaction column and so
needed less room, not more. Confirmed against the rendered page: at 390px the
neighbour table showed all four columns, at 1440px it did not.

The 7-day traffic axis read "Sat … Sat". One format feeds both the per-point
hover labels and the two axis labels, and seven days apart is the same weekday,
so both ends printed identically and all 24 points in a day shared one tooltip.
Now 'D j H:i' — the date disambiguates the axis, the hour makes hovering a
spike informative.

A network with no vlan id now says "untagged" rather than showing a bare dash,
which read as missing data. Same reasoning as the hidden-SSID label.

Live-verified on :beta: gateway row now shows its LAN address (192.168.68.1,
was the public WAN address), the Networks table is populated with real VLAN ids
and subnets, the poller coalesces into one ?p=live,infra request per due tick,
and polling stops dead after a Turbo navigation away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An admin-only network-operations page over the classic UniFi Network API,
built on a two-method UnifiFetcher seam so the shipped dashboard widget
cannot regress. Three readers own their own endpoint group and cadence
(live 10s / infra 60s / history 300s), so every endpoint is hit once per
cycle no matter how many panels consume it, and a page-scoped coalescing
poller batches every due region into a single request per tick.

Panels: WAN/gateway/client tiles, 7-day traffic and 30-day speedtest
trends as server-rendered SVG, a device inventory, RF environment with
AP/radio and neighbour tables, VLAN inventory, wireless clients, live
talkers, top clients and DHCP-reservation mismatches. Read-only
throughout; every action answers 200 with an empty state rather than
letting a sick console 500 the page.

Field map verified against the live console: gateway-only temperatures[],
radio width from bw, radio satisfaction of -1 normalised to null,
neighbours via POST stat/rogueap, and gateway lan_ip preferred over the
WAN address in ip.

Live-verified on :beta — dark and light, desktop and 390px, poller
coalescing confirmed and polling confirmed to stop after a Turbo
navigation.
…shuo#17)

fix(qbittorrent): guard against stale pagination responses
jeromelefeuvre and others added 15 commits July 25, 2026 15:21
New download-client page alongside qBittorrent — setup wizard step,
admin settings, sidebar badge/poll, health circuit breaker — modeled
on the same conventions but with its own JSON-RPC transport, since
Transmission's session handshake (expected HTTP 409 carrying a fresh
X-Transmission-Session-Id to cache and retry with) is genuinely novel
versus qBittorrent's cookie auth.

Also fixes three bugs found while smoke-testing the new client:
- SetupController's Downloads step defaults were non-empty, so
  qBittorrent showed as "configured" even when untouched.
- DashboardController's services-health labels lacked a Transmission
  entry, so its chip never appeared.
- A user-entered URL with a redundant /transmission suffix doubled up
  with the client's own /transmission/rpc path; normalizeBaseUrl()
  strips it.
- torrent-get on a large library (this user has ~1,900 torrents) is
  multiple MB of uncompressed JSON, slow enough over a remote link to
  time out mid-transfer and silently render as "0 torrents". Requesting
  gzip via CURLOPT_ENCODING shrinks that response ~14x.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The contributed commit blanked qbittorrent_url and qbittorrent_user in the
Downloads step's default map. Unrelated to adding Transmission and it changes
onboarding for a live feature, so it goes the same way as the stray
DashboardController hunk.
Guards HealthService::TOGGLEABLE_SERVICES, the settings.html.twig
service_meta/groupings/kill-switch/TEST_FIELDS lists, and base.html.twig's
sidebar/poller/turbo:before-render cleanup entries for transmission, plus
a UniFi survival check since Task 1's rebase conflicted on the same lists.
Replaces the contributed qBittorrent-derived template. Deluge is the closer
semantic match (no Gluetun panel, label categories, session-wide pause/resume)
and the route sets are identical, so the fetch layer ports by prefix swap.
Adopts _stat_tiles on top of the three partials Deluge already includes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
getTorrentDetail() read $t['rateDownload']/$t['rateUpload'] for
properties.dl_speed/up_speed, but DETAIL_FIELDS never asked Transmission's
torrent-get RPC for either field — since Transmission only returns
requested fields, both always coalesced to 0 and the detail modal's
General tab showed 0 B/s regardless of actual speed.

Add a reflection-based test asserting DETAIL_FIELDS contains every field
getTorrentDetail() reads off the torrent object.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ControllersSmokeTest requests /transmission but ServiceRouteGuardSubscriber
redirects app_transmission_* to app_setup_downloads whenever
transmission_url is unset (always true under AbstractWebTestCase) — the
smoke test only asserts status < 500 || 503, so the 302 passes and the
template body is never parsed. Four raw dotted translation keys nearly
shipped in the rebuilt template as a result; only a hand check caught it.

Seed transmission_url, swap in a mocked TransmissionClient covering every
state normalizeState() can emit, render in both app locales, and assert
the HTML contains no unresolved qbittorrent./transmission./deluge.
translation key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…keys per file

Follows the Deluge precedent: a second torrent client reuses the qBittorrent
translation namespace and adds only client-specific keys.
…elp text

qbittorrent.page_title is also 'Downloads', so with both clients configured
two sidebar entries opened pages with an identical heading and
indistinguishable browser tabs.

The refresh setting's help text described the page's own polling, which is
hardcoded; the setting drives the sidebar badge and completion toasts.
Absorbs jeromelefeuvre's Transmission contribution (fork PR Shoshuo#18), rebased
onto post-UniFi main with the contributed client and controller carried as
authored. The page template is rebuilt on the Deluge base plus the shared
partials rather than the contributed qBittorrent-derived copy; qBittorrent
and Deluge are untouched.

Adds guard tests for the registration points and Deluge/Transmission route
parity, plus a render test — nothing in CI previously rendered any torrent
template, because ServiceRouteGuardSubscriber redirects to the setup wizard
under AbstractWebTestCase and the smoke test only asserts status < 500.

956 tests, live-verified on :beta against Transmission 4.1.3.
`startRefresh()` cleared a closure-scoped `refreshTimer`, but Turbo Drive
re-executes the incoming page's script with a fresh closure while the
outgoing page's closure — and its live 3s setInterval — survive. All three
torrent pages render the same element IDs (qbt-list, qbt-stat-total, ...),
so an orphaned poller's updateList()/updateStats() guards still passed on
its successor's DOM and it kept writing its own client's torrents over the
page the user was actually looking at. Two pollers then fought at 3s each,
which reads as data corruption ("Transmission is showing my qBittorrent
torrents"). Pre-existing between qBittorrent and Deluge; Transmission just
added a third page to the cycle.

Hoist the handle to window._prismarrTorrentPagePollTimer — one name serves
all three, since only one torrent page is ever mounted at a time — so the
incoming page's startRefresh() reaches and kills the outgoing page's timer.
Add it to base.html.twig's turbo:before-render cleanup as well, so leaving
for a NON-torrent page (which re-executes no torrent script, and so would
otherwise never clear it) stops the interval too.

Verified in a browser against a real image, A/B against the unfixed
templates: on HEAD, sitting on /transmission after a Turbo visit from
/qbittorrent logged 10 qBittorrent + 9 Transmission polls over 32s; with
the fix, 0 qBittorrent + 9 Transmission. A torrent -> dashboard visit nulls
the handle and torrent polling stops entirely.

Known residue, deliberately not addressed here: refreshCtrl/refreshSeq are
still closure-scoped, so a fetch already in flight when navigation starts
can still apply once into the incoming page's DOM before that page's own
poller corrects it 3s later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CHANGELOG gains the poller fix under Unreleased/Fixed.

README and docs/FORK-CHANGES had never been updated for the Transmission tab
(merged 2026-07-25 in 79e8ce3) — FORK-CHANGES still read "Last updated:
2026-07-11" and neither file mentioned Transmission anywhere, so the fork
record and the feature list both understated what main ships. Backfilled:

- README: Transmission in the Downloads feature bullet, Requirements, wizard
  config list and the credentials-storage note, plus a fork-section entry.
  Deliberately no bugfix line — a README describes capabilities, not fixes.
- FORK-CHANGES: a Transmission section (RPC 409 handshake, the
  reuse-the-qbittorrent-namespace convention, and the CI hole where no torrent
  template was ever rendered) and a poller-fix section, both under "shipped in
  the fork, not yet proposed upstream" since each is an upstream PR candidate —
  the poller bug affects the upstream-origin qBittorrent template too.
- Refreshed two stale claims in FORK-CHANGES: the "Last updated" date and the
  "~700 tests" gate figure, now 967.

Docs-only; no code touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nsmission)

Hoists the three torrent pages' 3s poll timer from a per-page closure onto one
shared window global, so a Turbo navigation can actually clear the outgoing
page's interval. Previously the orphaned poller kept writing its own client's
torrents into the next page's identically-id'd DOM, two pollers fighting at 3s
each. Also adds the handle to base.html.twig's turbo:before-render cleanup for
torrent -> non-torrent navigations.

967/967 tests, CI + GHCR green, live-verified on :beta (Deluge 54 torrents vs
Transmission 0: zero cross-writes in either direction).

Docs: CHANGELOG entry, plus README/FORK-CHANGES backfilled for the Transmission
tab, which had never been documented.
Three of the four merges from 2026-07-25 had never reached the docs. The
previous commit caught Transmission; this catches the other two.

- UniFi Network tab (/unifi, merge a7daffe) — documented in all three files.
  Only the 2026-07-05 dashboard *widget* was described anywhere, so the docs
  understated UniFi as a single dashboard section when main also ships a full
  admin-only ops page (SVG traffic/speedtest trends, device inventory, RF
  environment, VLANs, wireless clients, live talkers, top clients, DHCP
  mismatches; three readers on 10s/60s/300s cadences behind a coalescing
  poller; read-only and fail-soft per panel).
- qBittorrent stale-pagination guard (Shoshuo#17, merge 48d30f3) — CHANGELOG entry
  and a FORK-CHANGES section, credited to @jeromelefeuvre. Kept as its own
  entry rather than folded into the poller fix, so the attribution is clear.

Both are filed under "shipped in the fork, not yet proposed upstream": the
pagination guard touches the upstream-origin qbittorrent template, and the
UniFi tab is a poor upstream fit but nothing in it is fork-specific.

Also moved the UniFi tab entry into FORK-CHANGES' 2026-07-25 cluster — section
3 is otherwise strictly date-ordered, and grouping it with the widget it builds
on broke that.

Docs-only; no code touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…guard

Docs-only follow-up. Three of the four 2026-07-25 merges had never reached
README / CHANGELOG / FORK-CHANGES; the poller-fix merge covered Transmission,
this covers the /unifi tab (a7daffe) and the Shoshuo#17 stale-pagination guard
(48d30f3, credited to @jeromelefeuvre).
@jeromelefeuvre
jeromelefeuvre force-pushed the fix/hardcoded-prefix-paths branch 3 times, most recently from d71b393 to ce1adc8 Compare July 28, 2026 03:51
jeromelefeuvre and others added 4 commits July 29, 2026 00:43
…-widget

fix(media): collapse the download queue widget by default on Radarr/S…
Inline JS and href/action attributes across /medias, /decouverte,
/jellyseerr, /prowlarr and /setup templates hardcoded absolute paths
(fetch('/medias/...'), href="/jellyseerr/...", etc.), which ignore the
X-Forwarded-Prefix header set by a reverse proxy stripping a path
prefix (e.g. Traefik serving the app under /prismarr/). Symfony's
Request::getBasePath() already reflects that header, so prefix each
hardcoded literal with app.request.getBasePath() (or, inside an
already-open Twig expression, with the ~ concat operator) instead of
relying on instance_path()/path() helpers that aren't used everywhere.

Each affected template now computes the prefix once into a local
`var BASE` (matching the existing convention in qbittorrent/index and
deluge/index) instead of repeating the Twig call inline at every call
site, shrinking the diff and keeping a single point of change per
template.

The initial sweep missed the app-wide quick-look modal (_quicklook.html.twig,
shared by the dashboard, calendar, Explorer and top-bar search), the
top-bar search's own quicklook URLs in base.html.twig, the Plex info
modal (dashboard/_plex_info_modal.html.twig) and the entire Tautulli
dashboard page (tautulli/index.html.twig) — none of those were under
/medias, /decouverte, /jellyseerr, /prowlarr or /setup, so they kept
hardcoded absolute paths that broke behind a stripped path prefix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@jeromelefeuvre
jeromelefeuvre force-pushed the fix/hardcoded-prefix-paths branch from ce1adc8 to 894e5ce Compare July 30, 2026 03:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants