The default_tag dropdown was populated from every tag (type=1), but the
dashboard taglist only renders pinned tags. Selecting an unpinned tag as the
default therefore triggered a click on a taglist entry that does not exist,
silently doing nothing. Filter the option queries in both Setting accessors to
pinned tags so only selectable tags are offered, and assert an unpinned tag is
excluded.
Also drop the unused $data['default_tag'] assignment in ItemController: the
taglist partial reads the setting directly via Setting::fetch(), so the view
variable was never consumed.
The custom TrustHosts middleware only overrode hosts(), so it inherited the
parent's shouldSpecifyTrustedHosts() gate, which skips enforcement whenever the
app runs in the local environment or under the test runner. Heimdall ships
APP_ENV=local by default (.env.example, copied to .env on install), so the
TRUSTED_HOSTS allow-list a user configures per the .env.example guidance was
never actually applied.
Override shouldSpecifyTrustedHosts() to tie enforcement to configuration
instead of environment: apply the allow-list whenever TRUSTED_HOSTS is set, in
any environment; when it is unset hosts() is empty and enforcement stays off,
preserving the historic no-restriction behaviour. Add handle()-driven tests
covering both the configured and unconfigured cases.
In tags mode the dashboard always opened showing every link. This adds a
"Default tag" setting (Advanced) that pre-selects one tag group on load, so
the dashboard opens filtered to it - the built-in equivalent of the custom
JavaScript workaround people have been sharing.
The setting is a select populated from the user's own tags, following the
same dynamic-option pattern already used for the search provider. When a tag
is chosen its slug is exposed on the tag list and the matching tab is
activated on load; when the setting is empty, behaviour is unchanged and all
links are shown.
Resolves#1556
get_stats/{id} fataled when the item id was missing and 500'd whenever an
enhanced app's livestats() threw - a broken or updated remote app definition
(e.g. Komga) took the whole request down, and the frontend then stopped
refreshing that tile entirely.
getStats now returns valid JSON (200) with an inactive/empty payload when the
item is missing, has no class, references a stale class, or throws, logging
the failure for diagnosis. The successful path is unchanged and returns the
livestats output verbatim.
Resolves#1558
Heimdall trusted the incoming X-Forwarded-Host header for URL generation, so
a spoofed value poisoned the page base href, asset() URLs and redirect
targets - loading assets from and redirecting to an attacker-controlled host
(CVE-2025-50578).
- TrustProxies no longer trusts X-Forwarded-Host; a forged value can no longer
influence getHost(), url(), asset() or redirects. X-Forwarded-For/Port/Proto
handling is unchanged.
- Trusted proxies are now configurable via the TRUSTED_PROXIES env var
(comma-separated CIDRs/IPs, "*" to trust all), defaulting to the previous
private ranges.
- Added an opt-in TRUSTED_HOSTS allow-list: when set, only the listed hosts are
served and any other Host header is rejected. Unset keeps the historic
behaviour of serving arbitrary hosts, so existing installs are unaffected.
Resolves#1451
The export endpoint (api/item) now emits each item's assigned tag titles,
excluding the root/default dashboard tag. On import, those titles are
resolved back to local tags - reusing an existing tag or creating a missing
one - instead of dropping every imported item onto the default dashboard.
Tags round-trip by title so a config can be moved between instances without
having to reassign each item to its section by hand.
Resolves#1555
Previously logged only the exception message. Adds:
- exception_class: distinguishes ClientException vs ConnectException
vs other Guzzle/PHP failure types at a glance
- file: file path and line where the exception was raised, useful
for distinguishing 'failed inside Guzzle' from 'failed inside our
code path'
The exception message itself often contains the GitHub API URL,
which encodes the app identifier. Capturing the specific appid at
the moment of failure would require touching handle() to track the
current iteration; left as a follow-up.
Most failures for these jobs are GitHub API rate-limit responses;
retries inside the same window do not help, so one attempt is enough
and $backoff has nothing to pace.
$timeout would clip the intentionally throttled handle() loop
(sleep(1) per app) below realistic workloads. Heavy users with 60+
apps would lose updates mid-cycle. The original code left $timeout
unset, and `UpdateApps` is dispatched via `dispatchAfterResponse()`
which doesn't go through `queue:work` at all (the queue $timeout
is irrelevant in that path). Letting the operator's worker config
govern is more honest.
$uniqueFor reduced to 600 (10 min) since with $tries=1 +
worker-governed timeout there is no long retry chain to outlive.
Lock self-heals 10 minutes after a crashed worker.
Both jobs implement ShouldBeUnique without a $uniqueFor value, which on
Redis and database drivers produces a lock that never expires. If the
worker is killed mid-fire (OOM, SIGKILL, server crash), the lock
persists and blocks all future dispatches of UpdateApps or ProcessApps
until the cache entry is manually cleared.
Neither job sets $tries, $backoff, or $timeout, so they inherit the
worker command's defaults (1 for queue:work, 0 for vapor:work), which
varies by platform and is brittle.
This change adds:
- $uniqueFor = 3600 lock expires after 1 hour
- $tries = 3 hard cap across worker restarts
- $backoff = [30, 60, 120] pace retries to reduce GitHub API load
- $timeout = 60 bound per-attempt wall-clock time
- failed(Throwable) log permanent failures (and preserve the
existing Cache::lock('updateApps')->forceRelease
on UpdateApps)
A new test (tests/Feature/QueueSafetyTest.php) asserts the retry
properties are present.
All existing tests still pass.