Adds a new boolean setting in the Advanced settings group that allows
users to globally skip TLS certificate verification for all enhanced
apps. This is useful for users who have self-signed certificates on
their services.
When enabled, the Guzzle HTTP client will set 'verify' => false for
all API requests made by enhanced apps.
Resolveslinuxserver/Heimdall-Apps#687
Tiles could vanish without ever being deleted:
- Editing an item merged the editor's user_id into every save, so
updating a visible item (e.g. a shared user_id=0 tile) silently
reassigned ownership and hid it from everyone else. user_id is now
set on create only, and excluded from update input in both Item and
Tag controllers since it is mass-assignable.
- Deleting a user left their items orphaned with a dangling user_id,
invisible to all users forever. The user's items are now hard-deleted
with the account, and a data migration reassigns already-orphaned
items to the admin user so previously "lost" tiles reappear.
- The Item global scope's ownership filter had an ungrouped orWhere,
breaking operator precedence in any query that adds further clauses.
Includes regression coverage for ownership on create/update, user
deletion cleanup, and the orphan-recovery migration.
The :memory: overrides in phpunit.xml had been commented out since 2024,
so RefreshDatabase ran migrate:fresh against the real .env database and
wiped it on every local test run. Enable the overrides, let the special
:memory: identifier bypass database_path() resolution and the boot-time
touch(), and add a TestCase guard that aborts the suite unless it is
pointed at in-memory sqlite.
ItemExportTest only ever passed by reading the populated dev database;
seed the root dashboard item it depends on so it passes on a fresh DB.
- itemImport: check response.ok in fetchAppDetails so a genuine 404
(from the appload return-type fix) is reported as 'Failed to find app
id' instead of being parsed as a successful import; applied to the
source and the committed compiled bundle.
- phpunit.xml: point the schema URL at 12.5 to match the installed
PHPUnit 12.5.x.
- ColorHelpersTest: exercise the get_brightness() non-hex stripping the
test name promised (interior separators), which the prior assertion
never covered.
PHPUnit 12 renamed PHPT -> Phpt (src/Framework/Exception/PhptAssertionFailedError.php
and the src/Runner/Phpt/ directory). Because this repo commits vendor/ and the
macOS dev filesystem is case-insensitive, git kept the old-case paths in the index
while Composer wrote the new case to disk, so the change went undetected locally.
On CI's case-sensitive Linux filesystem the checked-out old-case files don't satisfy
PHPUnit's require of the new-case names and 'php artisan test' fatals before running.
Re-track these 8 files under their correct case.
ItemController::appload() was declared ': ?string', so its two error
branches that 'return response()->json([...], 404)' had the JsonResponse
coerced through Response::__toString() into a raw HTTP message served as
an HTTP 200 body. Widen the return type to
'\Illuminate\Http\JsonResponse|string|null' so those branches emit
real 404 JSON responses. The method body is unchanged, so the happy path
still returns the same JSON string and the frontend contract is preserved.
Flip the endpoint characterization test to assert the corrected 404.
Add tests guarding the surfaces most likely to break on a future
Laravel/PHP upgrade:
- Helper globals: format_bytes, parse_size, className, get_brightness,
title_color (tests/Unit/helpers)
- CSRF exception config actually reaches the framework
(PreventRequestForgery neverVerify list) and the excepted routes resolve
- Core GET routes boot and render on the current framework
- Filesystem disks resolve and the local disk root stays pinned to
storage_path('app') (guards the Laravel 12 default-root change)
Add a package.json "overrides" block forcing patched versions of
vulnerable transitive build dependencies (shell-quote, ws, node-forge,
serialize-javascript, lodash, minimatch, path-to-regexp, svgo, postcss,
qs, uuid and others). This takes `npm audit` from 41 vulnerabilities
(1 critical, 13 high, 18 moderate, 9 low) down to 9 (0 critical, 0 high,
3 moderate, 6 low).
The 9 residuals are all dev/build-time-only advisories in the EOL
laravel-mix@6 toolchain (the elliptic crypto-polyfill chain with no
upstream patch, ajv 6.x under babel-loader, webpack-dev-server /
laravel-mix, and webpack) that cannot be cleared without replacing
laravel-mix; none ship in Heimdall's production runtime. Both
'npm run production' and 'npm run dev' still compile successfully.
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
Bumps the packages reported by CVE/GHSA scans in #1564 to their patched
releases (with transitive dependencies):
- symfony/http-foundation 7.3.1 -> 7.4.14 (CVE-2025-64500 / GHSA-3rg7-wf37-54rm)
- phpunit/phpunit 10.5.47 -> 10.5.64 (CVE-2026-24765 / GHSA-vvj3-c3rp-c85p)
- aws/aws-sdk-php 3.349.3 -> 3.388.0 (GHSA-27qh-8cxx-2cr5)
- enshrined/svg-sanitize 0.21.0 -> 0.22.0 (GHSA-22wq-q86m-83fh)
Some transitive dependencies now require PHP 8.4, which matches the runtime
shipped in the official LinuxServer image, so the composer requirement is
raised to ^8.4, CI is pinned to PHP 8.4, and the readme is updated to match.
The remaining advisories in the report (php84, curl, libpq, git, sqlite,
busybox, coreutils) come from the LinuxServer base image, not this
repository, and are addressed by rebuilding the image on an updated base.
Refs #1564
CI ran `yarn && yarn dev` with no committed yarn.lock, so every run resolved
the latest matching versions and dependency drift broke the pipeline in two
independent ways:
- select2 4.1.0 added engines.node ">=24" but the runner used node 22, so
`yarn install` failed outright on every pull request.
- webpack 5.108 removed lib/SizeFormatHelpers, which laravel-mix 6 still
requires, so `yarn dev` would have failed the build regardless of node.
Switch the workflow to `npm ci`, which installs the exact, known-good
versions already pinned in package-lock.json (select2 4.0.13, webpack
5.100.1) and is verified to build and lint cleanly. Pin the runner to node
24 via actions/setup-node so the toolchain is explicit rather than tracking
the runner default. Also pin select2 to ~4.0.13 in package.json so a future
`npm install` cannot pull the incompatible 4.1.0 back in.
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
Reproduction-style behavior test, not a regression test, so it does
not belong in the main suite. Full test remains in this branch's
commit history at 56c53ab9 for reviewers:
git checkout 56c53ab9 -- tests/Feature/QueueFailedHandlerTest.php
vendor/bin/phpunit --filter QueueFailedHandlerTest
Keeps the PR aligned with Heimdall's existing test conventions
(HTTP-feature tests, no facade-mocking unit tests).
Tests that UpdateApps::failed() and ProcessApps::failed():
- call Log::error with the 'permanently failed' message
- include exception_class, exception_message, and file context keys
- for UpdateApps, still call Cache::lock('updateApps')->forceRelease()
Uses Log::spy() and Mockery to capture facade calls. 2 tests, 4
assertions, green on PHP 8.4.20. See follow-up commit for why this
lands in history but not in the shipped suite.
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.
The test only asserted property values and method_exists. The diff
itself shows the property values; the test added zero confidence
beyond reading the diff. Heimdall is an app, not a package; its
existing tests are HTTP feature tests against user behavior, not
class-property assertions. Removing this file keeps the PR aligned
with the existing test conventions.
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.