Compare commits

...

85 Commits

Author SHA1 Message Date
KodeStar 238452c104 Merge pull request #1583 from linuxserver/fix/app-icon-consistent-size
Fix inconsistent SVG tile icon sizes
2026-08-03 15:52:03 +01:00
KodeStar 91f7a2ec8d Fix inconsistent SVG tile icon sizes
SVG icons with explicit width/height attributes rendered tiny because
max-width/max-height only caps intrinsic size and never scales up, while
dimensionless SVGs defaulted large and were capped to 60px. Pin .app-icon
to a fixed 60x60 box with object-fit: contain so every icon renders at a
consistent size while preserving aspect ratio.

Fixes #1582
2026-08-03 15:47:36 +01:00
KodeStar 62823aef63 Merge pull request #1580 from tuxinaut/chore/de-language-update
🇩🇪 Update German translation
2026-07-10 17:10:02 +01:00
Schäfer, Denny 2ceec61e17 Update German translation 2026-07-10 12:30:14 +02:00
KodeStar 8fc5f3ccbf Merge pull request #1579 from linuxserver/release/v2.8.1
Tag and release / Tag and publish release (push) Has been cancelled
Bump version to 2.8.1
2026-07-09 22:58:46 +01:00
github-actions[bot] 6a45d44f69 Bump version to 2.8.1 2026-07-09 21:35:30 +00:00
KodeStar 327af60d1c Update CI 2026-07-09 22:33:11 +01:00
KodeStar a38ecd4864 Automate bumping version 2026-07-09 22:21:37 +01:00
KodeStar 1f7432fa26 Merge pull request #1578 from linuxserver/feat/truenas-websocket-client
Add WebSocket support for TrueNAS JSON-RPC 2.0 API
2026-07-09 22:06:47 +01:00
KodeStar 55bda9daf0 Merge pull request #1577 from linuxserver/fix/checkbox-test-config
Fix checkbox config values always returning "1" in Test config
2026-07-09 22:06:42 +01:00
KodeStar 667dd4c129 Merge pull request #1576 from linuxserver/feat/global-tls-skip
Add global TLS verification skip setting
2026-07-09 22:06:38 +01:00
KodeStar d16b7f60f3 Vendor phrity/websocket dependency and fix v3 exception namespace
The WebSocket support added for TrueNAS JSON-RPC 2.0 requires the
phrity/websocket library to actually be available at runtime. This repo
commits the vendor/ tree (CI does not run composer install), so the
dependency and composer.lock must be committed for class_exists() checks
in the TrueNAS app to succeed.

- composer require phrity/websocket:^3.6 (resolves to 3.7.3) with lock
  and vendor/ committed
- Fix TrueNASWebSocketClient to catch WebSocket\Exception\Exception
  (phrity/websocket v3 namespace) instead of the non-existent
  WebSocket\ConnectionException from the old textalk/websocket v1/v2 API,
  so connection/call failures are logged and wrapped as intended
2026-07-09 22:01:18 +01:00
Jay Collett 57fa9f26fb Fix checkbox config values always returning "1"
Checkbox-type config options (e.g. ignore_tls) always tested as "1"
regardless of the checkbox state. The Test-button config gatherer used
$(this).val() for every .config-item, and jQuery .val() on a checkbox
returns its value attribute ("1") regardless of checked state. App
config blades pair a hidden input (0) with a checkbox (1) sharing the
same data-config, and the checkbox is last in DOM order, so it always
overwrote the value with "1". Normal form saves were unaffected.

Use the :checked state for checkboxes so unchecking now tests as "0".

Also rebuilds the committed compiled bundle (public/js/app.js) so the
fix takes effect at runtime; the edit is byte-identical to the Laravel
Mix development build output for this block.

Fixes linuxserver/Heimdall-Apps#782
2026-07-09 21:59:46 +01:00
Jay Collett bdf9160fdc Add global TLS verification skip setting
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.

Resolves linuxserver/Heimdall-Apps#687
2026-07-09 21:57:45 +01:00
Jay Collett 93a321ff5b Add WebSocket support for TrueNAS JSON-RPC 2.0 API
TrueNAS is deprecating the REST API (api/v2.0/) in version 26.04,
requiring migration to JSON-RPC 2.0 over WebSocket.

This commit adds:
- phrity/websocket dependency for WebSocket communication
- TrueNASWebSocketClient helper class that handles:
  - Connection to ws(s)://host/api/current
  - Authentication via auth.login_with_api_key
  - JSON-RPC 2.0 request/response formatting
  - TLS verification toggle
  - Proper connection cleanup

Refs: #1530
2026-07-09 21:56:41 +01:00
KodeStar 3f483c9ca7 Merge pull request #1575 from linuxserver/chore/laravel-13-upgrade
Fix to try and mitigate any disappearing tiles
2026-07-09 11:30:47 +01:00
KodeStar 1c7ae3e335 Fix silent tile disappearance and ownership-reassignment bugs
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.
2026-07-09 10:52:29 +01:00
KodeStar 2555ab1b3c Run tests against in-memory sqlite and refuse real databases
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.
2026-07-09 10:52:17 +01:00
KodeStar 49c29c8681 Merge pull request #1574 from linuxserver/chore/laravel-13-upgrade
Upgrade to Laravel 13, bump to v2.8.0, and remediate vulnerabilities
2026-07-09 09:31:38 +01:00
KodeStar 6f932918aa Fix import status regression from appload 404 + tighten upgrade tests
- 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.
2026-07-09 09:28:53 +01:00
KodeStar 13642d59d9 Fix committed PHPUnit vendor path casing for case-sensitive CI
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.
2026-07-08 20:40:49 +01:00
KodeStar 25d1dc3c6a Bump version to 2.8.0 2026-07-08 20:33:27 +01:00
KodeStar f1eec81591 Fix appload() returning a coerced HTTP 200 instead of a 404
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.
2026-07-08 20:33:26 +01:00
KodeStar 46e09d172a Add upgrade-regression test coverage
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)
2026-07-08 20:33:25 +01:00
KodeStar 9cfa2548fa Remediate npm build-toolchain vulnerabilities via overrides
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.
2026-07-08 20:33:25 +01:00
KodeStar 8a622545ff Upgrade to Laravel 13
- laravel/framework ^12.0 -> ^13.0 (installed 13.19.0)
- laravel/tinker ^2.9 -> ^3.0 (installed 3.0.2, pulls psysh >=0.12.19)
- phpunit/phpunit ^11.0 -> ^12.0 (installed 12.5.31)
- Symfony components move to 8.x (supported by L13); symfony/yaml stays on
  patched 7.4.14 via its direct ^7.0 constraint
- league/commonmark auto-bumped to 2.8.2 (patched)
- No application/config code changes required; the fluent
  validateCsrfTokens(except: ...) config still resolves under L13

composer audit: No security vulnerability advisories found.
Full suite green: 59 tests, 128 assertions (1 skipped).
2026-07-08 19:57:08 +01:00
KodeStar 39191885e5 Upgrade to Laravel 12
- laravel/framework ^11.45 -> ^12.0 (installed 12.63.0)
- phpunit/phpunit ^10.5 -> ^11.0; migrate phpunit.xml to the 11.5 schema
- graham-campbell/github ^12.5 -> ^13.0 (v12 caps illuminate/support at ^11)
- Carbon 3 pulled in by L12; no application code changes required
- gitignore /.phpunit.cache

Full suite green: 59 tests, 128 assertions (1 skipped).
2026-07-08 19:49:05 +01:00
KodeStar cb59689ead Merge pull request #1569 from linuxserver/chore/security-dependency-updates
Update security-flagged dependencies and require PHP 8.4 (#1564)
2026-07-08 18:30:36 +01:00
KodeStar 6d0242dead Merge pull request #1572 from linuxserver/feature/default-tag-group
Add a configurable default tag for the dashboard
2026-07-08 18:29:43 +01:00
KodeStar caa4f39edd Merge pull request #1567 from linuxserver/fix/export-import-tags
Include tags in item export and restore them on import
2026-07-08 18:29:18 +01:00
KodeStar a1f0d8f75d Merge pull request #1568 from linuxserver/fix/host-header-injection
Harden against host header injection and open redirect (CVE-2025-50578)
2026-07-08 18:29:04 +01:00
KodeStar 076348478e Merge pull request #1570 from linuxserver/fix/getstats-graceful-failure
Return graceful output from get_stats instead of a 500
2026-07-08 18:28:55 +01:00
KodeStar ad9baffa62 Only offer pinned tags as the default tag
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.
2026-07-08 18:26:19 +01:00
KodeStar 9a9877a0dc Enforce TRUSTED_HOSTS allow-list regardless of APP_ENV
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.
2026-07-08 18:22:49 +01:00
KodeStar c0c202c5ff Add a configurable default tag for the dashboard
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
2026-07-08 17:10:19 +01:00
KodeStar f69cbba6cd Update dependencies flagged by security advisories and require PHP 8.4
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
2026-07-08 16:30:22 +01:00
KodeStar ec229ea0af Merge branch '2.x' into fix/export-import-tags 2026-07-08 16:09:46 +01:00
KodeStar 5dcf462542 Merge branch '2.x' into fix/host-header-injection 2026-07-08 16:09:44 +01:00
KodeStar d1c52b886b Merge branch '2.x' into fix/getstats-graceful-failure 2026-07-08 16:09:42 +01:00
KodeStar e2215fe42b Merge pull request #1571 from linuxserver/fix/ci-node-select2
Fix CI: pin Node 24 and install frontend deps with npm ci
2026-07-08 16:08:59 +01:00
KodeStar 5247349d33 Fix CI: pin Node 24 and install frontend deps with npm ci
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.
2026-07-08 16:06:35 +01:00
KodeStar f547ae42bb Return graceful output from get_stats instead of a 500
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
2026-07-08 15:21:39 +01:00
KodeStar 881533baa5 Harden against host header injection and open redirect
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
2026-07-08 14:42:12 +01:00
KodeStar fb9af1b216 Include tags in item export and restore them on import
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
2026-07-08 14:18:45 +01:00
KodeStar 5907a1f231 Merge pull request #1559 from JoshSalway/queue-safety-2026-04-22
[2.x] Bound retry and unique-lock lifetimes on UpdateApps and ProcessApps
2026-07-08 09:35:47 +01:00
KodeStar df0eba046b Merge pull request #1563 from Nyuwb/patch-1
fix: proxy options in ItemController
2026-07-08 09:34:39 +01:00
Fabien Ehrlich 6a776e30f8 fix: proxy options in ItemController
The correct context is http->proxy :

https://www.php.net/manual/en/context.http.php
2026-05-13 11:35:13 +02:00
Josh Salway cbf099be2c Remove QueueFailedHandlerTest from the shipped suite
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).
2026-04-22 20:16:11 +10:00
Josh Salway 56c53ab9c5 Prove failed() log shape with behavior test
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.
2026-04-22 20:15:54 +10:00
Josh Salway 98b6d96cd1 Enrich failed() log context with exception class and file:line
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.
2026-04-22 17:01:18 +10:00
Josh Salway 5be7a65677 Tighten retry shape: $tries=1, $uniqueFor=600, drop $timeout and $backoff
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.
2026-04-22 16:57:49 +10:00
Josh Salway 44be3cb319 Drop tests/Feature/QueueSafetyTest.php
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.
2026-04-22 16:56:21 +10:00
Josh Salway 243ad00810 Bound retry and unique-lock lifetimes on UpdateApps and ProcessApps
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.
2026-04-22 14:55:01 +10:00
KodeStar 7861ae1512 Merge pull request #1524 from KodeStar/remove_dropdown_on_single_provider
Mark stale issues and pull requests / stale (push) Has been cancelled
Remove search provider dropdown when there's only a single provider
2025-11-11 12:05:43 +00:00
Chris Hunt 66dfe95c9f Fix lint issue 2025-11-11 12:02:53 +00:00
Chris Hunt 130661bd34 Remove search provider dropdown when there's only a single provider
Resolves #1509
2025-11-11 12:00:41 +00:00
KodeStar 900fc83e79 Merge pull request #1523 from KodeStar/add_autocomplete_suggestions
Add autocomplete suggestions support
2025-11-11 11:43:55 +00:00
Chris Hunt 4f30332854 Fix lint issues 2025-11-11 11:42:38 +00:00
Chris Hunt 852c231724 Add autocomplete suggestions support and added to bing, duckduckgo, and google 2025-11-11 11:39:06 +00:00
KodeStar 045bdf0deb Merge pull request #1507 from KodeStar/taglist
Mark stale issues and pull requests / stale (push) Has been cancelled
Fix tag list url when tags are treated as tags
2025-09-16 09:53:06 +01:00
Chris Hunt 755c3e59e1 Fix tag list url when tags are treated as tags 2025-09-16 09:50:03 +01:00
KodeStar 32bf1d034f Add password field to fix #1498 2025-09-15 16:49:42 +01:00
Chris Hunt 6d12c547e7 Add password field 2025-09-15 16:42:53 +01:00
KodeStar ae4ce92dab Merge pull request #1503 from KodeStar/background_max_file_size
Add current background maxsize #1501
2025-09-15 16:28:52 +01:00
KodeStar 966279b252 Merge pull request #1499 from webmogul1/patch-1
Update app.php
2025-09-15 16:28:28 +01:00
KodeStar 54cf2b88ca Update application version to 2.7.6 2025-09-15 16:28:00 +01:00
Chris Hunt 31f1ba8192 Add current background maxsize #1501 2025-09-15 16:26:10 +01:00
webmogul1 eadd9d1dd8 Update app.php
Change version to 2.7.5
2025-09-11 14:46:50 -04:00
KodeStar c9ea2cdeb3 Merge pull request #1496 from KodeStar/bugfix/update_proxy_and_items_with_no_password
Update items with no password
2025-09-10 16:15:23 +01:00
Chris Hunt 517f51ba90 Update items with no password 2025-09-10 16:14:14 +01:00
KodeStar 825f67a4a4 Merge pull request #1480 from Nyuwb/patch-1
feat(icon-upload): proxy management
2025-09-10 15:15:48 +01:00
KodeStar 05a552ffcf Merge pull request #1475 from micvog/fix-german-translation
Fixed multiple typos (German translation)
2025-09-10 15:14:55 +01:00
Adam ad4584e548 Merge pull request #1488 from linuxserver/inherit-security-md 2025-08-23 15:22:58 +01:00
Adam 7d93099f2c Delete SECURITY.md
Inherit LSIO standard security.md from https://github.com/linuxserver/.github/blob/main/SECURITY.md
2025-08-23 15:20:18 +01:00
KodeStar 31ca05f74f Merge pull request #1483 from KodeStar/2.x
Mark stale issues and pull requests / stale (push) Has been cancelled
Fix for some enhanced apps not working
2025-08-02 17:50:17 +01:00
Chris Hunt cd95fc3b92 Update search test 2025-08-02 17:43:49 +01:00
Chris Hunt 63e777b338 Redirect to search provider without error fixes #1482 2025-08-02 17:40:37 +01:00
Chris Hunt fd926e983d Fix for some enhanced apps not working 2025-08-02 17:17:40 +01:00
Fabien Ehrlich dce37c1412 feat(icon-upload): proxy management 2025-07-31 16:54:44 +02:00
KodeStar 6b9f61b0e6 Merge pull request #1477 from KodeStar/2.x
Mark stale issues and pull requests / stale (push) Has been cancelled
Escape search queries and add setting value on edit
2025-07-24 19:06:50 +01:00
Chris Hunt d1a96dd752 Escape search queries and add setting value on edit 2025-07-24 19:05:16 +01:00
KodeStar 1ccc0da2a7 Merge pull request #1476 from KodeStar/2.x
Mark stale issues and pull requests / stale (push) Has been cancelled
Load in configs values if class has been lost
2025-07-22 15:59:00 +01:00
Chris Hunt 41aa255b88 Add missing variable 2025-07-22 15:57:29 +01:00
Chris Hunt a8e4ab448b Load in configs values if class has been lost 2025-07-22 15:50:51 +01:00
micvog 31db31d0f7 Fixed multiple typos (German translation) 2025-07-21 17:37:39 +02:00
6696 changed files with 253050 additions and 82914 deletions
+10
View File
@@ -4,6 +4,16 @@ APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
# Security: Host Header Injection / Open Redirect hardening (CVE-2025-50578).
# TRUSTED_PROXIES: comma-separated CIDRs/IPs of reverse proxies allowed to set
# X-Forwarded-* headers. Defaults to the private ranges below when unset. Use
# "*" to trust all proxies (only behind a trusted network boundary).
#TRUSTED_PROXIES=192.168.0.0/16,172.16.0.0/12,10.0.0.0/8,127.0.0.1
# TRUSTED_HOSTS: comma-separated hostnames Heimdall is allowed to serve. Unset
# means no restriction (default, backward compatible). Set this to your own
# domain to fully prevent host-header injection / open redirects.
#TRUSTED_HOSTS=heimdall.example.com
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
+8 -8
View File
@@ -12,7 +12,7 @@ jobs:
- name: Setup PHP, with composer and extensions
uses: shivammathur/setup-php@v2 #https://github.com/shivammathur/setup-php
with:
php-version: '8.3'
php-version: '8.4'
extensions: mbstring, dom, fileinfo, mysql, libxml, xml, xmlwriter, dom, tokenizer, filter, json, phar, pcre, openssl, pdo, intl, curl
- name: Cache composer dependencies
@@ -31,17 +31,17 @@ jobs:
cp .env.example .env
php artisan key:generate
- name: Cache yarn dependencies
uses: actions/cache@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
path: node_modules
key: yarn-${{ hashFiles('yarn.lock') }}
node-version: '24'
cache: 'npm'
- name: Run yarn
run: yarn && yarn dev
- name: Install node modules and build assets
run: npm ci && npm run dev
- name: Run ESLint
run: yarn lint
run: npm run lint
- name: Run tests
run: php artisan test
+97
View File
@@ -0,0 +1,97 @@
name: Release
# Stage 1 of the release flow: opens a version-bump PR against 2.x.
# When that PR is merged, tag-release.yml (stage 2) creates the tag and
# the GitHub release automatically.
on:
workflow_dispatch:
inputs:
bump:
description: 'Version bump type'
required: true
default: 'patch'
type: choice
options:
- patch
- minor
- major
version:
description: 'Explicit version (e.g. 2.9.0) — overrides bump type'
required: false
type: string
permissions:
contents: write
pull-requests: write
jobs:
release-pr:
name: Open version bump PR
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: 2.x
fetch-depth: 0
# Optional: set a RELEASE_TOKEN repo secret (fine-grained PAT with
# contents + pull-requests write) so the bump PR triggers CI checks.
# PRs created with the default github.token do not trigger workflows.
token: ${{ secrets.RELEASE_TOKEN || github.token }}
- name: Determine new version
id: version
env:
EXPLICIT_VERSION: ${{ inputs.version }}
BUMP: ${{ inputs.bump }}
run: |
current=$(sed -nE "s/^[[:space:]]*'version' => '([0-9]+\.[0-9]+\.[0-9]+)',/\1/p" config/app.php)
if [ -z "$current" ]; then
echo "::error::Could not read current version from config/app.php"
exit 1
fi
if [ -n "$EXPLICIT_VERSION" ]; then
new="$EXPLICIT_VERSION"
if ! echo "$new" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::Invalid version '$new' — expected X.Y.Z"
exit 1
fi
else
IFS=. read -r major minor patch <<< "$current"
case "$BUMP" in
major) new="$((major + 1)).0.0" ;;
minor) new="$major.$((minor + 1)).0" ;;
patch) new="$major.$minor.$((patch + 1))" ;;
esac
fi
if git rev-parse -q --verify "refs/tags/v$new" > /dev/null; then
echo "::error::Tag v$new already exists"
exit 1
fi
echo "Bumping $current -> $new"
echo "new=$new" >> "$GITHUB_OUTPUT"
- name: Push bump commit to release branch
env:
NEW_VERSION: ${{ steps.version.outputs.new }}
run: |
sed -i -E "s/^([[:space:]]*'version' => ')[0-9.]+(',)/\1$NEW_VERSION\2/" config/app.php
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -am "Bump version to $NEW_VERSION"
git push --force origin "HEAD:refs/heads/release/v$NEW_VERSION"
- name: Open pull request
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN || github.token }}
NEW_VERSION: ${{ steps.version.outputs.new }}
run: |
existing=$(gh pr list --head "release/v$NEW_VERSION" --base 2.x --state open --json number -q '.[0].number')
if [ -n "$existing" ]; then
echo "PR #$existing is already open for release/v$NEW_VERSION"
else
gh pr create \
--base 2.x \
--head "release/v$NEW_VERSION" \
--title "Bump version to $NEW_VERSION" \
--body "Automated version bump. Merging this PR will tag v$NEW_VERSION and publish the GitHub release. Merge it last, once everything for the release is on 2.x."
fi
+41
View File
@@ -0,0 +1,41 @@
name: Tag and release
# Stage 2 of the release flow: whenever the version in config/app.php changes
# on 2.x (normally by merging the PR opened by release.yml, but a hand-made
# bump PR works too), create the matching tag and GitHub release.
on:
push:
branches:
- 2.x
paths:
- config/app.php
permissions:
contents: write
jobs:
tag-release:
name: Tag and publish release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create release if version is untagged
env:
GH_TOKEN: ${{ github.token }}
run: |
version=$(sed -nE "s/^[[:space:]]*'version' => '([0-9]+\.[0-9]+\.[0-9]+)',/\1/p" config/app.php)
if [ -z "$version" ]; then
echo "::error::Could not read version from config/app.php"
exit 1
fi
if git rev-parse -q --verify "refs/tags/v$version" > /dev/null; then
echo "Tag v$version already exists — nothing to do"
exit 0
fi
gh release create "v$version" \
--target "$GITHUB_SHA" \
--title "v$version" \
--generate-notes
+26
View File
@@ -0,0 +1,26 @@
name: Tag version check
# Safety net for manually pushed tags: fails if the tag doesn't match the
# version in config/app.php. Tags created by tag-release.yml use GITHUB_TOKEN
# and therefore don't trigger this (they always match anyway).
on:
push:
tags:
- 'v*'
jobs:
check:
name: Tag matches config/app.php
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Compare tag with app version
run: |
tag="${GITHUB_REF_NAME#v}"
version=$(sed -nE "s/^[[:space:]]*'version' => '([0-9]+\.[0-9]+\.[0-9]+)',/\1/p" config/app.php)
if [ "$tag" != "$version" ]; then
echo "::error::Tag v$tag does not match config/app.php version $version — bump the version (or use the Release workflow, which does it for you)"
exit 1
fi
echo "Tag v$tag matches config/app.php"
+1
View File
@@ -29,3 +29,4 @@ yarn-error.log
storage/app/public/avatars/*
.env
.phpunit.result.cache
/.phpunit.cache
-14
View File
@@ -1,14 +0,0 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 2.3.x | :white_check_mark: |
| < 2.3 | :x: |
## Reporting a Vulnerability
You can report any vulnerabilities on our discord server by DM-ing a team member, or asking a team member to DM you.
https://discord.com/invite/YWrKVTn
+11
View File
@@ -27,6 +27,17 @@ function format_bytes($bytes, bool $is_drive_size = true, string $beforeunit = '
}
}
function parse_size($size) {
$unit = strtolower(substr($size, -1));
$bytes = (int)$size;
switch($unit) {
case 'g': $bytes *= 1024 * 1024 * 1024; break;
case 'm': $bytes *= 1024 * 1024; break;
case 'k': $bytes *= 1024; break;
}
return $bytes;
}
/**
* @param $title
* @param string $separator
+187
View File
@@ -0,0 +1,187 @@
<?php
namespace App\Helpers;
use Illuminate\Support\Facades\Log;
use Phrity\Net\Context;
use WebSocket\Client;
use WebSocket\Exception\Exception as WebSocketException;
/**
* TrueNAS JSON-RPC 2.0 WebSocket Client
*
* Handles WebSocket communication with TrueNAS using the JSON-RPC 2.0 protocol.
* Required for TrueNAS 25.04+ as the REST API is deprecated.
*
* @see https://api.truenas.com/v25.10/jsonrpc.html
*/
class TrueNASWebSocketClient
{
private ?Client $client = null;
private string $url;
private string $apiKey;
private bool $ignoreTls;
private bool $authenticated = false;
private int $requestId = 1;
/**
* Create a new TrueNAS WebSocket client instance.
*
* @param string $baseUrl The base URL of the TrueNAS instance (e.g., https://truenas.local)
* @param string $apiKey The API key for authentication
* @param bool $ignoreTls Whether to skip TLS certificate verification
*/
public function __construct(string $baseUrl, string $apiKey, bool $ignoreTls = false)
{
$baseUrl = rtrim($baseUrl, '/');
$scheme = parse_url($baseUrl, PHP_URL_SCHEME);
$host = parse_url($baseUrl, PHP_URL_HOST);
$port = parse_url($baseUrl, PHP_URL_PORT);
$wsScheme = ($scheme === 'https') ? 'wss' : 'ws';
$portPart = $port ? ':' . $port : '';
$this->url = "{$wsScheme}://{$host}{$portPart}/api/current";
$this->apiKey = $apiKey;
$this->ignoreTls = $ignoreTls;
}
/**
* Connect to the TrueNAS WebSocket API and authenticate.
*
* @return bool True if connection and authentication succeeded
* @throws \Exception If connection or authentication fails
*/
public function connect(): bool
{
if ($this->client !== null && $this->authenticated) {
return true;
}
// Build SSL options - always force HTTP/1.1 via ALPN for WebSocket compatibility
// TrueNAS nginx defaults to HTTP/2 which doesn't support WebSocket upgrade
$sslOptions = [
'alpn_protocols' => 'http/1.1',
];
if ($this->ignoreTls) {
$sslOptions['verify_peer'] = false;
$sslOptions['verify_peer_name'] = false;
$sslOptions['allow_self_signed'] = true;
}
// Create context using phrity/net-stream Context class (required by phrity/websocket v3.x)
$streamContext = stream_context_create(['ssl' => $sslOptions]);
$context = new Context($streamContext);
try {
$this->client = new Client($this->url);
$this->client->setTimeout(15);
$this->client->setContext($context);
$authResult = $this->call('auth.login_with_api_key', [$this->apiKey]);
if ($authResult === true) {
$this->authenticated = true;
return true;
}
throw new \Exception('Authentication failed: Invalid API key');
} catch (WebSocketException $e) {
Log::error('TrueNAS WebSocket connection failed: ' . $e->getMessage());
$this->disconnect();
throw new \Exception('WebSocket connection failed: ' . $e->getMessage());
}
}
/**
* Make a JSON-RPC 2.0 call to the TrueNAS API.
*
* @param string $method The JSON-RPC method name (e.g., 'system.info')
* @param array $params Optional parameters for the method
* @return mixed The result from the API call
* @throws \Exception If the call fails or returns an error
*/
public function call(string $method, array $params = [])
{
if ($this->client === null) {
throw new \Exception('WebSocket client not connected');
}
$request = [
'jsonrpc' => '2.0',
'method' => $method,
'id' => $this->requestId++,
];
if (!empty($params)) {
$request['params'] = $params;
}
try {
$this->client->text(json_encode($request));
$response = $this->client->receive();
$decoded = json_decode($response->getContent(), true);
if (isset($decoded['error'])) {
$errorMsg = $decoded['error']['message'] ?? 'Unknown error';
$errorCode = $decoded['error']['code'] ?? 0;
throw new \Exception("API error ({$errorCode}): {$errorMsg}");
}
return $decoded['result'] ?? null;
} catch (WebSocketException $e) {
Log::error('TrueNAS WebSocket call failed: ' . $e->getMessage());
throw new \Exception('WebSocket call failed: ' . $e->getMessage());
}
}
/**
* Close the WebSocket connection.
*/
public function disconnect(): void
{
if ($this->client !== null) {
try {
$this->client->close();
} catch (\Exception $e) {
Log::debug('Error closing WebSocket: ' . $e->getMessage());
}
$this->client = null;
$this->authenticated = false;
}
}
/**
* Check if the client is connected and authenticated.
*
* @return bool
*/
public function isConnected(): bool
{
return $this->client !== null && $this->authenticated;
}
/**
* Test the connection by calling core.ping.
*
* @return bool True if the ping succeeds
*/
public function ping(): bool
{
try {
$result = $this->call('core.ping');
return $result === 'pong';
} catch (\Exception $e) {
return false;
}
}
/**
* Clean up on destruction.
*/
public function __destruct()
{
$this->disconnect();
}
}
+79 -20
View File
@@ -194,6 +194,7 @@ class ItemController extends Controller
public function create(): View
{
//
$data['item'] = new \App\Item();
$data['tags'] = Item::ofType('tag')->orderBy('title', 'asc')->pluck('title', 'id');
$data['tags']->prepend(__('app.dashboard'), 0);
$data['current_tags'] = '0';
@@ -266,9 +267,16 @@ class ItemController extends Controller
],
];
// Proxy management
$httpsProxy = getenv('HTTPS_PROXY');
$httpsProxyLower = getenv('https_proxy');
if ($httpsProxy !== false || $httpsProxyLower !== false) {
$options['http']['proxy'] = $httpsProxy ?: $httpsProxyLower;
}
$file = $request->input('icon');
$path_parts = pathinfo($file);
if (!isset($path_parts['extension'])) {
if (!array_key_exists('extension', $path_parts)) {
throw ValidationException::withMessages(['file' => 'Icon URL must have a valid file extension.']);
}
$extension = $path_parts['extension'];
@@ -311,17 +319,27 @@ class ItemController extends Controller
$storedConfigObject = json_decode($storedItem->getAttribute('description'));
$configObject = json_decode($config);
$configObject->password = $storedConfigObject->password;
if ($storedConfigObject && property_exists($storedConfigObject, 'password')) {
$configObject->password = $storedConfigObject->password;
} else {
$configObject->password = null;
}
$config = json_encode($configObject);
}
$current_user = User::currentUser();
$request->merge([
'description' => $config,
'user_id' => $current_user->getId(),
]);
// Only assign ownership when creating; updates must keep the existing owner.
if ($id === null) {
$current_user = User::currentUser();
$request->merge([
'user_id' => $current_user->getId(),
]);
}
if ($request->input('appid') === 'null' || $request->input('appid') === null) {
$request->merge([
'class' => null,
@@ -336,7 +354,8 @@ class ItemController extends Controller
$item = Item::create($request->all());
} else {
$item = Item::find($id);
$item->update($request->all());
// Exclude user_id so an update can never reassign ownership
$item->update($request->except(['user_id']));
}
$item->parents()->sync($request->tags);
@@ -418,27 +437,41 @@ class ItemController extends Controller
*
* @throws GuzzleException
*/
public function appload(Request $request): ?string
public function appload(Request $request): \Illuminate\Http\JsonResponse|string|null
{
$output = [];
$appid = $request->input('app');
$itemId = $request->input('item_id');
if ($appid === 'null') {
return null;
}
$output['config'] = null;
$output['custom'] = null;
$app = Application::single($appid);
if (!$app) {
return response()->json(['error' => 'Application not found.'], 404);
}
$output = (array)$app;
$appdetails = Application::getApp($appid);
if (!$appdetails) {
return response()->json(['error' => 'Application details not found.'], 404);
}
if ((bool)$app->enhanced === true) {
// if(!isset($app->config)) { // class based config
$output['custom'] = className($appdetails->name) . '.config';
// }
$item = $itemId ? Item::find($itemId) : Item::where('appid', $appid)->first();
if ($item) {
$output['custom'] = className($appdetails->name) . '.config';
$output['appvalue'] = $item->description;
} else {
// Ensure the app is installed if not found
$output['custom'] = className($appdetails->name) . '.config';
$output['appvalue'] = null;
}
}
$output['colour'] = ($app->tile_background == 'light') ? '#fafbfc' : '#161b1f';
@@ -446,14 +479,12 @@ class ItemController extends Controller
if (strpos($app->icon, '://') !== false) {
$output['iconview'] = $app->icon;
} elseif (strpos($app->icon, 'icons/') !== false) {
// Private apps have the icon locally
$output['iconview'] = URL::to('/') . '/storage/' . $app->icon;
$output['icon'] = str_replace('icons/', '', $output['icon']);
} else {
$output['iconview'] = config('app.appsource') . 'icons/' . $app->icon;
}
return json_encode($output);
}
@@ -563,18 +594,46 @@ class ItemController extends Controller
}
/**
* @param $id
* @return void
* Return live stats for an enhanced application tile.
*
* Always responds with HTTP 200 and valid JSON so the frontend refresh
* loop (liveStatRefresh.js) keeps re-queueing the tile. On any failure we
* degrade gracefully to an inactive, empty tile instead of a 500.
*
* @param int|string $id
* @return \Illuminate\Http\JsonResponse|\Illuminate\Http\Response
*/
public function getStats($id)
{
$item = Item::find($id);
$graceful = response()->json(['status' => 'inactive', 'html' => '']);
$item = Item::find($id);
if ($item === null) {
return $graceful;
}
// Non-enhanced items (or stale records) have no live-stats class.
if (empty($item->class)) {
return $graceful;
}
try {
$config = $item->getconfig();
// Guard against a stale/renamed class string from the remote apps repo.
if (! class_exists($item->class)) {
return $graceful;
}
$config = $item->getconfig();
if (isset($item->class)) {
$application = new $item->class;
$application->config = $config;
echo $application->livestats();
// livestats() returns a JSON string; return it verbatim (no re-encoding).
return response($application->livestats());
} catch (\Throwable $e) {
Log::error('getStats failed for item '.$id.' ('.$item->class.'): '.$e->getMessage());
return $graceful;
}
}
+85 -2
View File
@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Item;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Collection;
@@ -21,6 +22,7 @@ class ItemRestController extends Controller
public function index(): Collection
{
$columns = [
'id',
'title',
'colour',
'url',
@@ -29,11 +31,27 @@ class ItemRestController extends Controller
'appdescription',
];
return Item::select($columns)
return Item::with('parents')
->select($columns)
->where('deleted_at', null)
->where('type', '0')
->orderBy('order', 'asc')
->get();
->get()
->map(function (Item $item) {
return [
'title' => $item->title,
'colour' => $item->colour,
'url' => $item->url,
'description' => $item->description,
'appid' => $item->appid,
'appdescription' => $item->appdescription,
'tags' => $item->parents
->where('id', '!=', 0)
->pluck('title')
->values()
->all(),
];
});
}
/**
@@ -50,6 +68,16 @@ class ItemRestController extends Controller
*/
public function store(Request $request): object
{
// Imports pass tags as an array of tag titles so they can round-trip
// across instances. Resolve those titles into local tag ids (creating
// any that don't yet exist) before handing off to the shared store
// logic. When no tags are supplied we keep the previous behaviour.
if ($request->has('tags')) {
$request->merge([
'tags' => $this->resolveTags($request->input('tags')),
]);
}
$item = ItemController::storelogic($request);
if ($item) {
@@ -59,6 +87,61 @@ class ItemRestController extends Controller
return (object) ['status' => 'FAILED'];
}
/**
* Resolve an incoming list of tags into tag ids.
*
* Numeric 0 (or "0") maps to the root/default dashboard. Every other entry
* is treated as a tag title: an existing tag with that title is reused, and
* a missing one is created. The lookup keeps the operation idempotent so
* importing many items that share a tag title only ever creates one tag.
*
* @param mixed $tags
* @return array<int, int>
*/
private function resolveTags($tags): array
{
if (! is_array($tags)) {
return [0];
}
$ids = [];
foreach ($tags as $tag) {
if ($tag === 0 || $tag === '0') {
$ids[] = 0;
continue;
}
$title = is_string($tag) ? trim($tag) : $tag;
if ($title === '' || $title === null) {
continue;
}
$existing = Item::where('type', '1')
->where('title', $title)
->first();
if ($existing) {
$ids[] = (int) $existing->id;
continue;
}
$created = Item::create([
'title' => $title,
'type' => '1',
'url' => str_slug($title, '-', 'en_US'),
'user_id' => User::currentUser()->getId(),
]);
$ids[] = (int) $created->id;
}
$ids = array_values(array_unique($ids));
return empty($ids) ? [0] : $ids;
}
/**
* Display the specified resource.
*/
+104 -5
View File
@@ -4,9 +4,11 @@ namespace App\Http\Controllers;
use App\Search;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Facades\Http;
class SearchController extends Controller
{
@@ -18,10 +20,8 @@ class SearchController extends Controller
$requestprovider = $request->input('provider');
$query = $request->input('q');
// Validate the presence and non-emptiness of the query parameter
if (!$query || trim($query) === '') {
abort(400, 'Missing or empty query parameter');
}
// Sanitize the query to prevent XSS
$query = htmlspecialchars($query, ENT_QUOTES, 'UTF-8');
$provider = Search::providerDetails($requestprovider);
@@ -29,6 +29,11 @@ class SearchController extends Controller
abort(404, 'Invalid provider');
}
// If the query is empty, redirect to the provider's base URL
if (!$query || trim($query) === '') {
return redirect($provider->url);
}
if ($provider->type == 'standard') {
return redirect($provider->url.'?'.$provider->query.'='.urlencode($query));
} elseif ($provider->type == 'external') {
@@ -36,5 +41,99 @@ class SearchController extends Controller
return $class->getResults($query, $provider);
}
abort(404, 'Provider type not supported');}
abort(404, 'Provider type not supported');
}
/**
* Get autocomplete suggestions for a search query
*
* @return JsonResponse
*/
public function autocomplete(Request $request)
{
$requestprovider = $request->input('provider');
$query = $request->input('q');
if (!$query || trim($query) === '') {
return response()->json([]);
}
$provider = Search::providerDetails($requestprovider);
if (!$provider || !isset($provider->autocomplete)) {
return response()->json([]);
}
// Replace {query} placeholder with actual query
$autocompleteUrl = str_replace('{query}', urlencode($query), $provider->autocomplete);
try {
$response = Http::timeout(5)->get($autocompleteUrl);
if ($response->successful()) {
$data = $response->body();
// Parse the response based on provider
$suggestions = $this->parseAutocompleteResponse($data, $provider->id);
return response()->json($suggestions);
}
} catch (\Exception $e) {
// Return empty array on error
return response()->json([]);
}
return response()->json([]);
}
/**
* Parse autocomplete response based on provider format
*
* @param string $data
* @param string $providerId
* @return array
*/
private function parseAutocompleteResponse($data, $providerId)
{
$suggestions = [];
switch ($providerId) {
case 'google':
// Google returns XML format
if (strpos($data, '<?xml') === 0) {
$xml = simplexml_load_string($data);
if ($xml && isset($xml->CompleteSuggestion)) {
foreach ($xml->CompleteSuggestion as $suggestion) {
if (isset($suggestion->suggestion['data'])) {
$suggestions[] = (string) $suggestion->suggestion['data'];
}
}
}
}
break;
case 'bing':
case 'ddg':
// Bing and DuckDuckGo return JSON array format
$json = json_decode($data, true);
if (is_array($json) && isset($json[1]) && is_array($json[1])) {
$suggestions = $json[1];
}
break;
default:
// Try to parse as JSON array
$json = json_decode($data, true);
if (is_array($json)) {
if (isset($json[1]) && is_array($json[1])) {
$suggestions = $json[1];
} else {
$suggestions = $json;
}
}
break;
}
return $suggestions;
}
}
@@ -45,6 +45,7 @@ class SettingsController extends Controller
if (! is_null($setting)) {
return view('settings.edit')->with([
'setting' => $setting,
'value' => $setting->value,
]);
} else {
$route = route('settings.list', []);
+4 -1
View File
@@ -101,6 +101,8 @@ class TagController extends Controller
$data['tag'] = $item->id;
$data['all_apps'] = $item->children;
$data['taglist'] = Item::ofType('tag')->where('id', '>', 0)->orderBy('title', 'asc')->get();
return view('welcome', $data);
}
@@ -140,7 +142,8 @@ class TagController extends Controller
'url' => $slug,
]);
Item::find($id)->update($request->all());
// Exclude user_id so an update can never reassign ownership
Item::find($id)->update($request->except(['user_id']));
$route = route('dash', []);
+6
View File
@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Item;
use App\User;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
@@ -154,6 +155,11 @@ class UserController extends Controller
public function destroy(User $user): RedirectResponse
{
if ($user->id !== 1) {
// Hard-delete this user's items (tiles and tags) so they don't become
// orphaned; item_tag pivot rows cascade via the existing FK. Shared
// items (user_id = 0) are left untouched.
Item::withoutGlobalScopes()->where('user_id', $user->id)->forceDelete();
$user->delete();
$route = route('dash', []);
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustHosts as Middleware;
class TrustHosts extends Middleware
{
/**
* Get the host patterns that should be trusted.
*
* The allow-list is read from the TRUSTED_HOSTS env var (comma-separated
* hostnames). When it is unset/empty an empty array is returned so that NO
* host restriction is applied, preserving Heimdall's historic behaviour of
* running on arbitrary hosts. When set, only the listed hosts (and their
* subdomains) are accepted; any other Host header is rejected by Symfony
* with a SuspiciousOperationException (HTTP 400).
*
* @return array
*/
public function hosts()
{
$trustedHosts = env('TRUSTED_HOSTS');
if ($trustedHosts === null || trim((string) $trustedHosts) === '') {
return [];
}
$hosts = [];
foreach (explode(',', (string) $trustedHosts) as $host) {
$host = trim($host);
if ($host !== '') {
$hosts[] = '^(.+\.)?'.preg_quote($host).'$';
}
}
return $hosts;
}
/**
* Determine if the application should specify trusted hosts.
*
* The parent implementation skips enforcement whenever the app runs in the
* "local" environment (Heimdall's shipped default, see .env.example) or
* under the test runner, which would leave the TRUSTED_HOSTS allow-list
* silently unenforced for almost every real deployment. Instead we tie
* enforcement directly to configuration: apply the allow-list whenever one
* has actually been provided, in any environment. When TRUSTED_HOSTS is
* unset hosts() is empty and this returns false, preserving the historic
* no-restriction behaviour.
*
* @return bool
*/
protected function shouldSpecifyTrustedHosts()
{
return ! empty($this->hosts());
}
}
+40 -4
View File
@@ -8,16 +8,52 @@ use Illuminate\Http\Request;
class TrustProxies extends Middleware
{
/**
* The trusted proxies for this application.
* The default trusted proxies used when the TRUSTED_PROXIES env var is unset.
*
* @var array
*/
protected $proxies = ['192.168.0.0/16', '172.16.0.0/12', '10.0.0.0/8', '127.0.0.1'];
protected $defaultProxies = ['192.168.0.0/16', '172.16.0.0/12', '10.0.0.0/8', '127.0.0.1'];
/**
* The trusted proxies for this application.
*
* @var array<int, string>|string|null
*/
protected $proxies;
/**
* The current proxy header mappings.
*
* @var array
* Note: Request::HEADER_X_FORWARDED_HOST is intentionally NOT trusted to
* prevent Host header injection / open redirects (CVE-2025-50578). A spoofed
* X-Forwarded-Host header must never influence getHost()/url()/asset().
*
* @var int
*/
protected $headers = Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB;
protected $headers = Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB;
/**
* Create a new middleware instance.
*
* The set of trusted proxies is read from the TRUSTED_PROXIES env var
* (comma-separated CIDRs/IPs). When unset it falls back to the historic
* default list. The special value "*" trusts all proxies.
*
* @return void
*/
public function __construct()
{
$trustedProxies = env('TRUSTED_PROXIES');
if ($trustedProxies === null || trim((string) $trustedProxies) === '') {
$this->proxies = $this->defaultProxies;
} elseif (trim((string) $trustedProxies) === '*') {
$this->proxies = '*';
} else {
$this->proxies = array_values(array_filter(
array_map('trim', explode(',', (string) $trustedProxies)),
fn ($proxy) => $proxy !== ''
));
}
}
}
+3 -1
View File
@@ -86,7 +86,9 @@ class Item extends Model
static::addGlobalScope('user_id', function (Builder $builder) {
$current_user = User::currentUser();
if ($current_user) {
$builder->where('user_id', $current_user->getId())->orWhere('user_id', 0);
$builder->where(function ($query) use ($current_user) {
$query->where('user_id', $current_user->getId())->orWhere('user_id', 0);
});
} else {
$builder->where('user_id', 0);
}
+22
View File
@@ -14,11 +14,24 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Throwable;
class ProcessApps implements ShouldQueue, ShouldBeUnique
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Most failures here are GitHub rate-limit responses; retries inside the
* same window do not help, so a single attempt is enough.
*/
public int $tries = 1;
/**
* Expire the ShouldBeUnique lock after 10 minutes so a crashed worker
* does not permanently block future ProcessApps dispatches.
*/
public int $uniqueFor = 600;
/**
* Create a new job instance.
*
@@ -57,4 +70,13 @@ class ProcessApps implements ShouldQueue, ShouldBeUnique
}
}
}
public function failed(Throwable $exception): void
{
Log::error(static::class . ' permanently failed', [
'exception_class' => $exception::class,
'exception_message' => $exception->getMessage(),
'file' => $exception->getFile() . ':' . $exception->getLine(),
]);
}
}
+22 -1
View File
@@ -12,11 +12,26 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Throwable;
class UpdateApps implements ShouldQueue, ShouldBeUnique
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Most failures here are GitHub rate-limit responses; retries inside the
* same window do not help, so a single attempt is enough. The throttle
* loop in handle() means the job is intentionally long-running, so we
* leave $timeout unset and let the operator's worker config govern.
*/
public int $tries = 1;
/**
* Expire the ShouldBeUnique lock after 10 minutes so a crashed worker
* does not permanently block future UpdateApps dispatches.
*/
public int $uniqueFor = 600;
/**
* Create a new job instance.
*
@@ -49,8 +64,14 @@ class UpdateApps implements ShouldQueue, ShouldBeUnique
Cache::lock('updateApps')->forceRelease();
}
public function failed($exception): void
public function failed(Throwable $exception): void
{
Cache::lock('updateApps')->forceRelease();
Log::error(static::class . ' permanently failed', [
'exception_class' => $exception::class,
'exception_message' => $exception->getMessage(),
'file' => $exception->getFile() . ':' . $exception->getLine(),
]);
}
}
+3 -2
View File
@@ -150,9 +150,10 @@ class AppServiceProvider extends ServiceProvider
$db_type = config()->get('database.default');
if ($db_type == 'sqlite') {
$db_file = database_path(env('DB_DATABASE', 'app.sqlite'));
$db_file = config()->get('database.connections.sqlite.database');
Log::debug('SQLite Database Path: ' . $db_file);
if (! is_file($db_file)) {
// Do not create a file for the in-memory database identifier.
if ($db_file !== ':memory:' && ! is_file($db_file)) {
touch($db_file);
}
}
+21 -6
View File
@@ -111,17 +111,32 @@ abstract class Search
if ((bool) $user_search_provider) {
$name = 'app.options.'.$user_search_provider;
$provider = self::providerDetails($user_search_provider);
$providers = self::providers();
$providerCount = count($providers);
// If there's only one provider, use its key instead of the user's setting
if ($providerCount === 1) {
$user_search_provider = $providers->keys()->first();
}
$output .= '<div class="searchform">';
$output .= '<form action="'.url('search').'"'.getLinkTargetAttribute().' method="get">';
$output .= '<div id="search-container" class="input-container">';
$output .= '<select name="provider">';
foreach (self::providers() as $key => $searchprovider) {
$selected = ((string) $key === (string) $user_search_provider) ? ' selected="selected"' : '';
$output .= '<option value="'.$key.'"'.$selected.'>'.$searchprovider['name'].'</option>';
// Only show dropdown if there's more than one provider
if ($providerCount > 1) {
$output .= '<select name="provider">';
foreach ($providers as $key => $searchprovider) {
$selected = ((string) $key === (string) $user_search_provider) ? ' selected="selected"' : '';
$output .= '<option value="'.$key.'"'.$selected.'>'.$searchprovider['name'].'</option>';
}
$output .= '</select>';
} else {
// Hidden input for single provider
$output .= '<input type="hidden" name="provider" value="'.$user_search_provider.'" />';
}
$output .= '</select>';
$output .= '<input type="text" name="q" value="'.(Input::get('q') ?? '').'" class="homesearch" autofocus placeholder="'.__('app.settings.search').'..." />';
$output .= '<input type="text" name="q" value="'.e(Input::get('q') ?? '').'" class="homesearch" autofocus placeholder="'.__('app.settings.search').'..." />';
$output .= '<button type="submit">'.ucwords(__('app.settings.search')).'</button>';
$output .= '</div>';
$output .= '</form>';
+21
View File
@@ -21,6 +21,27 @@ class CustomFormBuilder
);
}
public function password($name, $options = [])
{
return new HtmlString(
$this->html->input('password', $name)->attributes($options)
);
}
public function hidden($name, $value = null, $options = [])
{
return new HtmlString(
$this->html->input('hidden', $name, $value)->attributes($options)
);
}
public function checkbox($name, $value = null, $checked = false, $options = [])
{
return new HtmlString(
$this->html->checkbox($name, $value, $checked)->attributes($options)
);
}
public function select($name, $list = [], $selected = null, $options = [])
{
return new HtmlString(
+36 -24
View File
@@ -123,6 +123,12 @@ class Setting extends Model
$options = (array) json_decode($this->options);
if ($this->key === 'search_provider') {
$options = Search::providers()->pluck('name', 'id')->toArray();
} elseif ($this->key === 'default_tag') {
$options = [];
$tags = Item::where('type', 1)->where('id', '>', 0)->pinned()->orderBy('title', 'asc')->get();
foreach ($tags as $tag) {
$options[$tag->tag_url] = $tag->title;
}
}
$value = (array_key_exists($this->value, $options))
? __($options[$this->value])
@@ -150,64 +156,70 @@ class Setting extends Model
switch ($this->type) {
case 'image':
$value = '';
if (isset($this->value) && ! empty($this->value)) {
$value .= '<a class="setting-view-image" href="'.
asset('storage/'.$this->value).
'" title="'.
__('app.settings.view').
'" target="_blank"><img src="'.
asset('storage/'.
$this->value).
if (isset($this->value) && !empty($this->value)) {
$value .= '<a class="setting-view-image" href="' .
asset('storage/' . $this->value) .
'" title="' .
__('app.settings.view') .
'" target="_blank"><img src="' .
asset('storage/' .
$this->value) .
'" /></a>';
}
$value .= '<input type="file" name="value" class="form-control" />';
if (isset($this->value) && ! empty($this->value)) {
$value .= '<a class="settinglink" href="'.
route('settings.clear', $this->id).
'" title="'.
__('app.settings.remove').
'">'.
__('app.settings.reset').
if (isset($this->value) && !empty($this->value)) {
$value .= '<a class="settinglink" href="' .
route('settings.clear', $this->id) .
'" title="' .
__('app.settings.remove') .
'">' .
__('app.settings.reset') .
'</a>';
}
break;
case 'boolean':
$checked = false;
if (isset($this->value) && (bool) $this->value === true) {
if (isset($this->value) && (bool)$this->value === true) {
$checked = true;
}
$set_checked = ($checked) ? ' checked="checked"' : '';
$value = '
<input type="hidden" name="value" value="0" />
<label class="switch">
<input type="checkbox" name="value" value="1"'.$set_checked.' />
<input type="checkbox" name="value" value="1"' . $set_checked . ' />
<span class="slider round"></span>
</label>';
break;
case 'select':
$options = json_decode($this->options);
if ($this->key === 'search_provider') {
$options = Search::providers()->pluck('name', 'id');
} elseif ($this->key === 'default_tag') {
$options = ['' => 'app.options.none'];
$tags = Item::where('type', 1)->where('id', '>', 0)->pinned()->orderBy('title', 'asc')->get();
foreach ($tags as $tag) {
$options[$tag->tag_url] = $tag->title;
}
}
$value = '<select name="value" class="form-control">';
foreach ($options as $key => $opt) {
$value .= '<option value="'.$key.'" '.(($this->value == $key) ? 'selected' : '').'>'.__($opt).'</option>';
$value .= '<option value="' . $key . '" ' . (($this->value == $key) ? 'selected' : '') . '>' . __($opt) . '</option>';
}
$value .= '</select>';
break;
case 'textarea':
$value = '<textarea name="value" class="form-control" cols="44" rows="15"></textarea>';
$value = '<textarea name="value" class="form-control" cols="44" rows="15">' . htmlspecialchars($this->value, ENT_QUOTES, 'UTF-8') . '</textarea>';
break;
default:
$value = '<input type="text" name="value" class="form-control" />';
$value = '<input type="text" name="value" class="form-control" value="' . htmlspecialchars($this->value, ENT_QUOTES, 'UTF-8') . '" />';
break;
}
return $value;
}
public function group(): BelongsTo
{
return $this->belongsTo(\App\SettingGroup::class, 'group_id');
+5
View File
@@ -85,6 +85,11 @@ abstract class SupportedApps
'connect_timeout' => 15,
] : $overridevars;
// Check global setting to skip TLS verification (useful for self-signed certificates)
if (Setting::fetch('skip_tls_verification')) {
$vars['verify'] = false;
}
$client = new Client($vars);
$method = ($overridemethod === null || $overridemethod === false) ? $this->method : $overridemethod;
+3
View File
@@ -32,6 +32,9 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->replace(\Illuminate\Http\Middleware\TrustProxies::class, \App\Http\Middleware\TrustProxies::class);
$middleware->trustHosts();
$middleware->replace(\Illuminate\Http\Middleware\TrustHosts::class, \App\Http\Middleware\TrustHosts::class);
$middleware->alias([
'allowed' => \App\Http\Middleware\CheckAllowed::class,
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
+10 -6
View File
@@ -8,17 +8,18 @@
"license": "MIT",
"type": "project",
"require": {
"php": "^8.2",
"php": "^8.4",
"ext-intl": "*",
"ext-json": "*",
"enshrined/svg-sanitize": "^0.21.0",
"graham-campbell/github": "^12.5",
"enshrined/svg-sanitize": "^0.22.0",
"graham-campbell/github": "^13.0",
"guzzlehttp/guzzle": "^7.8",
"laravel/framework": "^11.45",
"laravel/tinker": "^2.9",
"laravel/framework": "^13.0",
"laravel/tinker": "^3.0",
"laravel/ui": "^4.4",
"league/flysystem-aws-s3-v3": "^3.0",
"nunomaduro/collision": "^8.0",
"phrity/websocket": "^3.6",
"spatie/laravel-html": "^3.11",
"spatie/laravel-ignition": "^2.4",
"symfony/yaml": "^7.0"
@@ -27,7 +28,7 @@
"barryvdh/laravel-ide-helper": "^3.0",
"filp/whoops": "^2.8",
"mockery/mockery": "^1.6",
"phpunit/phpunit": "^10.5",
"phpunit/phpunit": "^12.0",
"squizlabs/php_codesniffer": "3.*",
"symfony/thanks": "^1.2",
"fakerphp/faker": "^1.23"
@@ -84,6 +85,9 @@
"kylekatarnls/update-helper": true,
"symfony/thanks": true,
"php-http/discovery": true
},
"platform": {
}
},
"minimum-stability": "stable",
Generated
+2447 -1409
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -5,7 +5,7 @@ use Illuminate\Support\Facades\Facade;
return [
'version' => '2.7.1',
'version' => '2.8.1',
'appsource' => env('APP_SOURCE', 'https://appslist.heimdall.site/'),
+5 -1
View File
@@ -7,7 +7,11 @@ return [
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'database' => database_path(env('DB_DATABASE', 'app.sqlite')), // Make sure to use the correct path
// Use the correct path, but let the special in-memory identifier
// pass through untouched so tests can run against ':memory:'.
'database' => env('DB_DATABASE', 'app.sqlite') === ':memory:'
? ':memory:'
: database_path(env('DB_DATABASE', 'app.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), // Enable foreign key constraints
],
@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* Reassign items whose user_id references a user that no longer exists to a
* surviving user, so previously orphaned tiles and tags become visible again.
* user_id = 0 means "shared with all users" and is never treated as orphaned.
*
* The DB query builder is used directly (not the Item model) so the global
* scope and soft-delete constraints don't interfere.
*/
public function up(): void
{
// Prefer user id 1 if it still exists, otherwise the lowest existing id.
$target = DB::table('users')->where('id', 1)->value('id')
?? DB::table('users')->min('id');
// No users left: nothing to reassign to.
if ($target === null) {
return;
}
DB::table('items')
->where('user_id', '!=', 0)
->whereNotIn('user_id', function ($query) {
$query->select('id')->from('users');
})
->update(['user_id' => $target]);
}
/**
* Reverse the migrations.
*
* This is a data migration; the original ownership cannot be recovered.
*/
public function down(): void
{
//
}
};
+29
View File
@@ -349,5 +349,34 @@ class SettingsSeeder extends Seeder
$setting->label = 'app.settings.treat_tags_as';
$setting->save();
}
if (! $setting = Setting::find(15)) {
$setting = new Setting;
$setting->id = 15;
$setting->group_id = 4;
$setting->key = 'default_tag';
$setting->type = 'select';
$setting->label = 'app.settings.default_tag';
$setting->value = '';
$setting->save();
} else {
$setting->group_id = 4;
$setting->label = 'app.settings.default_tag';
$setting->save();
}
if (! $setting = Setting::find(16)) {
$setting = new Setting;
$setting->id = 16;
$setting->group_id = 4;
$setting->key = 'skip_tls_verification';
$setting->type = 'boolean';
$setting->label = 'app.settings.skip_tls_verification';
$setting->value = '0';
$setting->save();
} else {
$setting->label = 'app.settings.skip_tls_verification';
$setting->save();
}
}
}
+16 -5
View File
@@ -20,7 +20,7 @@ return array (
'settings.language' => 'Sprache',
'settings.reset' => 'Zurücksetzen auf Standard',
'settings.remove' => 'Entfernen',
'settings.search' => 'suche',
'settings.search' => 'Suche',
'settings.no_items' => 'Keine Elemente gefunden',
'settings.label' => 'Bezeichnung',
'settings.value' => 'Wert',
@@ -28,12 +28,18 @@ return array (
'settings.view' => 'Ansicht',
'settings.custom_css' => 'Angepasstes CSS',
'settings.custom_js' => 'Angepasstes JavaScript',
'settings.treat_tags_as' => 'Tags behandeln als:',
'settings.default_tag' => 'Standard Tag',
'settings.folders' => 'Ordner',
'settings.tags' => 'Tags',
'settings.categories' => 'Kategorien',
'settings.skip_tls_verification' => 'TLS-Überprüfung überspringen (selbstsignierte Zertifikate)',
'options.none' => '- nicht festgelegt -',
'options.google' => 'Google',
'options.ddg' => 'DuckDuckGo',
'options.bing' => 'Bing',
'options.qwant' => 'Qwant',
'options.startpage' => 'StartSeite',
'options.startpage' => 'Startseite',
'options.yes' => 'Ja',
'options.no' => 'Nein',
'options.nzbhydra' => 'NZBHydra',
@@ -46,7 +52,7 @@ return array (
'dash.pin_item' => 'Element auf dem Dashboard anheften',
'dash.no_apps' => 'Derzeit gibt es keine angeheftete Anwendungen. :link1 oder :link2',
'dash.link1' => 'Anwendung neu hinzufügen',
'dash.link2' => 'anheften',
'dash.link2' => 'Anheften',
'dash.pinned_items' => 'Angeheftete Elemente',
'apps.app_list' => 'Anwendungsliste',
'apps.view_trash' => 'Ansicht Papierkorb',
@@ -66,7 +72,7 @@ return array (
'apps.add_tag' => 'Tag hinzufügen',
'apps.tag_name' => 'Tag Name',
'apps.tags' => 'Tags',
'apps.override' => 'Fals anders zur Haupt-URL',
'apps.override' => 'Falls anders zur Haupt-URL',
'apps.preview' => 'Vorschau',
'apps.apptype' => 'Anwendungstyp',
'apps.website' => 'Webseite',
@@ -74,6 +80,7 @@ return array (
'apps.only_admin_account' => 'Nur mit Admin-Konto!',
'apps.autologin_url' => 'Auto Login URL',
'apps.show_deleted' => 'Gelöschte Anwendung anzeigen',
'app.import' => 'Importieren',
'dashboard' => 'Home Dashboard',
'user.user_list' => 'Nutzer',
'user.add_user' => 'Nutzer hinzufügen',
@@ -81,13 +88,15 @@ return array (
'user.avatar' => 'Avatar',
'user.email' => 'Email',
'user.password_confirm' => 'Passwort bestätigen',
'user.secure_front' => 'Öffentlichen Zugang erlauben - Tritt nur bei gesetztem Passwort in kraft.',
'user.secure_front' => 'Öffentlichen Zugang erlauben - Tritt nur bei gesetztem Passwort in Kraft.',
'user.autologin' => 'Anmelden von spezieller URL erlauben. Jeder mit diesem Link kann sich anmelden.',
'url' => 'URL',
'title' => 'Titel',
'delete' => 'Löschen',
'optional' => 'Optional',
'restore' => 'Wiederherstellen',
'export' => 'Exportieren',
'import' => 'Importieren',
'alert.success.item_created' => 'Element erfolgreich erstellt',
'alert.success.item_updated' => 'Element erfolgreich aktualisiert',
'alert.success.item_deleted' => 'Element erfolgreich gelöscht',
@@ -99,6 +108,8 @@ return array (
'alert.success.tag_restored' => 'Tag erfolgreich wiederhergestellt',
'alert.success.setting_updated' => 'Die Einstellungen wurden übernommen',
'alert.error.not_exist' => 'Diese Einstellung existiert nicht.',
'alert.error.file_too_big' => 'Datei zu groß',
'alert.error.file_not_stored' => 'Datei konnte nicht gespeichert werden',
'alert.success.user_created' => 'Nutzer erfolgreich erstellt',
'alert.success.user_updated' => 'Nutzer erfolgreich aktualisiert',
'alert.success.user_deleted' => 'Nutzer erfolgreich gelöscht',
+2
View File
@@ -29,9 +29,11 @@ return array (
'settings.custom_css' => 'Custom CSS',
'settings.custom_js' => 'Custom JavaScript',
'settings.treat_tags_as' => 'Treat Tags As:',
'settings.default_tag' => 'Default tag',
'settings.folders' => 'Folders',
'settings.tags' => 'Tags',
'settings.categories' => 'Categories',
'settings.skip_tls_verification' => 'Skip TLS Verification (for self-signed certificates)',
'options.none' => '- not set -',
'options.google' => 'Google',
'options.ddg' => 'DuckDuckGo',
+273 -279
View File
@@ -1,11 +1,11 @@
{
"name": "Heimdall",
"name": "Heimdall-LS",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"select2": "^4.0.13",
"select2": "~4.0.13",
"sortablejs": "^1.15.0"
},
"devDependencies": {
@@ -24,26 +24,14 @@
"webpack-cli": "^6.0.1"
}
},
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.27.1",
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -52,30 +40,32 @@
}
},
"node_modules/@babel/compat-data": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz",
"integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/core": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz",
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.0",
"@babel/helper-compilation-targets": "^7.27.2",
"@babel/helper-module-transforms": "^7.27.3",
"@babel/helpers": "^7.27.6",
"@babel/parser": "^7.28.0",
"@babel/template": "^7.27.2",
"@babel/traverse": "^7.28.0",
"@babel/types": "^7.28.0",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-compilation-targets": "^7.29.7",
"@babel/helper-module-transforms": "^7.29.7",
"@babel/helpers": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
@@ -91,13 +81,14 @@
}
},
"node_modules/@babel/generator": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz",
"integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.0",
"@babel/types": "^7.28.0",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -119,13 +110,14 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
"integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.27.2",
"@babel/helper-validator-option": "^7.27.1",
"@babel/compat-data": "^7.29.7",
"@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
@@ -189,10 +181,11 @@
}
},
"node_modules/@babel/helper-globals": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
@@ -211,27 +204,29 @@
}
},
"node_modules/@babel/helper-module-imports": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.1"
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.27.3",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz",
"integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1",
"@babel/traverse": "^7.27.3"
"@babel/helper-module-imports": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -253,10 +248,11 @@
}
},
"node_modules/@babel/helper-plugin-utils": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
"integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
"integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
@@ -309,28 +305,31 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-option": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
@@ -350,25 +349,27 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz",
"integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.27.2",
"@babel/types": "^7.27.6"
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.0"
"@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -955,15 +956,16 @@
}
},
"node_modules/@babel/plugin-transform-modules-systemjs": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.27.1.tgz",
"integrity": "sha512-w5N1XzsRbc0PQStASMksmUeqECuzKuTJer7kFagK8AXgpCMkeDMO5S+aaFb7A51ZYDF7XI34qsTX+fkHiIm5yA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.7.tgz",
"integrity": "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-transforms": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1",
"@babel/traverse": "^7.27.1"
"@babel/helper-module-transforms": "^7.29.7",
"@babel/helper-plugin-utils": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -1491,31 +1493,33 @@
}
},
"node_modules/@babel/template": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/parser": "^7.27.2",
"@babel/types": "^7.27.1"
"@babel/code-frame": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz",
"integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.0",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.28.0",
"@babel/template": "^7.27.2",
"@babel/types": "^7.28.0",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-globals": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7",
"debug": "^4.3.1"
},
"engines": {
@@ -1523,13 +1527,14 @@
}
},
"node_modules/@babel/types": {
"version": "7.28.1",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz",
"integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1"
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -1658,6 +1663,17 @@
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -2036,15 +2052,6 @@
"integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
"dev": true
},
"node_modules/@trysound/sax": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
"integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==",
"dev": true,
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2915,12 +2922,6 @@
"minimalistic-assert": "^1.0.0"
}
},
"node_modules/asn1.js/node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"dev": true
},
"node_modules/assert": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/assert/-/assert-1.5.1.tgz",
@@ -3069,7 +3070,8 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
@@ -3119,10 +3121,11 @@
}
},
"node_modules/bn.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz",
"integrity": "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==",
"dev": true
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.4.tgz",
"integrity": "sha512-QL7sb18rJ1PbdsKsqPA0guxL563vIMwRHgzNrW/uzQuRGN1Cjqd/wonUBAVqHox9KwzHA6vCbM0lXx3k4iQMow==",
"dev": true,
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.3",
@@ -3163,21 +3166,6 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"dev": true
},
"node_modules/body-parser/node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"dev": true,
"dependencies": {
"side-channel": "^1.0.6"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/bonjour-service": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz",
@@ -3201,10 +3189,11 @@
"dev": true
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -3730,7 +3719,8 @@
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/concat/node_modules/commander": {
"version": "2.20.3",
@@ -3858,12 +3848,6 @@
"elliptic": "^6.5.3"
}
},
"node_modules/create-ecdh/node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"dev": true
},
"node_modules/create-hash": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
@@ -4354,12 +4338,6 @@
"randombytes": "^2.0.0"
}
},
"node_modules/diffie-hellman/node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"dev": true
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -4557,12 +4535,6 @@
"minimalistic-crypto-utils": "^1.0.1"
}
},
"node_modules/elliptic/node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"dev": true
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -5218,21 +5190,6 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"dev": true
},
"node_modules/express/node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"dev": true,
"dependencies": {
"side-channel": "^1.0.6"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -5286,9 +5243,9 @@
"dev": true
},
"node_modules/fast-uri": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
"integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==",
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
"dev": true,
"funding": [
{
@@ -5299,7 +5256,8 @@
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
]
],
"license": "BSD-3-Clause"
},
"node_modules/fastest-levenshtein": {
"version": "1.0.16",
@@ -5492,15 +5450,16 @@
}
},
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"dev": true
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"dev": true,
"funding": [
{
@@ -5508,6 +5467,7 @@
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
@@ -6174,10 +6134,11 @@
}
},
"node_modules/http-proxy-middleware": {
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz",
"integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-proxy": "^1.17.8",
"http-proxy": "^1.18.1",
@@ -6325,10 +6286,11 @@
}
},
"node_modules/immutable": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz",
"integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==",
"dev": true
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz",
"integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==",
"dev": true,
"license": "MIT"
},
"node_modules/import-fresh": {
"version": "3.3.1",
@@ -6957,10 +6919,21 @@
"dev": true
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
@@ -7265,13 +7238,14 @@
}
},
"node_modules/launch-editor": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.10.0.tgz",
"integrity": "sha512-D7dBRJo/qcGX9xlvt/6wUYzQxjh5G1RvZPgPv8vi4KRU99DVQL/oW7tnVOCCTm2HGeo3C5HvGE5Yrh6UBoZ0vA==",
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz",
"integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picocolors": "^1.0.0",
"shell-quote": "^1.8.1"
"picocolors": "^1.1.1",
"shell-quote": "^1.8.4"
}
},
"node_modules/levn": {
@@ -7341,10 +7315,11 @@
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
@@ -7520,12 +7495,6 @@
"miller-rabin": "bin/miller-rabin"
}
},
"node_modules/miller-rabin/node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"dev": true
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
@@ -7620,10 +7589,11 @@
"dev": true
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -7660,9 +7630,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"dev": true,
"funding": [
{
@@ -7670,6 +7640,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@@ -7716,10 +7687,11 @@
"optional": true
},
"node_modules/node-forge": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
"integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==",
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
"dev": true,
"license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
"node": ">= 6.13.0"
}
@@ -7966,10 +7938,11 @@
}
},
"node_modules/on-headers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
"integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
@@ -8241,10 +8214,11 @@
"dev": true
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"dev": true
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"dev": true,
"license": "MIT"
},
"node_modules/path-type": {
"version": "4.0.0",
@@ -8310,10 +8284,11 @@
"dev": true
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
@@ -8395,9 +8370,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"dev": true,
"funding": [
{
@@ -8413,8 +8388,9 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -9090,12 +9066,6 @@
"safe-buffer": "^5.1.2"
}
},
"node_modules/public-encrypt/node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"dev": true
},
"node_modules/punycode": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
@@ -9103,12 +9073,14 @@
"dev": true
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
@@ -9685,6 +9657,16 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/sax": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/schema-utils": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz",
@@ -9785,12 +9767,13 @@
}
},
"node_modules/serialize-javascript": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"version": "7.0.7",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.7.tgz",
"integrity": "sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g==",
"dev": true,
"dependencies": {
"randombytes": "^2.1.0"
"license": "BSD-3-Clause",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/serve-index": {
@@ -9998,10 +9981,11 @@
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
"integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
@@ -10016,14 +10000,15 @@
"dev": true
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
@@ -10035,13 +10020,14 @@
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
@@ -10454,17 +10440,18 @@
}
},
"node_modules/svgo": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz",
"integrity": "sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==",
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.2.tgz",
"integrity": "sha512-TyzE4NVGLUFy+H/Uy4N6c3G0HEeprsVfge6Lmq+0FdQQ/zqoVYB62IsBZORsiL+o96s6ff/V6/3UQo/C0cgCAA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@trysound/sax": "0.2.0",
"commander": "^7.2.0",
"css-select": "^4.1.3",
"css-tree": "^1.1.3",
"csso": "^4.2.0",
"picocolors": "^1.0.0",
"sax": "^1.5.0",
"stable": "^0.1.8"
},
"bin": {
@@ -10986,12 +10973,17 @@
}
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/vary": {
@@ -11680,10 +11672,11 @@
"dev": true
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
@@ -11725,10 +11718,11 @@
"dev": true
},
"node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">= 6"
}
+28 -1
View File
@@ -26,7 +26,34 @@
"webpack-cli": "^6.0.1"
},
"dependencies": {
"select2": "^4.0.13",
"select2": "~4.0.13",
"sortablejs": "^1.15.0"
},
"overrides": {
"@babel/core": "^7.29.7",
"@babel/plugin-transform-modules-systemjs": "^7.29.7",
"bn.js": "^5.2.4",
"brace-expansion": "^1.1.16",
"fast-uri": "^3.1.3",
"flatted": "^3.4.2",
"follow-redirects": "^1.16.0",
"http-proxy-middleware": "^2.0.10",
"immutable": "^5.1.9",
"js-yaml": "^4.3.0",
"launch-editor": "^2.14.1",
"lodash": "^4.18.1",
"minimatch": "^3.1.5",
"node-forge": "^1.4.0",
"on-headers": "^1.1.0",
"path-to-regexp": "^0.1.13",
"picomatch": "^2.3.2",
"postcss": "^8.5.16",
"qs": "^6.15.3",
"serialize-javascript": "^7.0.7",
"shell-quote": "^1.9.0",
"svgo": "^2.8.2",
"uuid": "^11.1.1",
"ws": "^8.21.0",
"yaml": "^1.10.3"
}
}
+9 -9
View File
@@ -1,10 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd" bootstrap="vendor/autoload.php" colors="true">
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">./app</directory>
</include>
</coverage>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/12.5/phpunit.xsd" bootstrap="vendor/autoload.php" colors="true" cacheDirectory=".phpunit.cache">
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
@@ -17,12 +12,17 @@
<env name="APP_ENV" value="testing"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<!-- <env name="DB_CONNECTION" value="sqlite"/> -->
<!-- <env name="DB_DATABASE" value=":memory:"/> -->
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
</php>
<source>
<include>
<directory suffix=".php">./app</directory>
</include>
</source>
</phpunit>
+2184 -2
View File
File diff suppressed because one or more lines are too long
+4652 -1
View File
File diff suppressed because one or more lines are too long
-1
View File
File diff suppressed because one or more lines are too long
+2 -3
View File
@@ -1,5 +1,4 @@
{
"/js/dummy.js": "/js/dummy.js?id=daec5f3b283a510837bec36ca3868a54",
"/css/app.css": "/css/app.css?id=8e5c9ae35dd160a37c9d33d663f996b9",
"/js/app.js": "/js/app.js?id=19052619246fec368cad13937c62d850"
"/css/app.css": "/css/app.css?id=271cb5f5a1f91d0a6dfbc65e374ffc14",
"/js/app.js": "/js/app.js?id=2ebeb753597d1cbbf88d8bc652e4af5b"
}
+1 -1
View File
@@ -34,7 +34,7 @@ Supported applications are recognized by the title of the application as entered
[![foundationapps](https://img.shields.io/badge/dynamic/json.svg?label=Foundation%20Apps&url=https%3A%2F%2Fapps.heimdall.site%2Fstats&query=foundation_apps&colorB=3f8483&style=for-the-badge&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAjCAMAAACw/5reAAAAnFBMVEUAAADu7u7u7u7u7u7u7u7x8fHu7u7u7u7u7u7u7u7u7u7u7u7r6+vu7u7v7+/u7u7t7e3v7+/v7+/u7u7u7u7u7u7u7u7u7u7u7u7u7u7v7+/u7u7p6ent7e3v7+/v7+/v7+/u7u7u7u7u7u7u7u7t7e3////u7u7u7u7u7u7u7u7w8PDw8PDt7e3u7u7t7e3s7Ozu7u7t7e3u7u4TnCP6AAAAM3RSTlMA+9n3phHw3czC088M5Y5zG6mflWdJFumyfj4sB2NeTi7hiWlDOQPGt5lsMiG9hFQntpFqxQJtAAABnElEQVQoz2WRh3KrQAxFtYWO6ZhucItrynv6/3/LFnA24c6wurpnYBkJZvXduNix6+GXTo8qWnxUPU4m2w0O1ktTozPsftiZpejGlm7C2MWUnRcWOohIo36+PaKyDZdLUOgDXvqQfaT9kwkfvP3AN18E7Kl8hkJHMHSXSSadxaTtTNjJhMkfjFHKMqGlolg4T7mtCbcq8gBCotxkwklFLIQSlQoTHnVWQqzNxYQuzpfmqGVMc5ijHK5yAuIhxbZ5p/S92RZkjv5BKs6aosSIr0JrcXBo1FtICVINKRKK6u0GnraoN84O5KbhjRwYzxCJnQCMtotkdNxjq2F7dJ2RoGuXIBTvc3ROthdmat6hZ7cOyfcxKGV+wTxBkxQxTQTzWOFny/7qS2nzx37T7nbtZj9xu7zUr/323nVy0sQnhwMJktSZrl5v7CjgSQmWi+haUCY8sH4tyc/FGSKGouS+WqBJm8U2NIE/+nLu2tzpF/xVNGy02QzRClafC/ysVpDzQJuA8xXsKl8bv+pgpXz57H9Yy3J1lQNY62wUrW+mdzrylWS0QwAAAABJRU5ErkJggg==)](https://apps.heimdall.site/applications/foundation)
## Installing
Apart from the Laravel 10 dependencies, namely PHP >= 8.1, Ctype PHP Extension, cURL PHP Extension, DOM PHP Extension, Fileinfo PHP Extension, Filter PHP Extension, Hash PHP Extension, Mbstring PHP Extension, OpenSSL PHP Extension, PCRE PHP Extension, PDO PHP Extension, Session PHP Extension, Tokenizer PHP Extension, XML PHP Extension, the only other thing Heimdall needs is sqlite support and zip support (php-zip).
Apart from the Laravel 11 dependencies, namely PHP >= 8.4, Ctype PHP Extension, cURL PHP Extension, DOM PHP Extension, Fileinfo PHP Extension, Filter PHP Extension, Hash PHP Extension, Mbstring PHP Extension, OpenSSL PHP Extension, PCRE PHP Extension, PDO PHP Extension, Session PHP Extension, Tokenizer PHP Extension, XML PHP Extension, the only other thing Heimdall needs is sqlite support and zip support (php-zip).
If you find you can't change the background make sure `php_fileinfo` is enabled in your php.ini. I believe `php_fileinfo` should be enabled by default, but one user came across the issue on a windows system.
+116 -2
View File
@@ -108,11 +108,90 @@ $.when($.ready).then(() => {
}
});
// Autocomplete functionality
let autocompleteTimeout = null;
let currentAutocompleteRequest = null;
function hideAutocomplete() {
$("#search-autocomplete").remove();
}
function showAutocomplete(suggestions, inputElement) {
hideAutocomplete();
if (!suggestions || suggestions.length === 0) {
return;
}
const $input = $(inputElement);
const position = $input.position();
const width = $input.outerWidth();
const $autocomplete = $('<div id="search-autocomplete"></div>');
suggestions.forEach((suggestion) => {
const $item = $('<div class="autocomplete-item"></div>')
.text(suggestion)
.on("click", () => {
$input.val(suggestion);
hideAutocomplete();
$input.closest("form").submit();
});
$autocomplete.append($item);
});
$autocomplete.css({
position: "absolute",
top: `${position.top + $input.outerHeight()}px`,
left: `${position.left}px`,
width: `${width}px`,
});
$input.closest("#search-container").append($autocomplete);
}
function fetchAutocomplete(query, provider) {
// Cancel previous request if any
if (currentAutocompleteRequest) {
currentAutocompleteRequest.abort();
}
if (!query || query.trim().length < 2) {
hideAutocomplete();
return;
}
currentAutocompleteRequest = $.ajax({
url: `${base}search/autocomplete`,
method: "GET",
data: {
q: query,
provider,
},
success(data) {
const inputElement = $("#search-container input[name=q]")[0];
showAutocomplete(data, inputElement);
},
error() {
hideAutocomplete();
},
complete() {
currentAutocompleteRequest = null;
},
});
}
$("#search-container")
.on("input", "input[name=q]", function () {
const search = this.value;
const items = $("#sortable").find(".item-container");
if ($("#search-container select[name=provider]").val() === "tiles") {
// Get provider from either select or hidden input
const provider =
$("#search-container select[name=provider]").val() ||
$("#search-container input[name=provider]").val();
if (provider === "tiles") {
hideAutocomplete();
if (search.length > 0) {
items.hide();
items
@@ -126,6 +205,12 @@ $.when($.ready).then(() => {
}
} else {
items.show();
// Debounce autocomplete requests
clearTimeout(autocompleteTimeout);
autocompleteTimeout = setTimeout(() => {
fetchAutocomplete(search, provider);
}, 300);
}
})
.on("change", "select[name=provider]", function () {
@@ -147,9 +232,24 @@ $.when($.ready).then(() => {
} else {
$("#search-container button").show();
items.show();
hideAutocomplete();
}
});
// Hide autocomplete when clicking outside
$(document).on("click", (e) => {
if (!$(e.target).closest("#search-container").length) {
hideAutocomplete();
}
});
// Hide autocomplete on Escape key
$(document).on("keydown", (e) => {
if (e.key === "Escape") {
hideAutocomplete();
}
});
$("#search-container select[name=provider]").trigger("change");
$("#app")
@@ -210,7 +310,12 @@ $.when($.ready).then(() => {
data.url = apiurl;
$(".config-item").each(function () {
const config = $(this).data("config");
data[config] = $(this).val();
// For checkboxes, use checked state instead of value attribute
if ($(this).is(":checkbox")) {
data[config] = $(this).is(":checked") ? "1" : "0";
} else {
data[config] = $(this).val();
}
});
data.id = $("form[data-item-id]").data("item-id");
@@ -234,6 +339,15 @@ $.when($.ready).then(() => {
);
});
});
// Auto-select the configured default tag on load (tags mode only)
const taglist = document.getElementById("taglist");
if (taglist !== null) {
const defaultTag = taglist.getAttribute("data-default-tag");
if (typeof defaultTag === "string" && defaultTag !== "") {
$(`#taglist .tag[data-tag="tag-${defaultTag}"]`).trigger("click");
}
}
$("#pinlist").on("click", "a", function (e) {
e.preventDefault();
const current = $(this);
+11 -2
View File
@@ -60,7 +60,7 @@ const getCSRFToken = () => {
*/
const mergeItemWithAppDetails = (item, appDetails) => ({
pinned: 1,
tags: [0],
tags: Array.isArray(item.tags) && item.tags.length ? item.tags : [0],
appid: item.appid,
title: item.title,
@@ -92,7 +92,16 @@ const fetchAppDetails = (appId) => {
"Content-Type": "application/json",
},
body: JSON.stringify({ app: appId }),
}).then((response) => response.json());
}).then((response) => {
// A missing app now returns a genuine 404 (see ItemController::appload).
// fetch() does not reject on 4xx, so surface it as a rejection here to
// keep importItems reporting "Failed to find app id" rather than treating
// the {"error":...} body as a successful import.
if (!response.ok) {
return Promise.reject(new Error(`Failed to find app id: ${appId}`));
}
return response.json();
});
};
/**
+49 -3
View File
@@ -722,9 +722,10 @@ div.create {
flex: 0 0 60px;
}
.app-icon {
max-width: 60px;
width: 60px;
height: 60px;
object-fit: contain;
display: block;
max-height: 60px;
}
.sidenav {
@@ -926,6 +927,12 @@ div.create {
max-width: 620px;
position: relative;
z-index: 4;
// Reduce width when there's no select dropdown (only has hidden input)
&:has(input[name="provider"][type="hidden"]) {
max-width: 520px;
}
form {
width: 100%;
}
@@ -933,7 +940,6 @@ div.create {
background: white;
border-radius: 5px;
box-shadow: 0px 0px 5px 0 rgba(0,0,0,0.4);
overflow: hidden;
position: relative;
display: flex;
@@ -945,6 +951,11 @@ div.create {
width: 100%;
background: transparent;
}
// When there's no select dropdown, round the input's left corners
input[name="q"]:first-child {
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
}
button {
position: absolute;
right: 0px;
@@ -965,7 +976,42 @@ div.create {
background: #f5f5f5;
border: none;
border-right: 1px solid #ddd;
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
}
// When select exists, remove input's left border radius
select ~ input[name="q"] {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
#search-autocomplete {
position: absolute;
z-index: 1000;
background: white;
border: 1px solid #ddd;
border-top: none;
border-radius: 0 0 5px 5px;
box-shadow: 0px 4px 8px 0 rgba(0,0,0,0.2);
max-height: 300px;
overflow-y: auto;
.autocomplete-item {
padding: 12px 15px;
cursor: pointer;
font-size: 15px;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.2s ease;
&:last-child {
border-bottom: none;
}
&:hover {
background-color: #f5f5f5;
}
}
}
.ui-autocomplete {
+2 -1
View File
@@ -1,6 +1,7 @@
<section class="module-container">
@if($enable_auth_admin_controls)
<header>
{{ html()->hidden('app_id', $item->id) }}
<div class="section-title">{{ __('app.apps.preview') }}</div>
<div class="module-actions">
<div class="toggleinput">
@@ -120,7 +121,7 @@
@if(isset($item) && $item->enhanced())
<div id="sapconfig" style="display: block;">
@if(isset($item))
@if(isset($item) && $item->class)
@include('SupportedApps::'.App\Item::nameFromClass($item->class).'.config')
@endif
</div>
+17 -1
View File
@@ -171,13 +171,14 @@
})
function appload(appvalue) {
const itemId = $('input[name="item_id"]').val();
if(appvalue == 'null') {
$('#sapconfig').html('').hide();
$('#tile-preview .app-icon').attr('src', '/img/heimdall-icon-small.png');
$('#appimage').html("<img src='/img/heimdall-icon-small.png' />");
$('#sapconfig').html('').hide();
} else {
$.post('{{ route('appload') }}', { app: appvalue }, function(data) {
$.post('{{ route('appload') }}', { app: appvalue, item_id: itemId }, function(data) {
// Main details
$('#appimage').html("<img src='"+data.iconview+"' /><input type='hidden' name='icon' value='"+data.iconview+"' />");
$('input[name=colour]').val(data.colour);
@@ -194,6 +195,21 @@
if(data.custom != null) {
$.get(base+'view/'+data.custom, function(getdata) {
$('#sapconfig').html(getdata).show();
// Populate fields in the loaded form with description data
if (data.description) {
const description = JSON.parse(data.appvalue);
Object.keys(description).forEach(function(key) {
const value = description[key];
const field = $(`#sapconfig [name="config[${key}]"]`);
if (field.length) {
if (field.is(':checkbox')) {
field.prop('checked', value);
} else {
field.val(value);
}
}
});
}
});
} else {
$('#sapconfig').html('').hide();
+1 -1
View File
@@ -3,7 +3,7 @@ $treat_tags_as = \App\Setting::fetch('treat_tags_as');
?>
@if( $treat_tags_as == 'tags')
@if($taglist->first())
<div id="taglist" class="taglist">
<div id="taglist" class="taglist" data-default-tag="{{ \App\Setting::fetch('default_tag') }}">
<div class="tag white current" data-tag="all">All</div>
@foreach($taglist as $tag)
<div class="tag link{{ title_color($tag->colour) }}" style="background-color: {{ $tag->colour }}" data-tag="tag-{{ $tag->tag_url }}">{{ $tag->title }}</div>
+12 -1
View File
@@ -1,7 +1,18 @@
<section class="module-container">
@if($enable_auth_admin_controls)
<header>
<div class="section-title">{{ __($setting->label) }}</div>
<div class="section-title">
{{ __($setting->label) }}
@if($setting->type === 'image')
@php
$max_upload = ini_get('upload_max_filesize');
$max_upload_bytes = parse_size($max_upload);
@endphp
<a class="settinglink" target="_blank" rel="nofollow noreferer" href="https://github.com/linuxserver/Heimdall?tab=readme-ov-file#new-background-image-not-being-set">({{ format_bytes($max_upload_bytes, false) }})</a>
@endif
</div>
<div class="module-actions">
<button type="submit"class="button"><i class="fa fa-save"></i><span>{{ __('app.buttons.save') }}</span></button>
<a href="{{ route('settings.index', []) }}" class="button"><i class="fa fa-ban"></i><span>{{ __('app.buttons.cancel') }}</span></a>
+1
View File
@@ -75,6 +75,7 @@ Route::post('test_config', [ItemController::class,'testConfig'])->name('test_con
Route::get('get_stats/{id}', [ItemController::class,'getStats'])->name('get_stats');
Route::get('/search', [SearchController::class,'index'])->name('search');
Route::get('/search/autocomplete', [SearchController::class,'autocomplete'])->name('search.autocomplete');
Route::get('view/{name_view}', function ($name_view) {
return view('SupportedApps::'.$name_view)->render();
+3
View File
@@ -18,6 +18,7 @@ bing:
method: get
target: _blank
query: q
autocomplete: https://api.bing.com/osjson.aspx?query={query}
ddg:
id: ddg
@@ -26,6 +27,7 @@ ddg:
method: get
target: _blank
query: q
autocomplete: https://duckduckgo.com/ac/?q={query}&type=list
google:
id: google
@@ -34,6 +36,7 @@ google:
method: get
target: _blank
query: q
autocomplete: https://suggestqueries.google.com/complete/search?output=toolbar&hl=en&q={query}
startpage:
id: startpage
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace Tests\Feature;
use App\Item;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* End-to-end coverage for the AJAX POST endpoints that the dashboard relies
* on (the routes excluded from CSRF verification: order / appload).
*
* CSRF itself is disabled while running unit tests, so these tests deliberately
* do NOT assert "an un-tokened POST succeeds" (that would be meaningless).
* Instead they exercise the controller + routing end-to-end with realistic
* input and assert the real, observable behaviour, which is what would break
* if the controller or router regressed on a framework upgrade.
*/
class AjaxPostEndpointsTest extends TestCase
{
use RefreshDatabase;
public function test_order_endpoint_persists_the_new_item_order(): void
{
$this->seed();
$first = Item::factory()->create(['order' => 5]);
$second = Item::factory()->create(['order' => 9]);
// POST the ids in reverse: index 0 => $second, index 1 => $first.
$response = $this->post('/order', [
'order' => [$second->id, $first->id],
]);
$response->assertStatus(200);
$this->assertSame(0, (int) $second->fresh()->order);
$this->assertSame(1, (int) $first->fresh()->order);
}
public function test_appload_returns_null_for_the_none_selection(): void
{
$this->seed();
$response = $this->post('/appload', ['app' => 'null']);
$response->assertStatus(200);
$this->assertSame('', $response->getContent());
}
public function test_appload_surfaces_a_not_found_error_for_an_unknown_app(): void
{
$this->seed();
$response = $this->post('/appload', ['app' => 'this-app-does-not-exist']);
// For an unknown app the controller returns a genuine 404 JSON
// response. appload() is declared to return
// JsonResponse|string|null, so the JsonResponse is served as-is
// (correct status + JSON body) rather than being coerced through
// Response::__toString() into a raw HTTP message served as a 200.
$response->assertStatus(404);
$response->assertExactJson(['error' => 'Application not found.']);
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace Tests\Feature;
use App\Http\Controllers\ItemController;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Support\Facades\Route;
use ReflectionClass;
use Tests\TestCase;
/**
* Guards the CSRF configuration wired up in bootstrap/app.php.
*
* Laravel's request-forgery middleware self-disables while running unit tests,
* so we cannot observe CSRF at request time. Instead we assert the *configuration*
* directly: the three excluded URIs really are registered on the framework's
* PreventRequestForgery middleware, and the routes those exceptions cover still
* resolve to the expected controller actions. Either check would fail if a
* framework upgrade renamed / deprecated the exception API (validateCsrfTokens()
* now proxies preventRequestForgery()) or changed how the except list is stored,
* or if the AJAX routes were dropped.
*/
class CsrfExceptionsTest extends TestCase
{
/**
* @return string[]
*/
private function csrfExceptUris(): array
{
// The except list is stored in the protected static $neverVerify
// property that Middleware::validateCsrfTokens(except: [...]) feeds.
$reflection = new ReflectionClass(PreventRequestForgery::class);
$property = $reflection->getProperty('neverVerify');
$property->setAccessible(true);
return (array) $property->getValue();
}
public function test_ajax_routes_are_registered_as_csrf_exceptions(): void
{
$except = $this->csrfExceptUris();
$this->assertContains('order', $except);
$this->assertContains('appload', $except);
$this->assertContains('test_config', $except);
}
public function test_csrf_excepted_routes_resolve_to_the_expected_actions(): void
{
$expected = [
'items.order' => ['order', 'setOrder'],
'appload' => ['appload', 'appload'],
'test_config' => ['test_config', 'testConfig'],
];
foreach ($expected as $name => [$uri, $method]) {
$route = Route::getRoutes()->getByName($name);
$this->assertNotNull($route, "Route [{$name}] is not registered.");
$this->assertSame($uri, $route->uri());
$this->assertContains('POST', $route->methods());
$this->assertSame(ItemController::class . '@' . $method, $route->getActionName());
}
}
}
+22
View File
@@ -4,6 +4,7 @@ namespace Tests\Feature;
use App\Item;
use App\ItemTag;
use App\Setting;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@@ -80,4 +81,25 @@ class DashTest extends TestCase
$response->assertSee('Tag 1');
$response->assertSee('Tag 2');
}
public function test_dash_exposes_the_configured_default_tag(): void
{
$this->seed();
Setting::where('key', 'treat_tags_as')->update(['value' => 'tags']);
Setting::where('key', 'default_tag')->update(['value' => 'home-dashboard']);
Item::factory()->create([
'title' => 'Home',
'url' => 'home-dashboard',
'type' => 1,
'pinned' => 1,
'user_id' => 0,
]);
$response = $this->get('/');
$response->assertStatus(200);
$response->assertSee('data-default-tag="home-dashboard"', false);
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace Tests\Feature;
use App\Item;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Fixture app whose live stats rendering throws, mirroring the Komga
* failure from issue #1558 (broken remote blade / upstream API error).
*/
class ThrowingStatApp
{
public $config;
public function livestats()
{
throw new \Exception('boom');
}
}
/**
* Fixture app whose live stats rendering succeeds and returns the JSON
* string the frontend expects.
*/
class HappyStatApp
{
public $config;
public function livestats()
{
return json_encode(['status' => 'active', 'html' => '<b>ok</b>']);
}
}
class GetStatsTest extends TestCase
{
use RefreshDatabase;
public function test_missing_item_id_does_not_500(): void
{
$response = $this->get('get_stats/999999');
$response->assertStatus(200);
$response->assertJson(['status' => 'inactive', 'html' => '']);
}
public function test_throwing_app_degrades_gracefully(): void
{
$item = Item::factory()->create([
'class' => ThrowingStatApp::class,
]);
$response = $this->get('get_stats/'.$item->id);
$response->assertStatus(200);
$response->assertJson(['status' => 'inactive', 'html' => '']);
}
public function test_item_with_no_class_degrades_gracefully(): void
{
$item = Item::factory()->create([
'class' => null,
]);
$response = $this->get('get_stats/'.$item->id);
$response->assertStatus(200);
$response->assertJson(['status' => 'inactive', 'html' => '']);
}
public function test_happy_path_returns_livestats_output_verbatim(): void
{
$item = Item::factory()->create([
'class' => HappyStatApp::class,
]);
$expected = json_encode(['status' => 'active', 'html' => '<b>ok</b>']);
$response = $this->get('get_stats/'.$item->id);
$response->assertStatus(200);
$this->assertSame($expected, $response->getContent());
$response->assertJson(['status' => 'active', 'html' => '<b>ok</b>']);
}
}
+35 -1
View File
@@ -5,6 +5,7 @@ namespace Tests\Feature;
use App\Item;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class ItemExportTest extends TestCase
@@ -34,7 +35,40 @@ class ItemExportTest extends TestCase
$response = $this->get('api/item');
$response->assertExactJson([(object)$exampleItem]);
$response->assertExactJson([$exampleItem + ["tags" => []]]);
}
public function test_exports_assigned_tag_titles_excluding_the_root_tag(): void
{
// Mirror the root/default dashboard row that production seeds (id 0),
// so the item_tag pivot's foreign key to items.id is satisfied on a
// fresh in-memory database.
DB::table('items')->insert([
'id' => 0,
'title' => 'app.dashboard',
'url' => '',
'type' => 1,
'user_id' => 0,
'pinned' => 0,
]);
$item = Item::factory()
->create([
'title' => 'Tagged Item',
]);
$tag = Item::factory()
->create([
'type' => 1,
'title' => 'Media',
]);
// Assign both the root/default dashboard (id 0) and the Media tag.
$item->parents()->sync([0, $tag->id]);
$response = $this->get('api/item');
$response->assertJsonCount(1);
$response->assertJsonPath('0.tags', ['Media']);
}
public function test_returns_all_items(): void
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace Tests\Feature;
use App\Item;
use App\ItemTag;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ItemImportTest extends TestCase
{
use RefreshDatabase;
/**
* @return array<string, mixed>
*/
private function importPayload(array $overrides = []): array
{
return array_merge([
'pinned' => 1,
'appid' => 'null',
'website' => null,
'title' => 'Item A',
'colour' => '#00f',
'url' => 'http://10.0.1.1',
'tags' => [0],
], $overrides);
}
public function test_import_creates_and_assigns_a_tag_from_its_title(): void
{
$this->seed();
$response = $this->postJson('api/item', $this->importPayload([
'title' => 'Item A',
'tags' => ['Media'],
]));
$response->assertStatus(200);
$response->assertJson(['status' => 'OK']);
$tag = Item::where('type', 1)->where('title', 'Media')->first();
$this->assertNotNull($tag);
$this->assertSame(1, (int) $tag->type);
$item = Item::where('type', 0)->where('title', 'Item A')->first();
$this->assertNotNull($item);
$this->assertTrue(
ItemTag::where('item_id', $item->id)->where('tag_id', $tag->id)->exists()
);
}
public function test_import_reuses_an_existing_tag_for_the_same_title(): void
{
$this->seed();
$this->postJson('api/item', $this->importPayload([
'title' => 'Item A',
'tags' => ['Media'],
]))->assertStatus(200);
$this->postJson('api/item', $this->importPayload([
'title' => 'Item B',
'tags' => ['Media'],
]))->assertStatus(200);
$this->assertSame(
1,
Item::where('type', 1)->where('title', 'Media')->count()
);
$tag = Item::where('type', 1)->where('title', 'Media')->first();
$itemA = Item::where('type', 0)->where('title', 'Item A')->first();
$itemB = Item::where('type', 0)->where('title', 'Item B')->first();
$this->assertTrue(
ItemTag::where('item_id', $itemA->id)->where('tag_id', $tag->id)->exists()
);
$this->assertTrue(
ItemTag::where('item_id', $itemB->id)->where('tag_id', $tag->id)->exists()
);
}
public function test_import_with_root_tag_only_creates_no_tags(): void
{
$this->seed();
$response = $this->postJson('api/item', $this->importPayload([
'title' => 'Item A',
'tags' => [0],
]));
$response->assertStatus(200);
// No stray tag items should have been created beyond the seeded
// root/default dashboard tag (id 0).
$this->assertSame(0, Item::where('type', 1)->where('id', '>', 0)->count());
// The item should be assigned to the root/default dashboard (tag id 0).
$item = Item::where('type', 0)->where('title', 'Item A')->first();
$this->assertNotNull($item);
$this->assertTrue(
ItemTag::where('item_id', $item->id)->where('tag_id', 0)->exists()
);
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
namespace Tests\Feature;
use App\Item;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ItemOwnershipTest extends TestCase
{
use RefreshDatabase;
/**
* Create a passwordless user (so the "allowed" middleware lets the
* request through) and make it the current session user.
*/
private function actAsCurrentUser(array $attributes = []): User
{
$user = User::factory()->create(array_merge([
'password' => null,
'public_front' => 1,
], $attributes));
$this->withSession(['current_user' => $user]);
return $user;
}
public function test_creating_an_item_assigns_the_creator_as_owner(): void
{
$this->seed();
$creator = $this->actAsCurrentUser();
$response = $this->post('/items', [
'pinned' => 1,
'appid' => 'null',
'website' => null,
'title' => 'Owned Item',
'colour' => '#00f',
'url' => 'http://10.0.1.1',
'tags' => [0],
]);
$response->assertStatus(302);
$item = Item::withoutGlobalScopes()->where('title', 'Owned Item')->first();
$this->assertNotNull($item);
$this->assertSame($creator->id, (int) $item->user_id);
}
public function test_creating_an_item_ignores_a_crafted_user_id(): void
{
$this->seed();
$creator = $this->actAsCurrentUser();
$other = User::factory()->create();
$response = $this->post('/items', [
'pinned' => 1,
'appid' => 'null',
'title' => 'Crafted Owner Item',
'colour' => '#00f',
'url' => 'http://10.0.1.2',
'user_id' => $other->id, // attempt to create on behalf of another user
'tags' => [0],
]);
$response->assertStatus(302);
$item = Item::withoutGlobalScopes()->where('title', 'Crafted Owner Item')->first();
$this->assertNotNull($item);
$this->assertSame($creator->id, (int) $item->user_id);
}
public function test_updating_a_shared_item_does_not_change_its_owner(): void
{
$this->seed();
// Attacker is a different logged-in user.
$attacker = $this->actAsCurrentUser();
// A shared item (user_id = 0) is visible to every user.
$item = Item::factory()->create([
'title' => 'Shared Item',
'user_id' => 0,
]);
$response = $this->patch('/items/'.$item->id, [
'appid' => 'null',
'title' => 'Shared Item Edited',
'url' => 'http://example.test',
'user_id' => $attacker->id, // crafted mass-assignment attempt
'tags' => [0],
]);
$response->assertRedirect(route('dash'));
$fresh = Item::withoutGlobalScopes()->find($item->id);
// Ownership is unchanged despite the crafted user_id field...
$this->assertSame(0, (int) $fresh->user_id);
// ...but the rest of the edit still applied.
$this->assertSame('Shared Item Edited', $fresh->title);
}
public function test_updating_an_owned_item_does_not_change_its_owner(): void
{
$this->seed();
$owner = $this->actAsCurrentUser();
$item = Item::factory()->create([
'title' => 'Owned Item',
'user_id' => $owner->id,
]);
$response = $this->patch('/items/'.$item->id, [
'appid' => 'null',
'title' => 'Owned Item Edited',
'url' => 'http://example.test',
'user_id' => 999, // crafted mass-assignment attempt
'tags' => [0],
]);
$response->assertRedirect(route('dash'));
$fresh = Item::withoutGlobalScopes()->find($item->id);
$this->assertSame($owner->id, (int) $fresh->user_id);
$this->assertSame('Owned Item Edited', $fresh->title);
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace Tests\Feature;
use App\Item;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class OrphanItemRecoveryTest extends TestCase
{
use RefreshDatabase;
/**
* RefreshDatabase already runs every migration up-front, so the orphans
* are created afterwards and the data migration is invoked directly.
*/
public function test_reassigns_orphaned_items_but_leaves_shared_and_valid_items(): void
{
$this->seed();
// A surviving, valid owner.
$validOwner = User::factory()->create();
// Orphan: user_id references a user that does not exist.
$orphan = Item::factory()->create([
'title' => 'Orphaned Item',
'user_id' => 999,
]);
// Shared items (user_id = 0) must never be reassigned.
$shared = Item::factory()->create([
'title' => 'Shared Item',
'user_id' => 0,
]);
// A validly-owned item must be left untouched.
$valid = Item::factory()->create([
'title' => 'Valid Item',
'user_id' => $validOwner->id,
]);
$migration = include database_path('migrations/2026_07_09_120000_reassign_orphaned_items.php');
$migration->up();
// Orphan reassigned to the surviving admin (id 1).
$this->assertSame(1, (int) Item::withoutGlobalScopes()->find($orphan->id)->user_id);
// Shared and valid rows unchanged.
$this->assertSame(0, (int) Item::withoutGlobalScopes()->find($shared->id)->user_id);
$this->assertSame($validOwner->id, (int) Item::withoutGlobalScopes()->find($valid->id)->user_id);
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Broad "the app still boots and renders on this Laravel version" safety net.
*
* Beyond the focused per-feature tests, this walks the main GET surface of the
* app in one place and asserts every route returns its expected status with no
* exception. A framework/PHP upgrade that broke view rendering, routing, the
* auth scaffolding or the auth middleware would surface here as a 500 / wrong
* status even if a more specific test was missing.
*/
class RoutesRenderTest extends TestCase
{
use RefreshDatabase;
public function test_core_get_routes_boot_without_error(): void
{
$this->seed();
$routes = [
'/' => 200,
'/login' => 200,
'/userselect' => 200,
'/settings' => 200,
'/items' => 200,
'/items/create' => 200,
'/tags' => 200,
'/health' => 200,
'/up' => 200,
];
foreach ($routes as $uri => $expectedStatus) {
$response = $this->get($uri);
$this->assertSame(
$expectedStatus,
$response->getStatusCode(),
"GET {$uri} returned {$response->getStatusCode()}, expected {$expectedStatus}."
);
}
}
public function test_home_redirects_guests_to_login(): void
{
$this->seed();
// /home is behind the auth middleware; a guest must be redirected to
// the login route (redirectGuestsTo in bootstrap/app.php).
$response = $this->get('/home');
$response->assertRedirect(route('login'));
}
public function test_search_redirects_to_the_provider(): void
{
$this->seed();
$response = $this->get('/search?provider=google&q=heimdall');
$response->assertStatus(302);
}
}
-18
View File
@@ -32,22 +32,4 @@ class SearchTest extends TestCase
$response->assertStatus(404); // Assert that the response status is 404
}
public function test_search_page_without_query_parameter(): void
{
$provider = 'google'; // Example provider
$response = $this->get(route('search', ['provider' => $provider]));
$response->assertStatus(400); // Assert that the response status is 400 (Bad Request)
}
public function test_search_page_with_empty_query(): void
{
$provider = 'google'; // Example provider
$query = ''; // Empty search term
$response = $this->get(route('search', ['provider' => $provider, 'q' => $query]));
$response->assertStatus(400); // Assert that the response status is 400 (Bad Request)
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace Tests\Feature;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
/**
* Guards the filesystem configuration the icon / avatar upload paths depend on.
*
* config/filesystems.php explicitly pins the local disk root to
* storage_path('app'). Laravel 12 changed the default local root to
* storage_path('app/private'); if that pin were lost (or the framework's
* default disks stopped being merged in) every stored icon path would silently
* point at the wrong directory. These tests fail loudly if that happens.
*/
class StorageDiskTest extends TestCase
{
public function test_local_disk_resolves(): void
{
$this->assertInstanceOf(Filesystem::class, Storage::disk('local'));
}
public function test_public_disk_resolves(): void
{
// The public disk comes from the framework defaults merged over the
// app's partial config/filesystems.php.
$this->assertInstanceOf(Filesystem::class, Storage::disk('public'));
}
public function test_local_disk_root_is_pinned_to_storage_app(): void
{
$this->assertSame(storage_path('app'), config('filesystems.disks.local.root'));
// The resolved absolute path must live directly under storage/app,
// not the Laravel 12 storage/app/private default.
$this->assertSame(storage_path('app/icon.png'), Storage::disk('local')->path('icon.png'));
}
public function test_public_disk_root_is_storage_app_public(): void
{
$this->assertSame(storage_path('app/public'), config('filesystems.disks.public.root'));
}
public function test_public_disk_supports_a_put_exists_get_round_trip(): void
{
Storage::fake('public');
$contents = 'icon-bytes';
Storage::disk('public')->put('icons/test.png', $contents);
$this->assertTrue(Storage::disk('public')->exists('icons/test.png'));
$this->assertSame($contents, Storage::disk('public')->get('icons/test.png'));
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\TrustHosts;
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
use Tests\TestCase;
class TrustHostsTest extends TestCase
{
/**
* Remove any TRUSTED_HOSTS override and reset Symfony's static trusted host
* state so tests do not leak into one another.
*/
protected function tearDown(): void
{
putenv('TRUSTED_HOSTS');
unset($_ENV['TRUSTED_HOSTS'], $_SERVER['TRUSTED_HOSTS']);
Request::setTrustedHosts([]);
parent::tearDown();
}
private function setTrustedHostsEnv(string $value): void
{
putenv('TRUSTED_HOSTS='.$value);
$_ENV['TRUSTED_HOSTS'] = $value;
$_SERVER['TRUSTED_HOSTS'] = $value;
}
private function makeMiddleware(): TrustHosts
{
return $this->app->make(TrustHosts::class);
}
public function test_hosts_is_empty_when_env_unset(): void
{
putenv('TRUSTED_HOSTS');
unset($_ENV['TRUSTED_HOSTS'], $_SERVER['TRUSTED_HOSTS']);
$this->assertSame([], $this->makeMiddleware()->hosts());
}
public function test_arbitrary_host_is_accepted_when_env_unset(): void
{
putenv('TRUSTED_HOSTS');
unset($_ENV['TRUSTED_HOSTS'], $_SERVER['TRUSTED_HOSTS']);
// No trusted host patterns configured -> getHost() must not throw.
Request::setTrustedHosts(array_filter($this->makeMiddleware()->hosts()));
$request = Request::create('http://anything.example/', 'GET');
$this->assertSame('anything.example', $request->getHost());
}
public function test_hosts_contains_pattern_matching_configured_host(): void
{
$this->setTrustedHostsEnv('example.com');
$hosts = $this->makeMiddleware()->hosts();
$this->assertNotEmpty($hosts);
$this->assertCount(1, $hosts);
// Symfony wraps each pattern as {pattern}i before matching.
$this->assertSame(1, preg_match('{'.$hosts[0].'}i', 'example.com'));
$this->assertSame(0, preg_match('{'.$hosts[0].'}i', 'evil.com'));
}
public function test_configured_host_is_accepted_and_others_rejected(): void
{
$this->setTrustedHostsEnv('example.com');
Request::setTrustedHosts($this->makeMiddleware()->hosts());
$accepted = Request::create('http://example.com/', 'GET');
$this->assertSame('example.com', $accepted->getHost());
$this->expectException(SuspiciousOperationException::class);
Request::create('http://evil.com/', 'GET')->getHost();
}
public function test_multiple_hosts_can_be_configured(): void
{
$this->setTrustedHostsEnv('example.com, dash.example.org');
$hosts = $this->makeMiddleware()->hosts();
$this->assertCount(2, $hosts);
Request::setTrustedHosts($hosts);
$this->assertSame('example.com', Request::create('http://example.com/', 'GET')->getHost());
$this->assertSame('dash.example.org', Request::create('http://dash.example.org/', 'GET')->getHost());
}
public function test_custom_trust_hosts_middleware_is_registered_globally(): void
{
$globalMiddleware = $this->app->make(Kernel::class)->getGlobalMiddleware();
$this->assertContains(TrustHosts::class, $globalMiddleware);
$this->assertNotContains(\Illuminate\Http\Middleware\TrustHosts::class, $globalMiddleware);
}
public function test_handle_enforces_trusted_hosts_even_in_local_environment(): void
{
// The app runs as APP_ENV=local under the test runner; the parent
// middleware would skip enforcement entirely. Confirm handle() still
// applies the allow-list once TRUSTED_HOSTS is configured.
$this->setTrustedHostsEnv('example.com');
$request = Request::create('http://example.com/', 'GET');
$reachedNext = false;
$this->makeMiddleware()->handle($request, function ($req) use (&$reachedNext) {
$reachedNext = true;
return $req;
});
$this->assertTrue($reachedNext);
// The configured host is now accepted and any other Host is rejected.
$this->assertSame('example.com', Request::create('http://example.com/', 'GET')->getHost());
$this->expectException(SuspiciousOperationException::class);
Request::create('http://evil.com/', 'GET')->getHost();
}
public function test_handle_does_not_restrict_hosts_when_env_unset(): void
{
putenv('TRUSTED_HOSTS');
unset($_ENV['TRUSTED_HOSTS'], $_SERVER['TRUSTED_HOSTS']);
$request = Request::create('http://anything.example/', 'GET');
$this->makeMiddleware()->handle($request, fn ($req) => $req);
// No allow-list configured -> arbitrary hosts still accepted.
$this->assertSame('anything.example', Request::create('http://anything.example/', 'GET')->getHost());
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\TrustProxies;
use Illuminate\Http\Request;
use Tests\TestCase;
class TrustProxiesTest extends TestCase
{
/**
* Remove any TRUSTED_PROXIES override and reset Symfony's static trusted
* proxy/host state so tests do not leak into one another.
*/
protected function tearDown(): void
{
putenv('TRUSTED_PROXIES');
unset($_ENV['TRUSTED_PROXIES'], $_SERVER['TRUSTED_PROXIES']);
Request::setTrustedProxies([], Request::HEADER_X_FORWARDED_FOR);
Request::setTrustedHosts([]);
parent::tearDown();
}
private function setTrustedProxiesEnv(string $value): void
{
putenv('TRUSTED_PROXIES='.$value);
$_ENV['TRUSTED_PROXIES'] = $value;
$_SERVER['TRUSTED_PROXIES'] = $value;
}
private function readProtected(object $object, string $property): mixed
{
return (fn () => $this->{$property})->call($object);
}
public function test_x_forwarded_host_header_is_ignored(): void
{
$request = Request::create('http://localhost/', 'GET');
$request->server->set('REMOTE_ADDR', '10.0.0.1');
$request->headers->set('X-Forwarded-Host', 'evil.com');
$request->server->set('HTTP_X_FORWARDED_HOST', 'evil.com');
(new TrustProxies())->handle($request, fn ($req) => $req);
$this->assertSame('localhost', $request->getHost());
$this->assertNotSame('evil.com', $request->getHost());
}
public function test_headers_bitmask_excludes_forwarded_host(): void
{
$headers = $this->readProtected(new TrustProxies(), 'headers');
$this->assertSame(0, $headers & Request::HEADER_X_FORWARDED_HOST);
$this->assertNotSame(0, $headers & Request::HEADER_X_FORWARDED_FOR);
$this->assertNotSame(0, $headers & Request::HEADER_X_FORWARDED_PORT);
$this->assertNotSame(0, $headers & Request::HEADER_X_FORWARDED_PROTO);
$this->assertNotSame(0, $headers & Request::HEADER_X_FORWARDED_AWS_ELB);
}
public function test_default_trusted_proxies_when_env_unset(): void
{
putenv('TRUSTED_PROXIES');
unset($_ENV['TRUSTED_PROXIES'], $_SERVER['TRUSTED_PROXIES']);
$proxies = $this->readProtected(new TrustProxies(), 'proxies');
$this->assertSame(
['192.168.0.0/16', '172.16.0.0/12', '10.0.0.0/8', '127.0.0.1'],
$proxies
);
}
public function test_trusted_proxies_can_be_configured_via_env(): void
{
$this->setTrustedProxiesEnv('203.0.113.5, 198.51.100.0/24');
$proxies = $this->readProtected(new TrustProxies(), 'proxies');
$this->assertSame(['203.0.113.5', '198.51.100.0/24'], $proxies);
}
public function test_trusted_proxies_supports_wildcard(): void
{
$this->setTrustedProxiesEnv('*');
$proxies = $this->readProtected(new TrustProxies(), 'proxies');
$this->assertSame('*', $proxies);
}
public function test_wildcard_proxy_trusts_calling_ip_for_forwarded_headers(): void
{
$this->setTrustedProxiesEnv('*');
$request = Request::create('http://localhost/', 'GET');
$request->server->set('REMOTE_ADDR', '203.0.113.9');
$request->headers->set('X-Forwarded-Proto', 'https');
$request->server->set('HTTP_X_FORWARDED_PROTO', 'https');
(new TrustProxies())->handle($request, fn ($req) => $req);
// Proto is honored (proxy trusted) but host is still not taken from headers.
$this->assertTrue($request->isSecure());
$this->assertSame('localhost', $request->getHost());
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace Tests\Feature;
use App\Item;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class UserDeleteItemsTest extends TestCase
{
use RefreshDatabase;
public function test_deleting_a_user_hard_deletes_only_that_users_items(): void
{
$this->seed();
// Admin (id 1, passwordless) is the only user allowed to manage users.
$this->withSession(['current_user' => User::find(1)]);
$victim = User::factory()->create();
// The victim's own tile and tag should both be hard-deleted.
$victimTile = Item::factory()->create([
'title' => 'Victim Tile',
'type' => 0,
'user_id' => $victim->id,
]);
$victimTag = Item::factory()->create([
'title' => 'Victim Tag',
'type' => 1,
'user_id' => $victim->id,
]);
// A shared item and another user's item must be left untouched.
$sharedItem = Item::factory()->create([
'title' => 'Shared Item',
'type' => 0,
'user_id' => 0,
]);
$adminItem = Item::factory()->create([
'title' => 'Admin Item',
'type' => 0,
'user_id' => 1,
]);
$response = $this->delete(route('users.destroy', $victim->id));
$response->assertRedirect(route('dash'));
// Victim and their items are gone entirely (force-deleted, not soft-deleted).
$this->assertDatabaseMissing('users', ['id' => $victim->id]);
$this->assertNull(Item::withoutGlobalScopes()->withTrashed()->find($victimTile->id));
$this->assertNull(Item::withoutGlobalScopes()->withTrashed()->find($victimTag->id));
// Shared and other users' items survive.
$this->assertNotNull(Item::withoutGlobalScopes()->find($sharedItem->id));
$this->assertNotNull(Item::withoutGlobalScopes()->find($adminItem->id));
}
public function test_user_id_one_cannot_be_deleted(): void
{
$this->seed();
$this->withSession(['current_user' => User::find(1)]);
$adminItem = Item::factory()->create([
'title' => 'Admin Item',
'type' => 0,
'user_id' => 1,
]);
$this->delete(route('users.destroy', 1));
// The admin and their items remain.
$this->assertDatabaseHas('users', ['id' => 1]);
$this->assertNotNull(Item::withoutGlobalScopes()->find($adminItem->id));
}
}
+38 -1
View File
@@ -6,5 +6,42 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
protected function setUp(): void
{
parent::setUp();
$this->guardAgainstRealDatabase();
}
/**
* Refuse to run the test suite against anything other than an
* in-memory SQLite database.
*
* The suite uses RefreshDatabase (migrate:fresh), which would wipe
* whatever database it is pointed at. The only supported test
* configuration for this repo is sqlite + ':memory:'. Any other
* connection (a real sqlite file, mysql, pgsql, ...) is rejected so
* we can never destroy real data.
*/
private function guardAgainstRealDatabase(): void
{
$default = config('database.default');
$driver = config("database.connections.{$default}.driver");
$database = config("database.connections.{$default}.database");
if ($driver === 'sqlite' && $database === ':memory:') {
return;
}
throw new \RuntimeException(sprintf(
'Refusing to run tests: the default database connection (%s) is '
. 'driver "%s" pointing at "%s". Tests only run against an '
. 'in-memory SQLite database (driver "sqlite", database ":memory:") '
. 'to avoid wiping real data via RefreshDatabase. Check phpunit.xml '
. 'DB_CONNECTION/DB_DATABASE overrides.',
$default,
$driver,
is_scalar($database) ? (string) $database : gettype($database)
));
}
}
@@ -2,11 +2,16 @@
namespace Tests\Unit\database\seeders;
use App\Item;
use App\Setting;
use Database\Seeders\SettingsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class SettingsSeederTest extends TestCase
{
use RefreshDatabase;
/**
* All language keys are defined in all languages based on the en language file.
*/
@@ -18,4 +23,60 @@ class SettingsSeederTest extends TestCase
$this->assertTrue(count($languageMap) === count($languageDirectories));
}
public function test_seeds_the_default_tag_setting(): void
{
$this->seed();
$setting = Setting::where('key', 'default_tag')->first();
$this->assertNotNull($setting);
$this->assertSame('select', $setting->type);
$this->assertSame(4, (int) $setting->group_id);
}
public function test_default_tag_edit_value_lists_all_tags_and_a_none_option(): void
{
$this->seed();
Item::factory()->create([
'title' => 'Home',
'url' => 'home-dashboard',
'type' => 1,
'pinned' => 1,
'user_id' => 0,
]);
Item::factory()->create([
'title' => 'Media',
'url' => 'media',
'type' => 1,
'pinned' => 1,
'user_id' => 0,
]);
// An unpinned tag is not rendered in the dashboard taglist, so it must
// not be offered as a default (selecting it would silently do nothing).
Item::factory()->create([
'title' => 'Archive',
'url' => 'archive',
'type' => 1,
'pinned' => 0,
'user_id' => 0,
]);
$setting = Setting::where('key', 'default_tag')->first();
$editValue = $setting->edit_value;
// A "none" option with an empty value, using the shared translation key.
$this->assertStringContainsString('<option value="" ', $editValue);
$this->assertStringContainsString(__('app.options.none'), $editValue);
// One option per pinned tag: the slug as the value, the raw title as the label.
$this->assertStringContainsString('value="home-dashboard"', $editValue);
$this->assertStringContainsString('>Home</option>', $editValue);
$this->assertStringContainsString('value="media"', $editValue);
$this->assertStringContainsString('>Media</option>', $editValue);
// The unpinned tag is excluded.
$this->assertStringNotContainsString('value="archive"', $editValue);
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace Tests\Unit\helpers;
use Tests\TestCase;
/**
* Regression coverage for className() in app/Helper.php.
*
* className() turns a supported-app display name into the PHP class-name
* fragment used to resolve enhanced-app classes (see Application::single()).
* It relies on a unicode-aware preg_replace, which is exactly the kind of
* PCRE behaviour that can change across PHP versions, so the stripping rules
* and unicode-safety are pinned here.
*/
class ClassNameTest extends TestCase
{
public function test_strips_spaces_and_punctuation(): void
{
$this->assertSame('HomeAssistant', className('Home Assistant'));
$this->assertSame('Pihole', className('Pi-hole'));
$this->assertSame('NodeRED', className('Node-RED!'));
}
public function test_keeps_digits(): void
{
$this->assertSame('App2Go', className('App 2 Go'));
}
public function test_is_unicode_safe(): void
{
// Letters in other scripts / accented letters must be preserved,
// only the separators are removed.
$this->assertSame('CaféServer', className('Café Server'));
$this->assertSame('中文测试', className('中文 测试'));
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace Tests\Unit\helpers;
use Tests\TestCase;
/**
* Regression coverage for the colour brightness globals in app/Helper.php.
*
* get_brightness() and title_color() drive the automatic black/white tile
* text colour on the dashboard. They rely on hexdec(), substr() and integer
* maths, all of which are sensitive to PHP behavioural changes, so the
* expected luminance values and the black/white threshold are pinned here.
*/
class ColorHelpersTest extends TestCase
{
public function test_get_brightness_returns_full_luminance_for_white(): void
{
$this->assertEqualsWithDelta(255, get_brightness('#ffffff'), 0.0001);
}
public function test_get_brightness_returns_zero_for_black(): void
{
$this->assertEqualsWithDelta(0, get_brightness('#000000'), 0.0001);
}
public function test_get_brightness_expands_three_char_hex(): void
{
// #fff / #000 must expand to the six-char form before decoding.
$this->assertEqualsWithDelta(255, get_brightness('#fff'), 0.0001);
$this->assertEqualsWithDelta(0, get_brightness('#000'), 0.0001);
}
public function test_get_brightness_strips_leading_hash_and_other_non_hex(): void
{
// A value without the leading # must decode identically.
$this->assertEqualsWithDelta(255, get_brightness('ffffff'), 0.0001);
// Interior non-hex separators (the "other non-hex" in the name) must be
// stripped before decoding, so these normalise to ffffff. If the
// preg_replace were dropped these would decode to a different value.
$this->assertEqualsWithDelta(255, get_brightness('#ff:ff:ff'), 0.0001);
$this->assertEqualsWithDelta(255, get_brightness('ff-ff-ff'), 0.0001);
}
public function test_get_brightness_weights_channels_per_luma_formula(): void
{
// (R*299 + G*587 + B*114) / 1000
$this->assertEqualsWithDelta(76.245, get_brightness('#ff0000'), 0.0001);
$this->assertEqualsWithDelta(149.685, get_brightness('#00ff00'), 0.0001);
$this->assertEqualsWithDelta(29.07, get_brightness('#0000ff'), 0.0001);
}
public function test_title_color_returns_black_for_bright_colours(): void
{
// Brightness > 130 => dark text.
$this->assertSame(' black', title_color('#ffffff'));
$this->assertSame(' black', title_color('#00ff00'));
}
public function test_title_color_returns_white_for_dark_colours(): void
{
// Brightness <= 130 => light text.
$this->assertSame(' white', title_color('#000000'));
$this->assertSame(' white', title_color('#0000ff'));
$this->assertSame(' white', title_color('#ff0000'));
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace Tests\Unit\helpers;
use Tests\TestCase;
/**
* Regression coverage for the byte / size formatting globals in app/Helper.php.
*
* These are plain global functions (no framework involved) so a PHP upgrade
* that changes integer/float division, rounding, or numeric-string casting is
* exactly the kind of thing that would silently break them. Every assertion
* pins a concrete expected string/integer so the behaviour is locked down.
*/
class SizeHelpersTest extends TestCase
{
public function test_format_bytes_uses_drive_size_base_1000_by_default(): void
{
// Default $is_drive_size = true => divide by 1000 (simulated HD size).
$this->assertSame('500B', format_bytes(500));
$this->assertSame('1KB', format_bytes(1000));
$this->assertSame('2KB', format_bytes(2000));
$this->assertSame('1MB', format_bytes(1000000));
$this->assertSame('1.5MB', format_bytes(1500000));
$this->assertSame('1GB', format_bytes(1000000000));
$this->assertSame('2.5GB', format_bytes(2500000000));
$this->assertSame('1TB', format_bytes(1000000000000));
}
public function test_format_bytes_base_1024_when_not_drive_size(): void
{
// $is_drive_size = false => divide by 1024 (real byte size).
$this->assertSame('1KB', format_bytes(1024, false));
$this->assertSame('1MB', format_bytes(1048576, false));
$this->assertSame('1GB', format_bytes(1073741824, false));
$this->assertSame('1.43MB', format_bytes(1500000, false));
}
public function test_format_bytes_drive_size_flag_changes_the_result(): void
{
// The same byte count must format differently depending on the base.
$this->assertSame('1MB', format_bytes(1000000, true));
$this->assertSame('977KB', format_bytes(1000000, false));
}
public function test_format_bytes_caps_at_terabytes(): void
{
// The unit loop stops at TB (index 4) even for very large inputs.
$this->assertSame('5TB', format_bytes(5000000000000));
}
public function test_format_bytes_applies_before_and_after_unit_strings(): void
{
$this->assertSame('1 KBps', format_bytes(1000, true, ' ', 'ps'));
$this->assertSame('1.43 MB/s', format_bytes(1500000, false, ' ', '/s'));
}
public function test_parse_size_resolves_gmk_suffixes_to_bytes(): void
{
$this->assertSame(1073741824, parse_size('1g'));
$this->assertSame(2147483648, parse_size('2G'));
$this->assertSame(536870912, parse_size('512m'));
$this->assertSame(131072, parse_size('128k'));
}
public function test_parse_size_without_suffix_returns_the_integer_value(): void
{
$this->assertSame(1024, parse_size('1024'));
}
}
+12 -2
View File
@@ -3,8 +3,18 @@
// autoload.php @generated by Composer
if (PHP_VERSION_ID < 50600) {
echo 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
exit(1);
if (!headers_sent()) {
header('HTTP/1.1 500 Internal Server Error');
}
$err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
if (!ini_get('display_errors')) {
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
fwrite(STDERR, $err);
} elseif (!headers_sent()) {
echo $err;
}
}
throw new RuntimeException($err);
}
require_once __DIR__ . '/composer/autoload_real.php';
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "aws/aws-sdk-php",
"homepage": "http://aws.amazon.com/sdkforphp",
"homepage": "https://aws.amazon.com/sdk-for-php",
"description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
"keywords": ["aws","amazon","sdk","s3","ec2","dynamodb","cloud","glacier"],
"type": "library",
@@ -8,7 +8,7 @@
"authors": [
{
"name": "Amazon Web Services",
"homepage": "http://aws.amazon.com"
"homepage": "https://aws.amazon.com"
}
],
"support": {
@@ -20,20 +20,20 @@
"guzzlehttp/guzzle": "^7.4.5",
"guzzlehttp/psr7": "^2.4.5",
"guzzlehttp/promises": "^2.0",
"mtdowling/jmespath.php": "^2.8.0",
"mtdowling/jmespath.php": "^2.9.1",
"ext-pcre": "*",
"ext-json": "*",
"ext-simplexml": "*",
"aws/aws-crt-php": "^1.2.3",
"psr/http-message": "^2.0"
"psr/http-message": "^1.0 || ^2.0",
"symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0"
},
"require-dev": {
"composer/composer" : "^2.7.8",
"ext-openssl": "*",
"ext-dom": "*",
"ext-pcntl": "*",
"ext-sockets": "*",
"phpunit/phpunit": "^5.6.3 || ^8.5 || ^9.5",
"phpunit/phpunit": "^10.0",
"behat/behat": "~3.0",
"doctrine/cache": "~1.4",
"aws/aws-php-sns-message-validator": "~1.0",
@@ -41,14 +41,14 @@
"psr/cache": "^2.0 || ^3.0",
"psr/simple-cache": "^2.0 || ^3.0",
"sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
"symfony/filesystem": "^v6.4.0 || ^v7.1.0",
"yoast/phpunit-polyfills": "^2.0",
"dms/phpunit-arraysubset-asserts": "^0.4.0"
"dms/phpunit-arraysubset-asserts": "^v0.5.0"
},
"suggest": {
"ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
"ext-curl": "To send requests using cURL",
"ext-sockets": "To use client-side monitoring",
"ext-pcntl": "To use client-side monitoring",
"doctrine/cache": "To use the DoctrineCacheAdapter",
"aws/aws-php-sns-message-validator": "To validate incoming SNS notifications"
},
@@ -0,0 +1,51 @@
<?php
namespace Aws\ARCRegionSwitch;
use Aws\AwsClient;
/**
* This client is used to interact with the **ARC - Region switch** service.
* @method \Aws\Result approvePlanExecutionStep(array $args = [])
* @method \GuzzleHttp\Promise\Promise approvePlanExecutionStepAsync(array $args = [])
* @method \Aws\Result cancelPlanExecution(array $args = [])
* @method \GuzzleHttp\Promise\Promise cancelPlanExecutionAsync(array $args = [])
* @method \Aws\Result createPlan(array $args = [])
* @method \GuzzleHttp\Promise\Promise createPlanAsync(array $args = [])
* @method \Aws\Result deletePlan(array $args = [])
* @method \GuzzleHttp\Promise\Promise deletePlanAsync(array $args = [])
* @method \Aws\Result getPlan(array $args = [])
* @method \GuzzleHttp\Promise\Promise getPlanAsync(array $args = [])
* @method \Aws\Result getPlanEvaluationStatus(array $args = [])
* @method \GuzzleHttp\Promise\Promise getPlanEvaluationStatusAsync(array $args = [])
* @method \Aws\Result getPlanExecution(array $args = [])
* @method \GuzzleHttp\Promise\Promise getPlanExecutionAsync(array $args = [])
* @method \Aws\Result getPlanInRegion(array $args = [])
* @method \GuzzleHttp\Promise\Promise getPlanInRegionAsync(array $args = [])
* @method \Aws\Result listPlanExecutionEvents(array $args = [])
* @method \GuzzleHttp\Promise\Promise listPlanExecutionEventsAsync(array $args = [])
* @method \Aws\Result listPlanExecutions(array $args = [])
* @method \GuzzleHttp\Promise\Promise listPlanExecutionsAsync(array $args = [])
* @method \Aws\Result listPlans(array $args = [])
* @method \GuzzleHttp\Promise\Promise listPlansAsync(array $args = [])
* @method \Aws\Result listPlansInRegion(array $args = [])
* @method \GuzzleHttp\Promise\Promise listPlansInRegionAsync(array $args = [])
* @method \Aws\Result listRoute53HealthChecks(array $args = [])
* @method \GuzzleHttp\Promise\Promise listRoute53HealthChecksAsync(array $args = [])
* @method \Aws\Result listRoute53HealthChecksInRegion(array $args = [])
* @method \GuzzleHttp\Promise\Promise listRoute53HealthChecksInRegionAsync(array $args = [])
* @method \Aws\Result listTagsForResource(array $args = [])
* @method \GuzzleHttp\Promise\Promise listTagsForResourceAsync(array $args = [])
* @method \Aws\Result startPlanExecution(array $args = [])
* @method \GuzzleHttp\Promise\Promise startPlanExecutionAsync(array $args = [])
* @method \Aws\Result tagResource(array $args = [])
* @method \GuzzleHttp\Promise\Promise tagResourceAsync(array $args = [])
* @method \Aws\Result untagResource(array $args = [])
* @method \GuzzleHttp\Promise\Promise untagResourceAsync(array $args = [])
* @method \Aws\Result updatePlan(array $args = [])
* @method \GuzzleHttp\Promise\Promise updatePlanAsync(array $args = [])
* @method \Aws\Result updatePlanExecution(array $args = [])
* @method \GuzzleHttp\Promise\Promise updatePlanExecutionAsync(array $args = [])
* @method \Aws\Result updatePlanExecutionStep(array $args = [])
* @method \GuzzleHttp\Promise\Promise updatePlanExecutionStepAsync(array $args = [])
*/
class ARCRegionSwitchClient extends AwsClient {}
@@ -0,0 +1,9 @@
<?php
namespace Aws\ARCRegionSwitch\Exception;
use Aws\Exception\AwsException;
/**
* Represents an error interacting with the **ARC - Region switch** service.
*/
class ARCRegionSwitchException extends AwsException {}
@@ -21,10 +21,14 @@ use Aws\AwsClient;
* @method \GuzzleHttp\Promise\Promise createAnalyzerAsync(array $args = [])
* @method \Aws\Result createArchiveRule(array $args = [])
* @method \GuzzleHttp\Promise\Promise createArchiveRuleAsync(array $args = [])
* @method \Aws\Result createServiceLinkedAnalyzer(array $args = [])
* @method \GuzzleHttp\Promise\Promise createServiceLinkedAnalyzerAsync(array $args = [])
* @method \Aws\Result deleteAnalyzer(array $args = [])
* @method \GuzzleHttp\Promise\Promise deleteAnalyzerAsync(array $args = [])
* @method \Aws\Result deleteArchiveRule(array $args = [])
* @method \GuzzleHttp\Promise\Promise deleteArchiveRuleAsync(array $args = [])
* @method \Aws\Result deleteServiceLinkedAnalyzer(array $args = [])
* @method \GuzzleHttp\Promise\Promise deleteServiceLinkedAnalyzerAsync(array $args = [])
* @method \Aws\Result generateFindingRecommendation(array $args = [])
* @method \GuzzleHttp\Promise\Promise generateFindingRecommendationAsync(array $args = [])
* @method \Aws\Result getAccessPreview(array $args = [])
+2
View File
@@ -19,6 +19,8 @@ use Aws\AwsClient;
* @method \GuzzleHttp\Promise\Promise getAlternateContactAsync(array $args = [])
* @method \Aws\Result getContactInformation(array $args = [])
* @method \GuzzleHttp\Promise\Promise getContactInformationAsync(array $args = [])
* @method \Aws\Result getGovCloudAccountInformation(array $args = [])
* @method \GuzzleHttp\Promise\Promise getGovCloudAccountInformationAsync(array $args = [])
* @method \Aws\Result getPrimaryEmail(array $args = [])
* @method \GuzzleHttp\Promise\Promise getPrimaryEmailAsync(array $args = [])
* @method \Aws\Result getRegionOptStatus(array $args = [])
+46
View File
@@ -8,22 +8,54 @@ use Aws\AwsClient;
*
* @method \Aws\Result addTagsToCertificate(array $args = [])
* @method \GuzzleHttp\Promise\Promise addTagsToCertificateAsync(array $args = [])
* @method \Aws\Result createAcmeDomainValidation(array $args = [])
* @method \GuzzleHttp\Promise\Promise createAcmeDomainValidationAsync(array $args = [])
* @method \Aws\Result createAcmeEndpoint(array $args = [])
* @method \GuzzleHttp\Promise\Promise createAcmeEndpointAsync(array $args = [])
* @method \Aws\Result createAcmeExternalAccountBinding(array $args = [])
* @method \GuzzleHttp\Promise\Promise createAcmeExternalAccountBindingAsync(array $args = [])
* @method \Aws\Result deleteAcmeDomainValidation(array $args = [])
* @method \GuzzleHttp\Promise\Promise deleteAcmeDomainValidationAsync(array $args = [])
* @method \Aws\Result deleteAcmeEndpoint(array $args = [])
* @method \GuzzleHttp\Promise\Promise deleteAcmeEndpointAsync(array $args = [])
* @method \Aws\Result deleteAcmeExternalAccountBinding(array $args = [])
* @method \GuzzleHttp\Promise\Promise deleteAcmeExternalAccountBindingAsync(array $args = [])
* @method \Aws\Result deleteCertificate(array $args = [])
* @method \GuzzleHttp\Promise\Promise deleteCertificateAsync(array $args = [])
* @method \Aws\Result describeAcmeAccount(array $args = [])
* @method \GuzzleHttp\Promise\Promise describeAcmeAccountAsync(array $args = [])
* @method \Aws\Result describeAcmeDomainValidation(array $args = [])
* @method \GuzzleHttp\Promise\Promise describeAcmeDomainValidationAsync(array $args = [])
* @method \Aws\Result describeAcmeEndpoint(array $args = [])
* @method \GuzzleHttp\Promise\Promise describeAcmeEndpointAsync(array $args = [])
* @method \Aws\Result describeAcmeExternalAccountBinding(array $args = [])
* @method \GuzzleHttp\Promise\Promise describeAcmeExternalAccountBindingAsync(array $args = [])
* @method \Aws\Result describeCertificate(array $args = [])
* @method \GuzzleHttp\Promise\Promise describeCertificateAsync(array $args = [])
* @method \Aws\Result exportCertificate(array $args = [])
* @method \GuzzleHttp\Promise\Promise exportCertificateAsync(array $args = [])
* @method \Aws\Result getAccountConfiguration(array $args = [])
* @method \GuzzleHttp\Promise\Promise getAccountConfigurationAsync(array $args = [])
* @method \Aws\Result getAcmeExternalAccountBindingCredentials(array $args = [])
* @method \GuzzleHttp\Promise\Promise getAcmeExternalAccountBindingCredentialsAsync(array $args = [])
* @method \Aws\Result getCertificate(array $args = [])
* @method \GuzzleHttp\Promise\Promise getCertificateAsync(array $args = [])
* @method \Aws\Result importCertificate(array $args = [])
* @method \GuzzleHttp\Promise\Promise importCertificateAsync(array $args = [])
* @method \Aws\Result listAcmeAccounts(array $args = [])
* @method \GuzzleHttp\Promise\Promise listAcmeAccountsAsync(array $args = [])
* @method \Aws\Result listAcmeDomainValidations(array $args = [])
* @method \GuzzleHttp\Promise\Promise listAcmeDomainValidationsAsync(array $args = [])
* @method \Aws\Result listAcmeEndpoints(array $args = [])
* @method \GuzzleHttp\Promise\Promise listAcmeEndpointsAsync(array $args = [])
* @method \Aws\Result listAcmeExternalAccountBindings(array $args = [])
* @method \GuzzleHttp\Promise\Promise listAcmeExternalAccountBindingsAsync(array $args = [])
* @method \Aws\Result listCertificates(array $args = [])
* @method \GuzzleHttp\Promise\Promise listCertificatesAsync(array $args = [])
* @method \Aws\Result listTagsForCertificate(array $args = [])
* @method \GuzzleHttp\Promise\Promise listTagsForCertificateAsync(array $args = [])
* @method \Aws\Result listTagsForResource(array $args = [])
* @method \GuzzleHttp\Promise\Promise listTagsForResourceAsync(array $args = [])
* @method \Aws\Result putAccountConfiguration(array $args = [])
* @method \GuzzleHttp\Promise\Promise putAccountConfigurationAsync(array $args = [])
* @method \Aws\Result removeTagsFromCertificate(array $args = [])
@@ -34,8 +66,22 @@ use Aws\AwsClient;
* @method \GuzzleHttp\Promise\Promise requestCertificateAsync(array $args = [])
* @method \Aws\Result resendValidationEmail(array $args = [])
* @method \GuzzleHttp\Promise\Promise resendValidationEmailAsync(array $args = [])
* @method \Aws\Result revokeAcmeAccount(array $args = [])
* @method \GuzzleHttp\Promise\Promise revokeAcmeAccountAsync(array $args = [])
* @method \Aws\Result revokeAcmeExternalAccountBinding(array $args = [])
* @method \GuzzleHttp\Promise\Promise revokeAcmeExternalAccountBindingAsync(array $args = [])
* @method \Aws\Result revokeCertificate(array $args = [])
* @method \GuzzleHttp\Promise\Promise revokeCertificateAsync(array $args = [])
* @method \Aws\Result searchCertificates(array $args = [])
* @method \GuzzleHttp\Promise\Promise searchCertificatesAsync(array $args = [])
* @method \Aws\Result tagResource(array $args = [])
* @method \GuzzleHttp\Promise\Promise tagResourceAsync(array $args = [])
* @method \Aws\Result untagResource(array $args = [])
* @method \GuzzleHttp\Promise\Promise untagResourceAsync(array $args = [])
* @method \Aws\Result updateAcmeDomainValidation(array $args = [])
* @method \GuzzleHttp\Promise\Promise updateAcmeDomainValidationAsync(array $args = [])
* @method \Aws\Result updateAcmeEndpoint(array $args = [])
* @method \GuzzleHttp\Promise\Promise updateAcmeEndpointAsync(array $args = [])
* @method \Aws\Result updateCertificateOptions(array $args = [])
* @method \GuzzleHttp\Promise\Promise updateCertificateOptionsAsync(array $args = [])
*/
@@ -43,8 +43,8 @@ use Aws\AwsClient;
* @method \GuzzleHttp\Promise\Promise getBackendJobAsync(array $args = [])
* @method \Aws\Result getBackendStorage(array $args = [])
* @method \GuzzleHttp\Promise\Promise getBackendStorageAsync(array $args = [])
* @method \Aws\Result getToken(array $args = [])
* @method \GuzzleHttp\Promise\Promise getTokenAsync(array $args = [])
* @method \Aws\Result getChallengeToken(array $args = [])
* @method \GuzzleHttp\Promise\Promise getChallengeTokenAsync(array $args = [])
* @method \Aws\Result importBackendAuth(array $args = [])
* @method \GuzzleHttp\Promise\Promise importBackendAuthAsync(array $args = [])
* @method \Aws\Result importBackendStorage(array $args = [])
+664
View File
@@ -0,0 +1,664 @@
<?php
namespace Aws\Api\Cbor;
use Aws\Api\Cbor\Exception\CborException;
/**
* Decodes Concise Binary Object Representation encoded strings
* into PHP values according to RFC 8949
*
* https://www.rfc-editor.org/rfc/rfc8949.html
*
* Supports Major types 0-7 including:
* - Type 0: Unsigned integers
* - Type 1: Negative integers
* - Type 2: Byte strings
* - Type 3: Text strings (UTF-8)
* - Type 4: Arrays
* - Type 5: Maps
* - Type 6: Tagged values (timestamps)
* - Type 7: Simple values (null, bool, float)
*
* @internal
*/
final class CborDecoder
{
private int $offset;
private int $length;
/**
* Decode CBOR binary data to PHP value
*
* @param string $data The CBOR-encoded binary data to decode
*
* @return mixed The decoded PHP value (can be any type: int, string, array, bool, null, float)
* @throws CborException If data is empty or malformed CBOR
*/
public function decode(string $data): mixed
{
if ($data === '') {
throw new CborException("No data to decode");
}
$this->offset = 0;
$this->length = strlen($data);
return $this->decodeValue($data);
}
/**
* Decode multiple CBOR values from sequential binary data
*
* @param string $data The CBOR-encoded binary data containing multiple values
*
* @return array Array of decoded PHP values in the order they appear in the data
* @throws CborException If data is malformed CBOR
*/
public function decodeAll(string $data): array
{
$this->length = strlen($data);
$this->offset = 0;
$values = [];
while ($this->offset < $this->length) {
$values[] = $this->decodeValue($data);
}
return $values;
}
/**
* Decodes a single CBOR value at the current offset
*
* @param string $data Reference to the CBOR data being decoded
*
* @return mixed The decoded value
* @throws CborException If unexpected end of data or invalid CBOR format
*/
private function decodeValue(string &$data): mixed
{
$offset = $this->offset;
$length = $this->length;
if ($offset >= $length) {
throw new CborException("Unexpected end of data");
}
$byte = ord($data[$offset++]);
$majorType = $byte >> 5;
$info = $byte & 0x1F;
switch ($majorType) {
case 0: // Unsigned integer
if ($info < 24) {
$this->offset = $offset;
return $info;
}
switch ($info) {
case 24:
if ($offset >= $length) {
throw new CborException("Not enough data");
}
$this->offset = $offset + 1;
return ord($data[$offset]);
case 25:
if ($offset + 2 > $length) {
throw new CborException("Not enough data");
}
$this->offset = $offset + 2;
return (ord($data[$offset]) << 8) | ord($data[$offset + 1]);
case 26:
if ($offset + 4 > $length) {
throw new CborException("Not enough data");
}
$this->offset = $offset + 4;
return unpack('N', $data, $offset)[1];
case 27:
if ($offset + 8 > $length) {
throw new CborException("Not enough data");
}
$this->offset = $offset + 8;
return unpack('J', $data, $offset)[1];
default:
throw new CborException("Invalid additional info for integer: $info");
}
case 1: // Negative integer
if ($info < 24) {
$this->offset = $offset;
return -1 - $info;
}
switch ($info) {
case 24:
if ($offset >= $length) {
throw new CborException("Not enough data");
}
$this->offset = $offset + 1;
return -1 - ord($data[$offset]);
case 25:
if ($offset + 2 > $length) {
throw new CborException("Not enough data");
}
$this->offset = $offset + 2;
return -1 - ((ord($data[$offset]) << 8) | ord($data[$offset + 1]));
case 26:
if ($offset + 4 > $length) {
throw new CborException("Not enough data");
}
$this->offset = $offset + 4;
return -1 - unpack('N', $data, $offset)[1];
case 27:
if ($offset + 8 > $length) {
throw new CborException("Not enough data");
}
$this->offset = $offset + 8;
$unsigned = unpack('J', $data, $offset)[1];
return ($unsigned === 9223372036854775807) ? PHP_INT_MIN : -1 - $unsigned;
default:
throw new CborException("Invalid additional info for integer: $info");
}
case 2: // Byte string
if ($info < 24) {
$len = $info;
} else {
switch ($info) {
case 24:
if ($offset >= $length) {
throw new CborException("Not enough data");
}
$len = ord($data[$offset++]);
break;
case 25:
if ($offset + 2 > $length) {
throw new CborException("Not enough data");
}
$len = (ord($data[$offset]) << 8) | ord($data[$offset + 1]);
$offset += 2;
break;
case 26:
if ($offset + 4 > $length) {
throw new CborException("Not enough data");
}
$len = unpack('N', $data, $offset)[1];
$offset += 4;
break;
case 27:
if ($offset + 8 > $length) {
throw new CborException("Not enough data");
}
$len = unpack('J', $data, $offset)[1];
$offset += 8;
break;
case 31:
$this->offset = $offset;
return $this->decodeIndefiniteString($data, 0x40);
default:
throw new CborException("Invalid additional info for byte string: $info");
}
}
if ($offset + $len > $length) {
throw new CborException("Not enough data");
}
$this->offset = $offset + $len;
return substr($data, $offset, $len);
case 3: // Text string
if ($info < 24) {
$len = $info;
} else {
switch ($info) {
case 24:
if ($offset >= $length) {
throw new CborException("Not enough data");
}
$len = ord($data[$offset++]);
break;
case 25:
if ($offset + 2 > $length) {
throw new CborException("Not enough data");
}
$len = (ord($data[$offset]) << 8) | ord($data[$offset + 1]);
$offset += 2;
break;
case 26:
if ($offset + 4 > $length) {
throw new CborException("Not enough data");
}
$len = unpack('N', $data, $offset)[1];
$offset += 4;
break;
case 27:
if ($offset + 8 > $length) {
throw new CborException("Not enough data");
}
$len = unpack('J', $data, $offset)[1];
$offset += 8;
break;
case 31:
$this->offset = $offset;
return $this->decodeIndefiniteString($data, 0x60);
default:
throw new CborException("Invalid additional info for text string: $info");
}
}
if ($offset + $len > $length) {
throw new CborException("Not enough data");
}
$this->offset = $offset + $len;
return substr($data, $offset, $len);
case 4: // Array
if ($info < 24) {
$count = $info;
} else {
switch ($info) {
case 24:
if ($offset >= $length) {
throw new CborException("Not enough data");
}
$count = ord($data[$offset++]);
break;
case 25:
if ($offset + 2 > $length) {
throw new CborException("Not enough data");
}
$count = (ord($data[$offset]) << 8) | ord($data[$offset + 1]);
$offset += 2;
break;
case 26:
if ($offset + 4 > $length) {
throw new CborException("Not enough data");
}
$count = unpack('N', $data, $offset)[1];
$offset += 4;
break;
case 27:
if ($offset + 8 > $length) {
throw new CborException("Not enough data");
}
$count = unpack('J', $data, $offset)[1];
$offset += 8;
break;
case 31:
$this->offset = $offset;
return $this->decodeIndefiniteArray($data);
default:
throw new CborException("Invalid additional info for array: $info");
}
}
$this->offset = $offset;
$arr = [];
for ($i = 0; $i < $count; $i++) {
$arr[] = $this->decodeValue($data);
}
return $arr;
case 5: // Map
if ($info < 24) {
$count = $info;
} else {
switch ($info) {
case 24:
if ($offset >= $length) {
throw new CborException("Not enough data");
}
$count = ord($data[$offset++]);
break;
case 25:
if ($offset + 2 > $length) {
throw new CborException("Not enough data");
}
$count = (ord($data[$offset]) << 8) | ord($data[$offset + 1]);
$offset += 2;
break;
case 26:
if ($offset + 4 > $length) {
throw new CborException("Not enough data");
}
$count = unpack('N', $data, $offset)[1];
$offset += 4;
break;
case 27:
if ($offset + 8 > $length) {
throw new CborException("Not enough data");
}
$count = unpack('J', $data, $offset)[1];
$offset += 8;
break;
case 31:
$this->offset = $offset;
return $this->decodeIndefiniteMap($data);
default:
throw new CborException("Invalid additional info for map: $info");
}
}
$this->offset = $offset;
$map = [];
for ($i = 0; $i < $count; $i++) {
$key = $this->decodeValue($data);
$map[$key] = $this->decodeValue($data);
}
return $map;
case 6: // Tag
switch ($info) {
case 24:
$offset++;
break;
case 25:
$offset += 2;
break;
case 26:
$offset += 4;
break;
case 27:
$offset += 8;
break;
}
$this->offset = $offset;
return $this->decodeValue($data);
case 7: // Simple/float
switch ($info) {
case 20:
$this->offset = $offset;
return false;
case 21:
$this->offset = $offset;
return true;
case 22:
case 23:
$this->offset = $offset;
return null;
case 25: // Half-precision float
if ($offset + 2 > $length) {
throw new CborException("Not enough data");
}
$this->offset = $offset + 2;
$half = (ord($data[$offset]) << 8) | ord($data[$offset + 1]);
$sign = ($half >> 15) & 0x01;
$exp = ($half >> 10) & 0x1F;
$mant = $half & 0x3FF;
if ($exp === 0) {
return $mant === 0
? ($sign ? -0.0 : 0.0)
: ($sign ? -1 : 1) * pow(2, -14) * ($mant / 1024);
}
if ($exp === 31) {
return $mant === 0 ? ($sign ? -INF : INF) : NAN;
}
return (float) (($sign ? -1 : 1) * pow(2, $exp - 15) * (1 + $mant / 1024));
case 26: // Single-precision float
if ($offset + 4 > $length) {
throw new CborException("Not enough data");
}
$this->offset = $offset + 4;
return unpack('G', $data, $offset)[1];
case 27: // Double-precision float
if ($offset + 8 > $length) {
throw new CborException("Not enough data");
}
$this->offset = $offset + 8;
return unpack('E', $data, $offset)[1];
case 31:
throw new CborException("Unexpected break");
default:
throw new CborException("Unknown simple value: $info");
}
default:
throw new CborException("Unknown major type: $majorType");
}
}
/**
* Decode indefinite-length string (byte or text)
*
* @param string $data Reference to the CBOR data being decoded
* @param int $expectedMajor Expected major type (0x40 for byte string, 0x60 for text string)
*
* @return string The concatenated string from all chunks
* @throws CborException If invalid chunk format or unexpected end of data
*/
private function decodeIndefiniteString(string &$data, int $expectedMajor): string
{
$chunks = [];
while (true) {
$offset = $this->offset;
$length = $this->length;
if ($offset >= $length) {
throw new CborException("Unexpected end of data");
}
$byte = ord($data[$offset++]);
if ($byte === 0xFF) {
$this->offset = $offset;
return implode('', $chunks);
}
if (($byte & 0xE0) !== $expectedMajor) {
throw new CborException("Invalid chunk in indefinite string");
}
$info = $byte & 0x1F;
if ($info === 31) {
throw new CborException("Nested indefinite string");
}
if ($info < 24) {
$len = $info;
} else {
switch ($info) {
case 24:
if ($offset >= $length) {
throw new CborException("Not enough data");
}
$len = ord($data[$offset++]);
break;
case 25:
if ($offset + 2 > $length) {
throw new CborException("Not enough data");
}
$len = (ord($data[$offset]) << 8) | ord($data[$offset + 1]);
$offset += 2;
break;
case 26:
if ($offset + 4 > $length) {
throw new CborException("Not enough data");
}
$len = unpack('N', $data, $offset)[1];
$offset += 4;
break;
case 27:
if ($offset + 8 > $length) {
throw new CborException("Not enough data");
}
$len = unpack('J', $data, $offset)[1];
$offset += 8;
break;
default:
throw new CborException("Invalid chunk length info: $info");
}
}
if ($offset + $len > $length) {
throw new CborException("Not enough data for chunk");
}
$chunks[] = substr($data, $offset, $len);
$this->offset = $offset + $len;
}
}
/**
* Decode indefinite-length array
*
* @param string $data Reference to the CBOR data being decoded
*
* @return array The decoded array elements
* @throws CborException If unexpected end of data
*/
private function decodeIndefiniteArray(string &$data): array
{
$result = [];
while (true) {
if ($this->offset >= $this->length) {
throw new CborException("Unexpected end of data");
}
if (ord($data[$this->offset]) === 0xFF) {
$this->offset++;
return $result;
}
$result[] = $this->decodeValue($data);
}
}
/**
* Decode indefinite-length map
*
* @param string $data Reference to the CBOR data being decoded
*
* @return array The decoded map as associative array
* @throws CborException If unexpected end of data or odd number of items
*/
private function decodeIndefiniteMap(string &$data): array
{
$result = [];
while (true) {
if ($this->offset >= $this->length) {
throw new CborException("Unexpected end of data");
}
if (ord($data[$this->offset]) === 0xFF) {
$this->offset++;
return $result;
}
$key = $this->decodeValue($data);
$result[$key] = $this->decodeValue($data);
}
}
}
+345
View File
@@ -0,0 +1,345 @@
<?php
namespace Aws\Api\Cbor;
use Aws\Api\Cbor\Exception\CborException;
/**
* Encodes PHP values to Concise Binary Object Representation according to RFC 8949
* https://www.rfc-editor.org/rfc/rfc8949.html
*
* Supports Major types 0-7 including:
* - Type 0: Unsigned integers
* - Type 1: Negative integers
* - Type 2: Byte strings (via ['__cbor_bytes' => $data] wrappers)
* - Type 3: Text strings (UTF-8)
* - Type 4: Arrays
* - Type 5: Maps
* - Type 6: Tagged values (timestamps)
* - Type 7: Simple values (null, bool, float)
*
* @internal
*/
final class CborEncoder
{
/**
* Pre-encoded integers 0-23 (single byte) and common larger values
* CBOR major type 0 (unsigned integer)
*/
private const INT_CACHE = [
0 => "\x00", 1 => "\x01", 2 => "\x02", 3 => "\x03",
4 => "\x04", 5 => "\x05", 6 => "\x06", 7 => "\x07",
8 => "\x08", 9 => "\x09", 10 => "\x0A", 11 => "\x0B",
12 => "\x0C", 13 => "\x0D", 14 => "\x0E", 15 => "\x0F",
16 => "\x10", 17 => "\x11", 18 => "\x12", 19 => "\x13",
20 => "\x14", 21 => "\x15", 22 => "\x16", 23 => "\x17",
24 => "\x18\x18", 25 => "\x18\x19", 26 => "\x18\x1A",
32 => "\x18\x20", 50 => "\x18\x32", 64 => "\x18\x40",
100 => "\x18\x64", 128 => "\x18\x80", 200 => "\x18\xC8",
255 => "\x18\xFF", 256 => "\x19\x01\x00", 500 => "\x19\x01\xF4",
1000 => "\x19\x03\xE8", 1023 => "\x19\x03\xFF",
];
/**
* Pre-encoded negative integers -1 to -24 and common larger values
* CBOR major type 1 (negative integer)
*/
private const NEG_CACHE = [
-1 => "\x20", -2 => "\x21", -3 => "\x22", -4 => "\x23",
-5 => "\x24", -10 => "\x29", -20 => "\x33", -24 => "\x37",
-25 => "\x38\x18", -50 => "\x38\x31", -100 => "\x38\x63",
];
/**
* Encode a PHP value to CBOR binary string
*
* @param mixed $value The value to encode
*
* @return string
*/
public function encode(mixed $value): string
{
return $this->encodeValue($value);
}
/**
* Recursively encode a value to CBOR
*
* @param mixed $value Value to encode
* @return string Encoded CBOR bytes
*/
private function encodeValue(mixed $value): string
{
switch (gettype($value)) {
case 'string':
$len = strlen($value);
if ($len < 24) {
return chr(0x60 | $len) . $value;
}
if ($len < 0x100) {
return "\x78" . chr($len) . $value;
}
return $this->encodeTextString($value);
case 'array':
if (isset($value['__cbor_timestamp'])) {
return "\xC1\xFB" . pack('E', $value['__cbor_timestamp']);
}
// Encode a byte string (major type 2)
if (isset($value['__cbor_bytes'])) {
$bytes = $value['__cbor_bytes'];
$len = strlen($bytes);
if ($len < 24) {
return chr(0x40 | $len) . $bytes;
}
if ($len < 0x100) {
return "\x58" . chr($len) . $bytes;
}
if ($len < 0x10000) {
return "\x59" . pack('n', $len) . $bytes;
}
return "\x5A" . pack('N', $len) . $bytes;
}
if (array_is_list($value)) {
return $this->encodeArray($value);
}
return $this->encodeMap($value);
case 'integer':
if (isset(self::INT_CACHE[$value])) {
return self::INT_CACHE[$value];
}
if (isset(self::NEG_CACHE[$value])) {
return self::NEG_CACHE[$value];
}
// Fast path for positive integers
// Major type 0: unsigned integer
if ($value >= 0) {
if ($value < 24) {
return chr($value);
}
if ($value < 0x100) {
return "\x18" . chr($value);
}
if ($value < 0x10000) {
return "\x19" . pack('n', $value);
}
if ($value < 0x100000000) {
return "\x1A" . pack('N', $value);
}
return "\x1B" . pack('J', $value);
}
return $this->encodeInteger($value);
case 'double':
// Encode a float (major type 7, float 64)
return "\xFB" . pack('E', $value);
case 'boolean':
// Encode a boolean (major type 7, simple)
return $value ? "\xF5" : "\xF4";
case 'NULL':
// Encode null (major type 7, simple)
return "\xF6";
case 'object':
throw new CborException("Cannot encode object of type: " . get_class($value));
default:
throw new CborException("Cannot encode value of type: " . gettype($value));
}
}
/**
* Encode an integer (major type 0 or 1)
*
* @param int $value
* @return string
*/
private function encodeInteger(int $value): string
{
if (isset(self::INT_CACHE[$value])) {
return self::INT_CACHE[$value];
}
if (isset(self::NEG_CACHE[$value])) {
return self::NEG_CACHE[$value];
}
if ($value >= 0) {
// Major type 0: unsigned integer
if ($value < 24) {
return chr($value);
}
if ($value < 0x100) {
return "\x18" . chr($value);
}
if ($value < 0x10000) {
return "\x19" . pack('n', $value);
}
if ($value < 0x100000000) {
return "\x1A" . pack('N', $value);
}
return "\x1B" . pack('J', $value);
}
// Major type 1: negative integer (-1 - n)
$value = -1 - $value;
if ($value < 24) {
return chr(0x20 | $value);
}
if ($value < 0x100) {
return "\x38" . chr($value);
}
if ($value < 0x10000) {
return "\x39" . pack('n', $value);
}
if ($value < 0x100000000) {
return "\x3A" . pack('N', $value);
}
return "\x3B" . pack('J', $value);
}
/**
* Encode a text string (major type 3)
*
* @param string $value
* @return string
*/
private function encodeTextString(string $value): string
{
$len = strlen($value);
if ($len < 24) {
return chr(0x60 | $len) . $value;
}
if ($len < 0x100) {
return "\x78" . chr($len) . $value;
}
if ($len < 0x10000) {
return "\x79" . pack('n', $len) . $value;
}
if ($len < 0x100000000) {
return "\x7A" . pack('N', $len) . $value;
}
return "\x7B" . pack('J', $len) . $value;
}
/**
* Encode an array (major type 4)
*
* @param array $value
* @return string
*/
private function encodeArray(array $value): string
{
$count = count($value);
if ($count < 24) {
$result = chr(0x80 | $count);
} elseif ($count < 0x100) {
$result = "\x98" . chr($count);
} elseif ($count < 0x10000) {
$result = "\x99" . pack('n', $count);
} elseif ($count < 0x100000000) {
$result = "\x9A" . pack('N', $count);
} else {
$result = "\x9B" . pack('J', $count);
}
foreach ($value as $item) {
$result .= $this->encodeValue($item);
}
return $result;
}
/**
* Encode a map (major type 5)
*
* @param array $value
* @return string
*/
private function encodeMap(array $value): string
{
$count = count($value);
if ($count < 24) {
$result = chr(0xA0 | $count);
} elseif ($count < 0x100) {
$result = "\xB8" . chr($count);
} elseif ($count < 0x10000) {
$result = "\xB9" . pack('n', $count);
} elseif ($count < 0x100000000) {
$result = "\xBA" . pack('N', $count);
} else {
$result = "\xBB" . pack('J', $count);
}
foreach ($value as $k => $v) {
if (is_int($k)) {
$result .= $this->encodeInteger($k);
} else {
$len = strlen($k);
if ($len < 24) {
$result .= chr(0x60 | $len) . $k;
} elseif ($len < 0x100) {
$result .= "\x78" . chr($len) . $k;
} else {
$result .= "\x79" . pack('n', $len) . $k;
}
}
$result .= $this->encodeValue($v);
}
return $result;
}
/**
* Create an empty map (major type 5 with 0 elements)
*
* @return string
*/
public function encodeEmptyMap(): string
{
return "\xA0";
}
/**
* Create an empty indefinite map (major type 5 indefinite length)
*
* @return string
*/
public function encodeEmptyIndefiniteMap(): string
{
return "\xBF\xFF";
}
}
@@ -0,0 +1,6 @@
<?php
namespace Aws\Api\Cbor\Exception;
use RuntimeException;
class CborException extends RuntimeException {}
-5
View File
@@ -30,11 +30,6 @@ class DateTimeResult extends \DateTime implements \JsonSerializable
throw new ParserException('Invalid timestamp value passed to DateTimeResult::fromEpoch');
}
// PHP 5.5 does not support sub-second precision
if (\PHP_VERSION_ID < 56000) {
return new self(gmdate('c', $unixTimestamp));
}
$decimalSeparator = isset(localeconv()['decimal_point']) ? localeconv()['decimal_point'] : ".";
$formatString = "U" . $decimalSeparator . "u";
$dateTime = DateTime::createFromFormat(
@@ -31,19 +31,6 @@ abstract class AbstractErrorParser
StructureShape $member
);
protected function extractPayload(
StructureShape $member,
ResponseInterface $response
) {
if ($member instanceof StructureShape) {
// Structure members parse top-level data into a specific key.
return $this->payload($response, $member);
} else {
// Streaming data is just the stream from the response body.
return $response->getBody();
}
}
protected function populateShape(
array &$data,
ResponseInterface $response,
@@ -57,16 +44,15 @@ abstract class AbstractErrorParser
if (!empty($data['code'])) {
$errors = $this->api->getOperation($command->getName())->getErrors();
foreach ($errors as $key => $error) {
foreach ($errors as $error) {
// If error code matches a known error shape, populate the body
if ($this->errorCodeMatches($data, $error)) {
$modeledError = $error;
$data['body'] = $this->extractPayload(
$modeledError,
$response
$data['body'] = $this->payload(
$response,
$error
);
$data['error_shape'] = $modeledError;
$data['error_shape'] = $error;
foreach ($error->getMembers() as $name => $member) {
switch ($member['location']) {
@@ -0,0 +1,159 @@
<?php
namespace Aws\Api\ErrorParser;
use Aws\Api\Parser\AbstractParser;
use Aws\Api\StructureShape;
use Aws\CommandInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
/**
* Base implementation for Smithy RPC V2 protocol error parsers.
*
* @internal
*/
abstract class AbstractRpcV2ErrorParser extends AbstractErrorParser
{
private const HEADER_QUERY_ERROR = 'x-amzn-query-error';
private const HEADER_ERROR_TYPE = 'x-amzn-errortype';
private const HEADER_REQUEST_ID = 'x-amzn-requestid';
/**
* @param ResponseInterface $response
* @param CommandInterface|null $command
*
* @return array
*/
public function __invoke(
ResponseInterface $response,
?CommandInterface $command = null
) {
$response = AbstractParser::getResponseWithCachingStream($response);
$data = $this->parseError($response);
if (isset($data['parsed']['__type'])) {
$data['message'] = $data['parsed']['message'] ?? null;
}
$this->populateShape($data, $response, $command);
return $data;
}
/**
* @param ResponseInterface $response
* @param StructureShape $member
*
* @return array
*/
abstract protected function payload(
ResponseInterface $response,
StructureShape $member
): array;
/**
* @param StreamInterface $body
* @param ResponseInterface $response
*
* @return mixed
*/
abstract protected function parseBody(
StreamInterface $body,
ResponseInterface $response
): mixed;
/**
* @param ResponseInterface $response
*
* @return array
*/
private function parseError(ResponseInterface $response): array
{
$statusCode = (string) $response->getStatusCode();
$errorCode = null;
$errorType = null;
if ($this->api?->getMetadata('awsQueryCompatible') !== null
&& $response->hasHeader(self::HEADER_QUERY_ERROR)
&& $awsQueryError = $this->parseQueryCompatibleHeader($response)
) {
$errorCode = $awsQueryError['code'];
$errorType = $awsQueryError['type'];
}
if (!$errorCode && $response->hasHeader(self::HEADER_ERROR_TYPE)) {
$errorCode = $this->extractErrorCode(
$response->getHeaderLine(self::HEADER_ERROR_TYPE)
);
}
$parsedBody = null;
$body = $response->getBody();
if ($body->getSize()) {
//TODO handle unseekable streams with CachingStream
$parsedBody = array_change_key_case($this->parseBody($body, $response));
}
if (!$errorCode && $parsedBody) {
$errorCode = $this->extractErrorCode(
$parsedBody['code'] ?? $parsedBody['__type'] ?? ''
);
}
return [
'request_id' => $response->getHeaderLine(self::HEADER_REQUEST_ID),
'code' => $errorCode ?: null,
'message' => null,
'type' => $errorType ?? ($statusCode[0] === '4' ? 'client' : 'server'),
'parsed' => $parsedBody,
];
}
/**
* Parse AWS Query Compatible error from header
*
* @param ResponseInterface $response
*
* @return array|null Returns ['code' => string, 'type' => string] or null
*/
private function parseQueryCompatibleHeader(ResponseInterface $response): ?array
{
$parts = explode(';', $response->getHeaderLine(self::HEADER_QUERY_ERROR));
if (count($parts) === 2 && $parts[0] && $parts[1]) {
return [
'code' => $parts[0],
'type' => $parts[1],
];
}
return null;
}
/**
* Extract error code from raw error string containing # and/or : delimiters
*
* @param string $rawErrorCode
* @return string
*/
private function extractErrorCode(string $rawErrorCode): string
{
// Handle format with both # and uri (e.g., "namespace#ErrorCode:http://foo-bar")
if (str_contains($rawErrorCode, ':') && str_contains($rawErrorCode, '#')) {
$start = strpos($rawErrorCode, '#') + 1;
$end = strpos($rawErrorCode, ':', $start);
return substr($rawErrorCode, $start, $end - $start);
}
// Handle format with uri only : (e.g., "ErrorCode:http://foo-bar.com/baz")
if (str_contains($rawErrorCode, ':')) {
return substr($rawErrorCode, 0, strpos($rawErrorCode, ':'));
}
// Handle format with only # (e.g., "namespace#ErrorCode")
if (str_contains($rawErrorCode, '#')) {
return substr($rawErrorCode, strpos($rawErrorCode, '#') + 1);
}
return $rawErrorCode;
}
}
+108 -13
View File
@@ -1,6 +1,7 @@
<?php
namespace Aws\Api\ErrorParser;
use Aws\Api\Parser\AbstractParser;
use Aws\Api\Parser\PayloadParserTrait;
use Aws\Api\StructureShape;
use Psr\Http\Message\ResponseInterface;
@@ -12,41 +13,135 @@ trait JsonParserTrait
{
use PayloadParserTrait;
private function genericHandler(ResponseInterface $response)
private function genericHandler(ResponseInterface $response): array
{
$code = (string) $response->getStatusCode();
$error_code = null;
$error_type = null;
// Parse error code and type for query compatible services
if ($this->api
&& !is_null($this->api->getMetadata('awsQueryCompatible'))
&& $response->getHeaderLine('x-amzn-query-error')
&& $response->hasHeader('x-amzn-query-error')
) {
$queryError = $response->getHeaderLine('x-amzn-query-error');
$parts = explode(';', $queryError);
if (isset($parts) && count($parts) == 2 && $parts[0] && $parts[1]) {
$error_code = $parts[0];
$error_type = $parts[1];
$awsQueryError = $this->parseAwsQueryCompatibleHeader($response);
if ($awsQueryError) {
$error_code = $awsQueryError['code'];
$error_type = $awsQueryError['type'];
}
}
// Parse error code from X-Amzn-Errortype header
if (!$error_code && $response->hasHeader('X-Amzn-Errortype')) {
$error_code = $this->extractErrorCode(
$response->getHeaderLine('X-Amzn-Errortype')
);
}
$parsedBody = null;
$rawBody = AbstractParser::getBodyContents($response);
if (!empty($rawBody)) {
$parsedBody = $this->parseJson($rawBody, $response);
}
// Parse error code from response body
if (!$error_code && $parsedBody) {
$error_code = $this->parseErrorFromBody($parsedBody);
}
if (!isset($error_type)) {
$error_type = $code[0] == '4' ? 'client' : 'server';
}
return [
'request_id' => (string) $response->getHeaderLine('x-amzn-requestid'),
'code' => isset($error_code) ? $error_code : null,
'request_id' => $response->getHeaderLine('x-amzn-requestid'),
'code' => $error_code ?? null,
'message' => null,
'type' => $error_type,
'parsed' => $this->parseJson($response->getBody(), $response)
'parsed' => $parsedBody
];
}
/**
* Parse AWS Query Compatible error from header
*
* @param ResponseInterface $response
* @return array|null Returns ['code' => string, 'type' => string] or null
*/
private function parseAwsQueryCompatibleHeader(ResponseInterface $response): ?array
{
$queryError = $response->getHeaderLine('x-amzn-query-error');
$parts = explode(';', $queryError);
if (count($parts) === 2 && $parts[0] && $parts[1]) {
return [
'code' => $parts[0],
'type' => $parts[1]
];
}
return null;
}
/**
* Parse error code from response body
*
* @param array|null $parsedBody
* @return string|null
*/
private function parseErrorFromBody(?array $parsedBody): ?string
{
if (!$parsedBody
|| (!isset($parsedBody['code']) && !isset($parsedBody['__type']))
) {
return null;
}
$error_code = $parsedBody['code'] ?? $parsedBody['__type'];
return $this->extractErrorCode($error_code);
}
/**
* Extract error code from raw error string containing # and/or : delimiters
*
* @param string $rawErrorCode
* @return string
*/
private function extractErrorCode(string $rawErrorCode): string
{
// Handle format with both # and uri (e.g., "namespace#http://foo-bar")
if (str_contains($rawErrorCode, ':') && str_contains($rawErrorCode, '#')) {
$start = strpos($rawErrorCode, '#') + 1;
$end = strpos($rawErrorCode, ':', $start);
return substr($rawErrorCode, $start, $end - $start);
}
// Handle format with uri only : (e.g., "ErrorCode:http://foo-bar.com/baz")
if (str_contains($rawErrorCode, ':')) {
return substr($rawErrorCode, 0, strpos($rawErrorCode, ':'));
}
// Handle format with only # (e.g., "namespace#ErrorCode")
if (str_contains($rawErrorCode, '#')) {
return substr($rawErrorCode, strpos($rawErrorCode, '#') + 1);
}
return $rawErrorCode;
}
protected function payload(
ResponseInterface $response,
StructureShape $member
) {
$jsonBody = $this->parseJson($response->getBody(), $response);
$rawBody = AbstractParser::getBodyContents($response);
if ($jsonBody) {
return $this->parser->parse($member, $jsonBody);
if (!empty($rawBody)) {
$jsonBody = $this->parseJson($rawBody, $response);
} else {
$jsonBody = $rawBody;
}
return $this->parser->parse($member, $jsonBody);
}
}
@@ -1,6 +1,7 @@
<?php
namespace Aws\Api\ErrorParser;
use Aws\Api\Parser\AbstractParser;
use Aws\Api\Parser\JsonParser;
use Aws\Api\Service;
use Aws\CommandInterface;
@@ -25,6 +26,7 @@ class JsonRpcErrorParser extends AbstractErrorParser
ResponseInterface $response,
?CommandInterface $command = null
) {
$response = AbstractParser::getResponseWithCachingStream($response);
$data = $this->genericHandler($response);
// Make the casing consistent across services.
@@ -1,6 +1,7 @@
<?php
namespace Aws\Api\ErrorParser;
use Aws\Api\Parser\AbstractParser;
use Aws\Api\Parser\JsonParser;
use Aws\Api\Service;
use Aws\Api\StructureShape;
@@ -26,11 +27,12 @@ class RestJsonErrorParser extends AbstractErrorParser
ResponseInterface $response,
?CommandInterface $command = null
) {
$response = AbstractParser::getResponseWithCachingStream($response);
$data = $this->genericHandler($response);
// Merge in error data from the JSON body
if ($json = $data['parsed']) {
$data = array_replace($data, $json);
$data = array_replace($json, $data);
}
// Correct error type from services like Amazon Glacier
@@ -38,18 +40,11 @@ class RestJsonErrorParser extends AbstractErrorParser
$data['type'] = strtolower($data['type']);
}
// Retrieve the error code from services like Amazon Elastic Transcoder
if ($code = $response->getHeaderLine('x-amzn-errortype')) {
$colon = strpos($code, ':');
$data['code'] = $colon ? substr($code, 0, $colon) : $code;
}
// Retrieve error message directly
$data['message'] = isset($data['parsed']['message'])
? $data['parsed']['message']
: (isset($data['parsed']['Message'])
? $data['parsed']['Message']
: null);
$data['message'] = $data['parsed']['message']
?? $data['parsed']['Message']
?? $data['parsed']['error_description']
?? null;
$this->populateShape($data, $response, $command);
@@ -0,0 +1,65 @@
<?php
namespace Aws\Api\ErrorParser;
use Aws\Api\Cbor\CborDecoder;
use Aws\Api\Parser\RpcV2ParserTrait;
use Aws\Api\Service;
use Aws\Api\StructureShape;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
/**
* Parses errors according to Smithy RPC V2 CBOR protocol standards.
*
* https://smithy.io/2.0/additional-specs/protocols/smithy-rpc-v2.html
*
* @internal
*/
final class RpcV2CborErrorParser extends AbstractRpcV2ErrorParser
{
/** @var CborDecoder */
private CborDecoder $decoder;
use RpcV2ParserTrait;
/**
* @param Service|null $api
*/
public function __construct(?Service $api = null)
{
$this->decoder = new CborDecoder();
parent::__construct($api);
}
/**
* @param ResponseInterface $response
* @param StructureShape $member
*
* @return array
* @throws \Exception
*/
protected function payload(
ResponseInterface $response,
StructureShape $member
): array
{
$body = $response->getBody();
$cborBody = $this->parseCbor($body, $response);
return $this->resolveOutputShape($member, $cborBody);
}
/**
* @param StreamInterface $body
* @param ResponseInterface $response
*
* @return mixed
*/
protected function parseBody(
StreamInterface $body,
ResponseInterface $response
): mixed
{
return $this->parseCbor($body, $response);
}
}
@@ -1,6 +1,7 @@
<?php
namespace Aws\Api\ErrorParser;
use Aws\Api\Parser\AbstractParser;
use Aws\Api\Parser\PayloadParserTrait;
use Aws\Api\Parser\XmlParser;
use Aws\Api\Service;
@@ -27,6 +28,7 @@ class XmlErrorParser extends AbstractErrorParser
ResponseInterface $response,
?CommandInterface $command = null
) {
$response = AbstractParser::getResponseWithCachingStream($response);
$code = (string) $response->getStatusCode();
$data = [
@@ -37,9 +39,9 @@ class XmlErrorParser extends AbstractErrorParser
'parsed' => null
];
$body = $response->getBody();
if ($body->getSize() > 0) {
$this->parseBody($this->parseXml($body, $response), $data);
$rawBody = AbstractParser::getBodyContents($response);
if (!empty($rawBody)) {
$this->parseBody($this->parseXml($rawBody, $response), $data);
} else {
$this->parseHeaders($response, $data);
}
@@ -100,12 +102,20 @@ class XmlErrorParser extends AbstractErrorParser
ResponseInterface $response,
StructureShape $member
) {
$xmlBody = $this->parseXml($response->getBody(), $response);
$rawBody = AbstractParser::getBodyContents($response);
if (empty($rawBody)) {
return $rawBody;
}
$xmlBody = $this->parseXml($rawBody, $response);
$prefix = $this->registerNamespacePrefix($xmlBody);
$errorBody = $xmlBody->xpath("//{$prefix}Error");
if (is_array($errorBody) && !empty($errorBody[0])) {
return $this->parser->parse($member, $errorBody[0]);
}
return $rawBody;
}
}
@@ -0,0 +1,11 @@
<?php
namespace Aws\Api\Exception;
use Aws\HasMonitoringEventsTrait;
use Aws\MonitoringEventsInterface;
class RpcV2CborException extends \RuntimeException implements
MonitoringEventsInterface
{
use HasMonitoringEventsTrait;
}
+1 -1
View File
@@ -89,7 +89,7 @@ class Operation extends AbstractModel
/**
* Get an array of operation error shapes.
*
* @return Shape[]
* @return StructureShape[]
*/
public function getErrors()
{
@@ -5,6 +5,7 @@ use Aws\Api\Service;
use Aws\Api\StructureShape;
use Aws\CommandInterface;
use Aws\ResultInterface;
use GuzzleHttp\Psr7\CachingStream;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
@@ -43,4 +44,27 @@ abstract class AbstractParser
StructureShape $member,
$response
);
public static function getBodyContents(ResponseInterface $response): string
{
$body = $response->getBody();
if ($body->isSeekable()) {
$body->rewind();
}
return $body->getContents();
}
public static function getResponseWithCachingStream(
ResponseInterface $response
): ResponseInterface
{
if (!$response->getBody()->isSeekable()) {
return $response->withBody(
new CachingStream($response->getBody())
);
}
return $response;
}
}
+65 -15
View File
@@ -39,6 +39,21 @@ abstract class AbstractRestParser extends AbstractParser
if ($payload = $output['payload']) {
$this->extractPayload($payload, $output, $response, $result);
} else {
$response = AbstractParser::getResponseWithCachingStream($response);
if ($response->getBody()->getSize() === null) {
$rawBody = AbstractParser::getBodyContents($response);
$isEmpty = empty($rawBody);
} else {
$isEmpty = $response->getBody()->getSize() === 0;
}
if (!$isEmpty && count($output->getMembers()) > 0
) {
// if no payload was found, then parse the contents of the body
$this->payload($response, $output, $result);
}
}
foreach ($output->getMembers() as $name => $member) {
@@ -55,14 +70,6 @@ abstract class AbstractRestParser extends AbstractParser
}
}
if (!$payload
&& $response->getBody()->getSize() > 0
&& count($output->getMembers()) > 0
) {
// if no payload was found, then parse the contents of the body
$this->payload($response, $output, $result);
}
return new Result($result);
}
@@ -73,20 +80,38 @@ abstract class AbstractRestParser extends AbstractParser
array &$result
) {
$member = $output->getMember($payload);
$body = $response->getBody();
if (!empty($member['eventstream'])) {
$result[$payload] = new EventParsingIterator(
$response->getBody(),
$body,
$member,
$this
);
} else if ($member instanceof StructureShape) {
// Structure members parse top-level data into a specific key.
return;
}
$response = AbstractParser::getResponseWithCachingStream($response);
if ($member instanceof StructureShape) {
//Unions must have at least one member set to a non-null value
// If the body is empty, we can assume it is unset
if ($response->getBody()->getSize() === null) {
$rawBody = AbstractParser::getBodyContents($response);
$isEmpty = empty($rawBody);
} else {
$isEmpty = $response->getBody()->getSize() === 0;
}
if (!empty($member['union']) && $isEmpty) {
return;
}
$result[$payload] = [];
$this->payload($response, $member, $result[$payload]);
} else {
// Streaming data is just the stream from the response body.
$result[$payload] = $response->getBody();
// Always set the payload to the body stream, regardless of content
$result[$payload] = $body;
}
}
@@ -100,13 +125,21 @@ abstract class AbstractRestParser extends AbstractParser
&$result
) {
$value = $response->getHeaderLine($shape['locationName'] ?: $name);
// Empty headers should not be deserialized
if ($value === null || $value === '') {
return;
}
switch ($shape->getType()) {
case 'float':
case 'double':
$value = (float) $value;
$value = match ($value) {
'NaN', 'Infinity', '-Infinity' => $value,
default => (float) $value
};
break;
case 'long':
case 'integer':
$value = (int) $value;
break;
case 'boolean':
@@ -143,6 +176,23 @@ abstract class AbstractRestParser extends AbstractParser
//output structure.
return;
}
case 'list':
$listMember = $shape->getMember();
$type = $listMember->getType();
// Only boolean lists require special handling
// other types can be returned as-is
if ($type !== 'boolean') {
break;
}
$items = array_map('trim', explode(',', $value));
$value = array_map(
static fn($item) => filter_var($item, FILTER_VALIDATE_BOOLEAN),
$items
);
break;
}
$result[$name] = $value;
@@ -0,0 +1,83 @@
<?php
namespace Aws\Api\Parser;
use Aws\Api\Operation;
use Aws\Api\Parser\Exception\ParserException;
use Aws\Result;
use Aws\CommandInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Base implementation for Smithy RPC V2 protocol parsers.
*
* Implementers MUST define the following static property representing
* the `Smithy-Protocol` header value:
* self::HEADER_SMITHY_PROTOCOL => static::$smithyProtocol
*
* @internal
*/
abstract class AbstractRpcV2Parser extends AbstractParser
{
private const HEADER_SMITHY_PROTOCOL = 'Smithy-Protocol';
/** @var string */
protected static string $smithyProtocol;
public function __invoke(
CommandInterface $command,
ResponseInterface $response
) {
$operation = $this->api->getOperation($command->getName());
return $this->parseResponse($response, $operation);
}
/**
* Parses a response according to Smithy RPC V2 protocol standards.
*
* @param ResponseInterface $response the response to parse.
* @param Operation $operation the operation which holds information for
* parsing the response.
*
* @return Result
*/
private function parseResponse(
ResponseInterface $response,
Operation $operation
): Result
{
$smithyProtocolHeader = $response->getHeaderLine(self::HEADER_SMITHY_PROTOCOL);
if ($smithyProtocolHeader !== static::$smithyProtocol) {
$statusCode = $response->getStatusCode();
throw new ParserException(
"Malformed response: Smithy-Protocol header mismatch (HTTP {$statusCode}). "
. 'Expected ' . static::$smithyProtocol
);
}
if ($operation['output'] === null) {
return new Result([]);
}
$outputShape = $operation->getOutput();
foreach ($outputShape->getMembers() as $memberName => $memberProps) {
if (!empty($memberProps['eventstream'])) {
return new Result([
$memberName => new EventParsingIterator(
$response->getBody(),
$outputShape->getMember($memberName),
$this
)
]);
}
}
$result = $this->parseMemberFromStream(
$response->getBody(),
$outputShape,
$response
);
return new Result(is_null($result) ? [] : $result);
}
}
+4 -1
View File
@@ -50,7 +50,10 @@ class JsonParser
$values = $shape->getValue();
$target = [];
foreach ($value as $k => $v) {
$target[$k] = $this->parse($values, $v);
// null map values should not be deserialized
if (!is_null($v)) {
$target[$k] = $this->parse($values, $v);
}
}
return $target;
+9 -4
View File
@@ -63,11 +63,16 @@ class JsonRpcParser extends AbstractParser
}
}
$body = $response->getBody();
if ($body->isSeekable()) {
$body->rewind();
}
$result = $this->parseMemberFromStream(
$response->getBody(),
$operation->getOutput(),
$response
);
$body,
$operation->getOutput(),
$response
);
return new Result(is_null($result) ? [] : $result);
}
@@ -17,6 +17,10 @@ trait MetadataParserTrait
&$result
) {
$value = $response->getHeaderLine($shape['locationName'] ?: $name);
// Empty values should not be deserialized
if ($value === null || $value === '') {
return;
}
switch ($shape->getType()) {
case 'float':
@@ -24,6 +28,7 @@ trait MetadataParserTrait
$value = (float) $value;
break;
case 'long':
case 'integer':
$value = (int) $value;
break;
case 'boolean':
@@ -64,10 +64,21 @@ class NonSeekableStreamDecodingEventStreamIterator extends DecodingEventStreamIt
while (!empty($this->tempBuffer) && $num > 0) {
$byte = array_shift($this->tempBuffer);
$bytes .= $byte;
$num = $num - 1;
$num -= 1;
}
// Loop until we've read the expected number of bytes
while ($num > 0 && !$this->stream->eof()) {
$chunk = $this->stream->read($num);
$chunkLen = strlen($chunk);
$bytes .= $chunk;
$num -= $chunkLen;
if ($chunkLen === 0) {
break; // Prevent infinite loop on unexpected EOF
}
}
$bytes = $bytes . $this->stream->read($num);
hash_update($this->hashContext, $bytes);
return $bytes;
@@ -2,7 +2,6 @@
namespace Aws\Api\Parser;
use Aws\Api\Parser\Exception\ParserException;
use Psr\Http\Message\ResponseInterface;
trait PayloadParserTrait
{
+11 -1
View File
@@ -40,7 +40,17 @@ class QueryParser extends AbstractParser
ResponseInterface $response
) {
$output = $this->api->getOperation($command->getName())->getOutput();
$xml = $this->parseXml($response->getBody(), $response);
// Read the full payload, even in non-seekable streams
$rawBody = AbstractParser::getBodyContents($response);
// Just parse when the body is not empty
$xml = !empty($rawBody)
? $this->parseXml($rawBody, $response)
: null;
// Empty request bodies should not be deserialized.
if (is_null($xml)) {
return new Result();
}
if ($this->honorResultWrapper && $output['resultWrapper']) {
$xml = $xml->{$output['resultWrapper']};

Some files were not shown because too many files have changed in this diff Show More