Compare commits

...

40 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 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 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 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
6675 changed files with 245646 additions and 82812 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
+1 -1
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
+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
+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();
}
}
+45 -10
View File
@@ -328,12 +328,18 @@ class ItemController extends Controller
$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,
@@ -348,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);
@@ -430,7 +437,7 @@ 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');
@@ -587,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.
*/
+2 -1
View File
@@ -142,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);
}
+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);
}
}
+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.7',
'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
{
//
}
};
+14
View File
@@ -364,5 +364,19 @@ class SettingsSeeder extends Seeder
$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();
}
}
}
+11
View File
@@ -28,6 +28,12 @@ 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',
@@ -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',
@@ -88,6 +95,8 @@ return array (
'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',
+1
View File
@@ -33,6 +33,7 @@ return array (
'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',
+272 -278
View File
@@ -1,5 +1,5 @@
{
"name": "Heimdall",
"name": "Heimdall-LS",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -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"
}
+27
View File
@@ -28,5 +28,32 @@
"dependencies": {
"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>
+3 -2
View File
@@ -927,9 +927,10 @@ div.create .input input, div.create .input select {
}
.app-icon {
max-width: 60px;
width: 60px;
height: 60px;
object-fit: contain;
display: block;
max-height: 60px;
}
.sidenav {
+10 -2
View File
@@ -4271,7 +4271,12 @@ $.when($.ready).then(function () {
data.url = apiurl;
$(".config-item").each(function () {
var 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");
if (data.password && data.password === fakePassword) {
@@ -4454,7 +4459,7 @@ var getCSRFToken = function getCSRFToken() {
var mergeItemWithAppDetails = function mergeItemWithAppDetails(item, appDetails) {
return {
pinned: 1,
tags: [0],
tags: Array.isArray(item.tags) && item.tags.length ? item.tags : [0],
appid: item.appid,
title: item.title,
colour: item.colour,
@@ -4484,6 +4489,9 @@ var fetchAppDetails = function fetchAppDetails(appId) {
app: appId
})
}).then(function (response) {
if (!response.ok) {
return Promise.reject(new Error("Failed to find app id: ".concat(appId)));
}
return response.json();
});
};
+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.
+6 -1
View File
@@ -310,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");
+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();
});
};
/**
+3 -2
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 {
+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());
}
}
}
+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);
}
}
+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)
));
}
}
+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']};
+17 -3
View File
@@ -28,10 +28,24 @@ class RestJsonParser extends AbstractRestParser
StructureShape $member,
array &$result
) {
$jsonBody = $this->parseJson($response->getBody(), $response);
$rawBody = AbstractParser::getBodyContents($response);
if ($jsonBody) {
$result += $this->parser->parse($member, $jsonBody);
// Parse JSON if we have content
if (!empty($rawBody)) {
$parsedJson = $this->parseJson($rawBody, $response);
} else {
// An empty response body should be deserialized as null
$result = null;
return;
}
$parsedBody = $this->parser->parse($member, $parsedJson);
if (is_string($parsedBody) && $member['document']) {
// Document types can be strings: replace entire result
$result = $parsedBody;
} else {
// Merge array/object results into existing result
$result = array_merge($result, (array) $parsedBody);
}
}
+6 -1
View File
@@ -28,7 +28,12 @@ class RestXmlParser extends AbstractRestParser
StructureShape $member,
array &$result
) {
$result += $this->parseMemberFromStream($response->getBody(), $member, $response);
$body = $response->getBody();
if ($body->isSeekable()) {
$body->rewind();
}
$result += $this->parseMemberFromStream($body, $member, $response);
}
public function parseMemberFromStream(
@@ -0,0 +1,50 @@
<?php
namespace Aws\Api\Parser;
use Aws\Api\Cbor\CborDecoder;
use Aws\Api\Service;
use Aws\Api\StructureShape;
use Psr\Http\Message\StreamInterface;
/**
* Parses responses according to Smithy RPC V2 CBOR protocol standards.
*
* https://smithy.io/2.0/additional-specs/protocols/smithy-rpc-v2.html
*
* @internal
*/
final class RpcV2CborParser extends AbstractRpcV2Parser
{
/** @var string */
protected static string $smithyProtocol = 'rpc-v2-cbor';
/** @var CborDecoder */
private CborDecoder $decoder;
use RpcV2ParserTrait;
/**
* @param Service $api Service description
*/
public function __construct(Service $api)
{
$this->decoder = new CborDecoder();
parent::__construct($api);
}
/**
* @param StreamInterface $stream
* @param StructureShape $member
* @param $response
*
* @return mixed
*/
public function parseMemberFromStream(
StreamInterface $stream,
StructureShape $member,
$response
): mixed
{
return $this->resolveOutputShape($member, $this->parseCbor($stream, $response));
}
}
@@ -0,0 +1,105 @@
<?php
namespace Aws\Api\Parser;
use Aws\Api\Cbor\Exception\CborException;
use Aws\Api\DateTimeResult;
use Aws\Api\Parser\Exception\ParserException;
use Aws\Api\Shape;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
/**
* Shared parsing logic for RPC V2 Parsers.
*
* @internal
*/
trait RpcV2ParserTrait
{
/**
* Resolves output shape fields that are present in the response
*
* @param Shape $shape
* @param mixed $value
*
* @return mixed
*/
protected function resolveOutputShape(Shape $shape, mixed $value): mixed
{
if ($value === null) {
return $value;
}
switch ($shape['type']) {
case 'structure':
$target = [];
foreach ($shape->getMembers() as $name => $member) {
$locationName = $member['locationName'] ?: $name;
if (isset($value[$locationName])) {
$target[$name] = $this->resolveOutputShape($member, $value[$locationName]);
}
}
return $target;
case 'list':
$target = [];
foreach ($value as $v) {
$target[] = $this->resolveOutputShape($shape->getMember(), $v);
}
return $target;
case 'map':
$target = [];
foreach ($value as $k => $v) {
if ($v !== null) {
$target[$k] = $this->resolveOutputShape($shape->getValue(), $v);
}
}
return $target;
case 'timestamp':
try {
$value = DateTimeResult::fromEpoch($value);
} catch (\Exception $e) {
trigger_error(
'Unable to parse timestamp value for '
. $shape->getName()
. ': ' . $e->getMessage(),
E_USER_WARNING
);
}
return $value;
default:
return $value;
}
}
/**
* Parses CBOR-encoded response data from RPC V2 CBOR services.
*
* @param StreamInterface $stream
* @param ResponseInterface $response
*
* @return mixed
*/
protected function parseCbor(
StreamInterface $stream,
ResponseInterface $response
): mixed
{
try {
$cborString = (string) $stream;
return empty($cborString)
? null
: $this->decoder->decode($cborString);
} catch (CborException $e) {
throw new ParserException(
"Malformed Response: error parsing CBOR: {$e->getMessage()}",
0,
$e,
['response' => $response]
);
}
}
}
+18 -14
View File
@@ -76,15 +76,18 @@ class XmlParser
private function memberKey(Shape $shape, $name)
{
if (null !== $shape['locationName']) {
return $shape['locationName'];
// Check if locationName came from shape definition
if ($shape instanceof StructureShape && isset($shape['locationName'])) {
$originalDef = $shape->getOriginalDefinition($shape->getName());
if ($originalDef && isset($originalDef['locationName'])
&& $originalDef['locationName'] === $shape['locationName']
) {
return $name;
}
}
if ($shape instanceof ListShape && $shape['flattened']) {
return $shape->getMember()['locationName'] ?: $name;
}
return $name;
return $shape['locationName'] ?? $name;
}
private function parse_list(ListShape $shape, \SimpleXMLElement $value)
@@ -132,7 +135,12 @@ class XmlParser
private function parse_float(Shape $shape, $value)
{
return (float) (string) $value;
$value = (string) $value;
return match ($value) {
'NaN', 'Infinity', '-Infinity' => $value,
default => (float) $value
};
}
private function parse_integer(Shape $shape, $value)
@@ -162,12 +170,8 @@ class XmlParser
private function parse_xml_attribute(Shape $shape, Shape $memberShape, $value)
{
$namespace = $shape['xmlNamespace']['uri']
? $shape['xmlNamespace']['uri']
: '';
$prefix = $shape['xmlNamespace']['prefix']
? $shape['xmlNamespace']['prefix']
: '';
$namespace = $shape['xmlNamespace']['uri'] ?? '';
$prefix = $shape['xmlNamespace']['prefix'] ?? '';
if (!empty($prefix)) {
$prefix .= ':';
}
@@ -0,0 +1,220 @@
<?php
namespace Aws\Api\Serializer;
use Aws\Api\Service;
use Aws\Api\Shape;
use Aws\Api\StructureShape;
use Aws\CommandInterface;
use Aws\EndpointV2\EndpointV2SerializerTrait;
use Aws\EndpointV2\Ruleset\RulesetEndpoint;
use DateTimeInterface;
use GuzzleHttp\Psr7;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Uri;
use Psr\Http\Message\RequestInterface;
/**
* Base implementation for Smithy RPC V2 protocol serializers.
*
* Implementers MUST override the defaultHeader property to represent
* protocol-specific default header values:
* self::HEADER_SMITHY_PROTOCOL => static::SMITHY_PROTOCOL,
* self::HEADER_CONTENT_TYPE => static::DEFAULT_CONTENT_TYPE,
* self::HEADER_ACCEPT => static::DEFAULT_ACCEPT
*
* Implementers must also implement `serialize()`, `resolveBlob()`, and `resolveTimestamp()
* according to their respective protocol specifications.
*
* @internal
*/
abstract class AbstractRpcV2Serializer
{
protected const HEADER_SMITHY_PROTOCOL = 'Smithy-Protocol';
protected const HEADER_CONTENT_TYPE = 'Content-Type';
protected const HEADER_ACCEPT = 'Accept';
/** @var array */
protected static array $defaultHeaders;
/** @var Service */
private Service $api;
/** @var string|Uri */
private string|Uri $endpoint;
/** @var bool */
private bool $isUseEndpointV2;
use EndpointV2SerializerTrait;
/**
* @param Service $api Service API description
* @param string $endpoint Endpoint to connect to
*/
public function __construct(Service $api, string|Uri $endpoint)
{
$this->api = $api;
$this->endpoint = Psr7\Utils::uriFor($endpoint);
}
/**
* @param CommandInterface $command Command to serialize into a request.
* @param mixed|null $endpoint
*
* @return RequestInterface
*/
public function __invoke(
CommandInterface $command,
mixed $endpoint = null
)
{
$commandArgs = $command->toArray();
$commandName = $command->getName();
$operation = $this->api->getOperation($commandName);
$headers = static::$defaultHeaders;
// Operations with no defined input type must not contain bodies
// Content-Type must not be set
if ($operation['input'] !== null) {
$body = $this->serialize($operation->getInput(), $commandArgs);
$headers['Content-Length'] = (string) strlen($body);
} else {
unset($headers['Content-Type']);
}
if ($endpoint instanceof RulesetEndpoint) {
$this->isUseEndpointV2 = true;
$this->setEndpointV2RequestOptions($endpoint, $headers);
$this->endpoint = $endpoint->getUrl();
}
$requestTarget = $this->buildRequestTarget(
$commandName,
$operation['http']['requestUri'] ?? ''
);
$uri = new Uri($this->endpoint . $requestTarget);
return new Request(
$operation['http']['method'],
$uri,
$headers,
$body ?? null
);
}
/**
* @param StructureShape $inputShape
* @param array $commandArgs
*
* @return string
*/
abstract public function serialize(
StructureShape $inputShape,
array $commandArgs
): string;
/**
* Resolves arguments for blob shapes present in the request arguments
* into a protocol-specific format.
*
* @param mixed $value
*
* @return array
*/
abstract protected function resolveBlob(mixed $value): array;
/**
* Resolves arguments for timestamp shapes present in the request arguments
* into a protocol-specific format.
*
* @param mixed $value
*
* @return array
*/
abstract protected function resolveTimestamp(
int|float|string|DateTimeInterface $value
): array;
/**
* Resolves input shape fields that are present in the request arguments
*
* @param Shape $shape
* @param mixed $value
*
* @return mixed
*/
protected function resolveInputShape(Shape $shape, mixed $value): mixed
{
switch ($shape->getType()) {
case 'structure':
$data = [];
foreach ($value as $k => $v) {
if ($v !== null && $shape->hasMember($k)) {
$valueShape = $shape->getMember($k);
$data[$valueShape['locationName'] ?: $k]
= $this->resolveInputShape($valueShape, $v);
}
}
return $data;
case 'list':
$items = $shape->getMember();
foreach ($value as $k => $v) {
$value[$k] = $this->resolveInputShape($items, $v);
}
return $value;
case 'map':
$values = $shape->getValue();
foreach ($value as $k => $v) {
$value[$k] = $this->resolveInputShape($values, $v);
}
return $value;
case 'timestamp':
return $this->resolveTimestamp($value);
case 'string':
return (string) $value;
case 'integer':
case 'long':
return (int) $value;
case 'double':
case 'float':
return (float) $value;
case 'blob':
return $this->resolveBlob($value);
default:
return $value;
}
}
/**
* Builds request URI absolute path
*
* @param string $commandName
* @param string $requestUri
*
* @return string
*/
private function buildRequestTarget(
string $commandName,
string $requestUri
): string
{
$requestUri = str_ends_with($requestUri, '/')
? $requestUri
: $requestUri . '/';
$targetPrefix = $this->api->getMetadata('targetPrefix');
return "{$requestUri}service/{$targetPrefix}/operation/{$commandName}";
}
}
+13 -5
View File
@@ -45,14 +45,22 @@ class JsonBody
* Builds the JSON body based on an array of arguments.
*
* @param Shape $shape Operation being constructed
* @param array $args Associative array of arguments
* @param array|string $args Associative array of arguments, or a string.
*
* @return string
*/
public function build(Shape $shape, array $args)
public function build(Shape $shape, array|string $args)
{
$result = json_encode($this->format($shape, $args));
return $result == '[]' ? '{}' : $result;
try {
$result = json_encode($this->format($shape, $args), JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new InvalidJsonException(
'Unable to encode JSON document ' . $shape->getName() . ': ' .
$e->getMessage() . PHP_EOL
);
}
return $result === '[]' ? '{}' : $result;
}
private function format(Shape $shape, $value)
@@ -60,7 +68,7 @@ class JsonBody
switch ($shape['type']) {
case 'structure':
$data = [];
if (isset($shape['document']) && $shape['document']) {
if ($shape['document'] ?? false) {
return $value;
}
foreach ($value as $k => $v) {
@@ -62,23 +62,26 @@ class JsonRpcSerializer
$operationName = $command->getName();
$operation = $this->api->getOperation($operationName);
$commandArgs = $command->toArray();
$body = $this->jsonFormatter->build($operation->getInput(), $commandArgs);
$headers = [
'X-Amz-Target' => $this->api->getMetadata('targetPrefix') . '.' . $operationName,
'Content-Type' => $this->contentType
];
'Content-Type' => $this->contentType,
'Content-Length' => (string) strlen($body)
];
if ($endpoint instanceof RulesetEndpoint) {
$this->setEndpointV2RequestOptions($endpoint, $headers);
}
$requestUri = $operation['http']['requestUri'] ?? null;
$absoluteUri = str_ends_with($this->endpoint, '/')
? $this->endpoint : $this->endpoint . $requestUri;
return new Request(
$operation['http']['method'],
$this->endpoint,
$absoluteUri,
$headers,
$this->jsonFormatter->build(
$operation->getInput(),
$commandArgs
)
$body
);
}
}
@@ -98,7 +98,8 @@ class QueryParamBuilder
if (!$this->isFlat($shape)) {
$locationName = $shape->getMember()['locationName'] ?: 'member';
$prefix .= ".$locationName";
} elseif ($name = $this->queryName($items)) {
// flattened lists can also model a `locationName`
} elseif ($name = $shape['locationName'] ?? $this->queryName($items)) {
$parts = explode('.', $prefix);
$parts[count($parts) - 1] = $name;
$prefix = implode('.', $parts);
@@ -36,9 +36,7 @@ class QuerySerializer
* containing "method", "uri", "headers", and "body" key value pairs.
*
* @param CommandInterface $command Command to serialize into a request.
* @param $endpointProvider Provider used for dynamic endpoint resolution.
* @param $clientArgs Client arguments used for dynamic endpoint resolution.
*
* @param null $endpoint Endpoint resolved using EndpointProviderV2
* @return RequestInterface
*/
public function __invoke(
@@ -63,17 +61,20 @@ class QuerySerializer
}
$body = http_build_query($body, '', '&', PHP_QUERY_RFC3986);
$headers = [
'Content-Length' => strlen($body),
'Content-Length' => (string) strlen($body),
'Content-Type' => 'application/x-www-form-urlencoded'
];
$requestUri = $operation['http']['requestUri'] ?? null;
if ($endpoint instanceof RulesetEndpoint) {
$this->setEndpointV2RequestOptions($endpoint, $headers);
}
$absoluteUri = str_ends_with($this->endpoint, '/')
? $this->endpoint : $this->endpoint . $requestUri;
return new Request(
'POST',
$this->endpoint,
$absoluteUri,
$headers,
$body
);
@@ -31,12 +31,11 @@ class RestJsonSerializer extends RestSerializer
$this->jsonFormatter = $jsonFormatter ?: new JsonBody($api);
}
protected function payload(StructureShape $member, array $value, array &$opts)
protected function payload(StructureShape $member, array|string $value, array &$opts)
{
$body = isset($value) ?
((string) $this->jsonFormatter->build($member, $value))
: "{}";
$opts['headers']['Content-Type'] = $this->contentType;
$body = $this->jsonFormatter->build($member, $value);
$opts['headers']['Content-Length'] = (string) strlen($body);
$opts['body'] = $body;
}
}
+315 -105
View File
@@ -1,6 +1,7 @@
<?php
namespace Aws\Api\Serializer;
use Aws\Api\ListShape;
use Aws\Api\MapShape;
use Aws\Api\Service;
use Aws\Api\Operation;
@@ -10,11 +11,13 @@ use Aws\Api\TimestampShape;
use Aws\CommandInterface;
use Aws\EndpointV2\EndpointV2SerializerTrait;
use Aws\EndpointV2\Ruleset\RulesetEndpoint;
use DateTimeInterface;
use GuzzleHttp\Psr7;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Uri;
use GuzzleHttp\Psr7\UriResolver;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\UriInterface;
/**
* Serializes HTTP locations like header, uri, payload, etc...
@@ -22,10 +25,15 @@ use Psr\Http\Message\RequestInterface;
*/
abstract class RestSerializer
{
use EndpointV2SerializerTrait;
private const TEMPLATE_STRING_REGEX = '/\{([^\}]+)\}/';
private static array $excludeContentType = [
's3' => true,
'glacier' => true
];
/** @var Service */
private $api;
private Service $api;
/** @var Uri */
private $endpoint;
@@ -33,9 +41,11 @@ abstract class RestSerializer
/** @var bool */
private $isUseEndpointV2;
use EndpointV2SerializerTrait;
/**
* @param Service $api Service API description
* @param string $endpoint Endpoint to connect to
* @param Service $api Service API description
* @param string $endpoint Endpoint to connect to
*/
public function __construct(Service $api, $endpoint)
{
@@ -45,19 +55,18 @@ abstract class RestSerializer
/**
* @param CommandInterface $command Command to serialize into a request.
* @param $clientArgs Client arguments used for dynamic endpoint resolution.
*
* @param mixed|null $endpoint
* @return RequestInterface
*/
public function __invoke(
CommandInterface $command,
$endpoint = null
mixed $endpoint = null
)
{
$operation = $this->api->getOperation($command->getName());
$commandArgs = $command->toArray();
$opts = $this->serialize($operation, $commandArgs);
$headers = isset($opts['headers']) ? $opts['headers'] : [];
$headers = $opts['headers'] ?? [];
if ($endpoint instanceof RulesetEndpoint) {
$this->isUseEndpointV2 = true;
@@ -70,16 +79,16 @@ abstract class RestSerializer
$operation['http']['method'],
$uri,
$headers,
isset($opts['body']) ? $opts['body'] : null
$opts['body'] ?? null
);
}
/**
* Modifies a hash of request options for a payload body.
*
* @param StructureShape $member Member to serialize
* @param array $value Value to serialize
* @param array $opts Request options to modify.
* @param StructureShape $member Member to serialize
* @param array $value Value to serialize
* @param array $opts Request options to modify.
*/
abstract protected function payload(
StructureShape $member,
@@ -103,20 +112,20 @@ abstract class RestSerializer
$location = $member['location'];
if (!$payload && !$location) {
$bodyMembers[$name] = $value;
} elseif ($location == 'header') {
} elseif ($location === 'header') {
$this->applyHeader($name, $member, $value, $opts);
} elseif ($location == 'querystring') {
} elseif ($location === 'querystring') {
$this->applyQuery($name, $member, $value, $opts);
} elseif ($location == 'headers') {
} elseif ($location === 'headers') {
$this->applyHeaderMap($name, $member, $value, $opts);
}
}
}
if (isset($bodyMembers)) {
$this->payload($operation->getInput(), $bodyMembers, $opts);
$this->payload($input, $bodyMembers, $opts);
} else if (!isset($opts['body']) && $this->hasPayloadParam($input, $payload)) {
$this->payload($operation->getInput(), [], $opts);
$this->payload($input, [], $opts);
}
return $opts;
@@ -130,12 +139,38 @@ abstract class RestSerializer
$m = $input->getMember($name);
$type = $m->getType();
if ($m['streaming'] ||
($m['type'] == 'string' || $m['type'] == 'blob')
($type === 'string' || $type === 'blob')
) {
// This path skips setting the content-type header usually done in
// RestJsonSerializer and RestXmlSerializer.certain S3 and glacier
// operations determine content type in Middleware::ContentType()
if (!isset(self::$excludeContentType[$this->api->getServiceName() ?? ''])) {
switch ($type) {
case 'string':
$opts['headers']['Content-Type'] = 'text/plain';
break;
case 'blob':
$opts['headers']['Content-Type'] = 'application/octet-stream';
break;
}
}
$body = $args[$name];
if (!$m['streaming'] && is_string($body)) {
$opts['headers']['Content-Length'] = (string) strlen($body);
}
// Streaming bodies or payloads that are strings are
// always just a stream of data.
$opts['body'] = Psr7\Utils::streamFor($args[$name]);
$stream = Psr7\Utils::streamFor($body);
// User-owned resource which should be detached instead of closed
// during garbage-collection
if (is_resource($body)) {
$stream = \Aws\detach_on_close_stream($stream);
}
$opts['body'] = $stream;
return;
}
@@ -144,13 +179,45 @@ abstract class RestSerializer
private function applyHeader($name, Shape $member, $value, array &$opts)
{
if ($member->getType() === 'timestamp') {
$timestampFormat = !empty($member['timestampFormat'])
? $member['timestampFormat']
: 'rfc822';
$value = TimestampShape::format($value, $timestampFormat);
} elseif ($member->getType() === 'boolean') {
$value = $value ? 'true' : 'false';
if ($value === null) {
return;
}
// Handle lists by applying header logic to each element
if ($member instanceof ListShape) {
if (!is_array($value)) {
throw new \InvalidArgumentException('Header values must be scalar or an array of scalars.');
}
$listMember = $member->getMember();
$headerValues = [];
foreach ($value as $listValue) {
if ($listValue === null) {
throw new \InvalidArgumentException('Header values must be scalar or an array of scalars.');
}
$tempOpts = ['headers' => []];
$this->applyHeader('temp', $listMember, $listValue, $tempOpts);
if (!array_key_exists('temp', $tempOpts['headers'])) {
throw new \InvalidArgumentException('Header values must be scalar or an array of scalars.');
}
$convertedValue = $tempOpts['headers']['temp'];
$headerValues[] = $convertedValue;
}
$value = $headerValues;
} else {
switch ($member->getType()) {
case 'timestamp':
$timestampFormat = $member['timestampFormat'] ?? 'rfc822';
$value = $this->formatTimestamp($value, $timestampFormat);
break;
case 'boolean':
$value = $this->formatBoolean($value);
break;
}
}
if ($member['jsonvalue']) {
@@ -163,7 +230,7 @@ abstract class RestSerializer
$value = base64_encode($value);
}
$opts['headers'][$member['locationName'] ?: $name] = $value;
$opts['headers'][$member['locationName'] ?: $name] = self::prepareHeaderValue($value);
}
/**
@@ -173,158 +240,301 @@ abstract class RestSerializer
{
$prefix = $member['locationName'];
foreach ($value as $k => $v) {
$opts['headers'][$prefix . $k] = $v;
if ($v === null) {
continue;
}
$opts['headers'][$prefix . $k] = self::prepareHeaderValue($v);
}
}
/**
* @return string|string[]
*/
private static function prepareHeaderValue($value)
{
if (is_scalar($value)) {
return (string) $value;
}
if (is_array($value)) {
if ($value === []) {
return '';
}
foreach ($value as $key => $item) {
if (!is_scalar($item)) {
throw new \InvalidArgumentException('Header values must be scalar or an array of scalars.');
}
$value[$key] = (string) $item;
}
return $value;
}
throw new \InvalidArgumentException('Header values must be scalar or an array of scalars.');
}
private function applyQuery($name, Shape $member, $value, array &$opts)
{
if ($member instanceof MapShape) {
$opts['query'] = isset($opts['query']) && is_array($opts['query'])
? $opts['query'] + $value
: $value;
} elseif ($value !== null) {
$type = $member->getType();
if ($type === 'boolean') {
$value = $value ? 'true' : 'false';
} elseif ($type === 'timestamp') {
$timestampFormat = !empty($member['timestampFormat'])
? $member['timestampFormat']
: 'iso8601';
$value = TimestampShape::format($value, $timestampFormat);
} elseif ($member instanceof ListShape) {
$listMember = $member->getMember();
$paramName = $member['locationName'] ?: $name;
foreach ($value as $listValue) {
// Recursively call applyQuery for each list element
$tempOpts = ['query' => []];
$this->applyQuery('temp', $listMember, $listValue, $tempOpts);
$opts['query'][$paramName][] = $tempOpts['query']['temp'];
}
} elseif (!is_null($value)) {
switch ($member->getType()) {
case 'timestamp':
$timestampFormat = $member['timestampFormat'] ?? 'iso8601';
$value = $this->formatTimestamp($value, $timestampFormat);
break;
case 'boolean':
$value = $this->formatBoolean($value);
break;
}
$opts['query'][$member['locationName'] ?: $name] = $value;
}
}
private function buildEndpoint(Operation $operation, array $args, array $opts)
private function buildEndpoint(
Operation $operation,
array $args,
array $opts
): UriInterface
{
// Expand `requestUri` field members
$relativeUri = $this->expandUriTemplate($operation, $args);
// Add query members to relativeUri
if (!empty($opts['query'])) {
$relativeUri = $this->appendQuery($opts['query'], $relativeUri);
}
// Special case - S3 keys that need path preservation
if ($this->api->getServiceName() === 's3'
&& isset($args['Key'])
&& $this->shouldPreservePath($args['Key'])
) {
return new Uri($this->endpoint . $relativeUri);
}
return $this->resolveUri($relativeUri, $opts);
}
/**
* Expands `requestUri` members
*
* @param Operation $operation
* @param array $args
*
* @return string
*/
private function expandUriTemplate(Operation $operation, array $args): string
{
$serviceName = $this->api->getServiceName();
// Create an associative array of variable definitions used in expansions
$varDefinitions = $this->getVarDefinitions($operation, $args);
$relative = preg_replace_callback(
'/\{([^\}]+)\}/',
function (array $matches) use ($varDefinitions) {
$isGreedy = substr($matches[1], -1, 1) == '+';
$k = $isGreedy ? substr($matches[1], 0, -1) : $matches[1];
if (!isset($varDefinitions[$k])) {
return preg_replace_callback(
self::TEMPLATE_STRING_REGEX,
static function (array $matches) use ($varDefinitions) {
$isGreedy = str_ends_with($matches[1], '+');
$varName = $isGreedy ? substr($matches[1], 0, -1) : $matches[1];
if (!isset($varDefinitions[$varName])) {
return '';
}
$value = $varDefinitions[$varName];
if ($isGreedy) {
return str_replace('%2F', '/', rawurlencode($varDefinitions[$k]));
return str_replace('%2F', '/', rawurlencode($value));
}
return rawurlencode($varDefinitions[$k]);
return rawurlencode($value);
},
$operation['http']['requestUri']
);
}
// Add the query string variables or appending to one if needed.
if (!empty($opts['query'])) {
$relative = $this->appendQuery($opts['query'], $relative);
}
$path = $this->endpoint->getPath();
if ($this->isUseEndpointV2 && $serviceName === 's3') {
if (substr($path, -1) === '/' && $relative[0] === '/') {
$path = rtrim($path, '/');
}
$relative = $path . $relative;
if (strpos($relative, '../') !== false
|| substr($relative, -2) === '..'
) {
if ($relative[0] !== '/') {
$relative = '/' . $relative;
/**
* Checks for path-like key names. If detected, traditional
* URI resolution is bypassed.
*
* @param string $key
* @return bool
*/
private function shouldPreservePath(string $key): bool
{
// Keys with dot segments
if (str_contains($key, '.')) {
$segments = explode('/', $key);
foreach ($segments as $segment) {
if ($segment === '.' || $segment === '..') {
return true;
}
return new Uri($this->endpoint->withPath('') . $relative);
}
}
if (((!empty($relative) && $relative !== '/')
&& !$this->isUseEndpointV2)
|| (isset($serviceName) && str_starts_with($serviceName, 'geo-'))
) {
$this->normalizePath($path);
// Keys starting with slash
if (str_starts_with($key, '/')) {
return true;
}
// If endpoint has path, remove leading '/' to preserve URI resolution.
if ($path && $relative[0] === '/') {
$relative = substr($relative, 1);
return false;
}
/**
* @param string $relativeUri
* @param array $opts
*
* @return UriInterface
*/
private function resolveUri(string $relativeUri, array $opts): UriInterface
{
$basePath = $this->endpoint->getPath();
// Only process if we have a non-empty base path
if (!empty($basePath) && $basePath !== '/') {
// if relative is just '/', we want just the base path without trailing slash
if ($relativeUri === '/' || empty($relativeUri)) {
// Remove trailing slash if present
return $this->endpoint->withPath(rtrim($basePath, '/'));
}
// if relative is '/?query', we want base path without trailing slash + query
// for now, this is only seen with S3 GetBucketLocation after processing the model
if (empty($opts['query'])
&& str_starts_with($relativeUri, '/?')
) {
$query = substr($relativeUri, 2); // Remove '/?'
return $this->endpoint->withQuery($query);
}
// Ensure base path has trailing slash
if (!str_ends_with($basePath, '/')) {
$this->endpoint = $this->endpoint->withPath($basePath . '/');
}
// Remove leading slash from relative path to make it relative
if (str_starts_with($relativeUri, '/')) {
$relativeUri = substr($relativeUri, 1);
}
}
//Append path to endpoint when leading '//...'
// present as uri cannot be properly resolved
if ($this->isUseEndpointV2 && strpos($relative, '//') === 0) {
return new Uri($this->endpoint . $relative);
}
// Expand path place holders using Amazon's slightly different URI
// template syntax.
return UriResolver::resolve($this->endpoint, new Uri($relative));
return UriResolver::resolve($this->endpoint, new Uri($relativeUri));
}
/**
* @param StructureShape $input
* @param $payload
*
* @return bool
*/
private function hasPayloadParam(StructureShape $input, $payload)
{
if ($payload) {
$potentiallyEmptyTypes = ['blob','string'];
if ($this->api->getMetadata('protocol') == 'rest-xml') {
if ($this->api->getProtocol() === 'rest-xml') {
$potentiallyEmptyTypes[] = 'structure';
}
$payloadMember = $input->getMember($payload);
if (in_array($payloadMember['type'], $potentiallyEmptyTypes)) {
//unions may also be empty/unset
if (!empty($payloadMember['union'])
|| in_array($payloadMember['type'], $potentiallyEmptyTypes)
) {
return false;
}
}
foreach ($input->getMembers() as $member) {
if (!isset($member['location'])) {
return true;
}
}
return false;
}
private function appendQuery($query, $endpoint)
/**
* @param $query
* @param $relativeUri
*
* @return string
*/
private function appendQuery($query, $relativeUri): string
{
$append = Psr7\Query::build($query);
return $endpoint .= strpos($endpoint, '?') !== false ? "&{$append}" : "?{$append}";
return $relativeUri
. (str_contains($relativeUri, '?') ? "&{$append}" : "?{$append}");
}
private function getVarDefinitions($command, $args)
/**
* @param CommandInterface $command
* @param array $args
*
* @return array
*/
private function getVarDefinitions(
Operation $operation,
array $args
): array
{
$varDefinitions = [];
foreach ($command->getInput()->getMembers() as $name => $member) {
if ($member['location'] == 'uri') {
$varDefinitions[$member['locationName'] ?: $name] =
isset($args[$name])
? $args[$name]
: null;
foreach ($operation->getInput()->getMembers() as $name => $member) {
if ($member['location'] === 'uri') {
$value = $args[$name] ?? null;
if (!is_null($value)) {
switch ($member->getType()) {
case 'timestamp':
$timestampFormat = $member['timestampFormat'] ?? 'iso8601';
$value = $this->formatTimestamp($value, $timestampFormat);
break;
case 'boolean':
$value = $this->formatBoolean($value);
break;
}
}
$varDefinitions[$member['locationName'] ?: $name] = $value;
}
}
return $varDefinitions;
}
/**
* Appends trailing slash to non-empty paths with at least one segment
* to ensure proper URI resolution
* @param DateTimeInterface|string|int $value
* @param string $timestampFormat
*
* @param string $path
*
* @return void
* @return string
*/
private function normalizePath(string $path): void
private function formatTimestamp(
DateTimeInterface|string|int $value,
string $timestampFormat
): string
{
if (!empty($path) && $path !== '/' && substr($path, -1) !== '/') {
$this->endpoint = $this->endpoint->withPath($path . '/');
}
return TimestampShape::format($value, $timestampFormat);
}
/**
* @param $value
*
* @return string
*/
private function formatBoolean($value): string
{
return $value ? 'true' : 'false';
}
}
@@ -29,7 +29,9 @@ class RestXmlSerializer extends RestSerializer
protected function payload(StructureShape $member, array $value, array &$opts)
{
$opts['headers']['Content-Type'] = 'application/xml';
$opts['body'] = $this->getXmlBody($member, $value);
$body = $this->getXmlBody($member, $value);
$opts['headers']['Content-Length'] = (string) strlen($body);
$opts['body'] = $body;
}
/**
@@ -39,7 +41,7 @@ class RestXmlSerializer extends RestSerializer
*/
private function getXmlBody(StructureShape $member, array $value)
{
$xmlBody = (string)$this->xmlBody->build($member, $value);
$xmlBody = $this->xmlBody->build($member, $value);
$xmlBody = str_replace("'", "&apos;", $xmlBody);
$xmlBody = str_replace('\r', "&#13;", $xmlBody);
$xmlBody = str_replace('\n', "&#10;", $xmlBody);
@@ -0,0 +1,124 @@
<?php
namespace Aws\Api\Serializer;
use Aws\Api\Cbor\CborEncoder;
use Aws\Api\Cbor\Exception\CborException;
use Aws\Api\Exception\RpcV2CborException;
use Aws\Api\Service;
use Aws\Api\StructureShape;
use DateTimeInterface;
/**
* Serializes requests according to Smithy RPC-V2 CBOR protocol standards.
*
* https://smithy.io/2.0/additional-specs/protocols/smithy-rpc-v2.html
*
* @internal
*/
final class RpcV2CborSerializer extends AbstractRpcV2Serializer
{
/** @var array|string[] */
protected static array $defaultHeaders = [
self::HEADER_SMITHY_PROTOCOL => 'rpc-v2-cbor',
self::HEADER_CONTENT_TYPE => 'application/cbor',
self::HEADER_ACCEPT => 'application/cbor',
];
/** @var CborEncoder */
private CborEncoder $encoder;
/**
* @param Service $api Service API description
* @param string $endpoint Endpoint to connect to
*/
public function __construct(Service $api, string $endpoint)
{
$this->encoder = new CborEncoder();
parent::__construct($api, $endpoint);
}
/**
* @param StructureShape $inputShape
* @param array $commandArgs
*
* @return string
* @throws RpcV2CborException
*/
public function serialize(
StructureShape $inputShape,
array $commandArgs
): string
{
try {
$resolvedInput = $this->resolveInputShape($inputShape, $commandArgs);
return !empty($resolvedInput)
? $this->encoder->encode($resolvedInput)
: $this->encoder->encodeEmptyIndefiniteMap();
} catch (CborException $e) {
throw new RpcV2CborException(
'Unable to encode CBOR document ' . $inputShape->getName() . ': ' .
$e->getMessage() . PHP_EOL
);
}
}
/**
* Wraps blob values in order to be encoded properly into
* byte strings.
*
* @param mixed $value
*
* @return string[]
* @throws RpcV2CborException
*/
protected function resolveBlob(mixed $value): array
{
if (is_resource($value)) {
$value = stream_get_contents($value);
if ($value === false) {
throw new RpcV2CborException(
'Failed to read resource stream value during serialization',
);
}
}
// Wrapper to differentiate byte string values during encoding
return ['__cbor_bytes' => (string) $value];
}
/**
* Wraps timestamp values in order to be encoded properly into
* value tag 1.
*
* @param mixed $value
*
* @return string[]
* @throws RpcV2CborException
*/
protected function resolveTimestamp(
int|float|string|DateTimeInterface $value
): array
{
if (is_numeric($value)) {
return ['__cbor_timestamp' => $value];
}
if ($value instanceof DateTimeInterface) {
// Preserve milliseconds
$micro = (int) $value->format('u');
$value = $value->getTimestamp() + $micro / 1e6;
} else {
$timestamp = strtotime($value);
if ($timestamp === false) {
throw new RpcV2CborException(
'Request serialization failed: Invalid date/time: ' . $value,
);
}
$value = $timestamp;
}
// Wrapper to differentiate timestamp values during encoding
return ['__cbor_timestamp' => $value];
}
}
+42 -8
View File
@@ -14,8 +14,8 @@ use XMLWriter;
*/
class XmlBody
{
/** @var \Aws\Api\Service */
private $api;
/** @var Service */
private Service $api;
/**
* @param Service $api API being used to create the XML body.
@@ -38,7 +38,10 @@ class XmlBody
$xml = new XMLWriter();
$xml->openMemory();
$xml->startDocument('1.0', 'UTF-8');
$this->format($shape, $shape['locationName'] ?: $shape['name'], $args, $xml);
$rootElementName = $this->determineRootElementName($shape);
$this->format($shape, $rootElementName, $args, $xml);
$xml->endDocument();
return $xml->outputMemory();
@@ -51,7 +54,7 @@ class XmlBody
if ($ns = $shape['xmlNamespace']) {
$xml->writeAttribute(
isset($ns['prefix']) ? "xmlns:{$ns['prefix']}" : 'xmlns',
$shape['xmlNamespace']['uri']
$ns['uri']
);
}
}
@@ -93,9 +96,17 @@ class XmlBody
$this->startElement($shape, $name, $xml);
foreach ($this->getStructureMembers($shape, $value) as $k => $definition) {
// Default to member name
$elementName = $k;
if ($definition['member']['locationName']
&& !isset($definition['member']['locationNameAtStructureLevel'])) {
$elementName = $definition['member']['locationName'];
}
$this->format(
$definition['member'],
$definition['member']['locationName'] ?: $k,
$elementName,
$definition['value'],
$xml
);
@@ -157,11 +168,13 @@ class XmlBody
array $value,
XMLWriter $xml
) {
$xmlEntry = $shape['flattened'] ? $shape['locationName'] : 'entry';
$xmlEntry = $shape['flattened'] ? $name : 'entry';
$xmlKey = $shape->getKey()['locationName'] ?: 'key';
$xmlValue = $shape->getValue()['locationName'] ?: 'value';
$this->startElement($shape, $name, $xml);
if (!$shape['flattened']) {
$this->startElement($shape, $name, $xml);
}
foreach ($value as $key => $v) {
$this->startElement($shape, $xmlEntry, $xml);
@@ -170,7 +183,9 @@ class XmlBody
$xml->endElement();
}
$xml->endElement();
if (!$shape['flattened']) {
$xml->endElement();
}
}
private function add_blob(Shape $shape, $name, $value, XMLWriter $xml)
@@ -217,4 +232,23 @@ class XmlBody
$this->defaultShape($shape, $name, $value, $xml);
}
}
private function determineRootElementName(Shape $shape): string
{
$shapeName = $shape->getName();
// Look up the shape definition first
if ($shapeName && $shapeMap = $shape->getShapeMap()) {
if (isset($shapeMap[$shapeName]['locationName'])) {
return $shapeMap[$shapeName]['locationName'];
}
}
// Fall back to shape's current locationName
if ($shape['locationName']) {
return $shape['locationName'];
}
return $shapeName;
}
}
+6 -3
View File
@@ -91,7 +91,8 @@ class Service extends AbstractModel
'json' => Serializer\JsonRpcSerializer::class,
'query' => Serializer\QuerySerializer::class,
'rest-json' => Serializer\RestJsonSerializer::class,
'rest-xml' => Serializer\RestXmlSerializer::class
'rest-xml' => Serializer\RestXmlSerializer::class,
'smithy-rpc-v2-cbor' => Serializer\RpcV2CborSerializer::class
];
$proto = $api->getProtocol();
@@ -126,7 +127,8 @@ class Service extends AbstractModel
'query' => ErrorParser\XmlErrorParser::class,
'rest-json' => ErrorParser\RestJsonErrorParser::class,
'rest-xml' => ErrorParser\XmlErrorParser::class,
'ec2' => ErrorParser\XmlErrorParser::class
'ec2' => ErrorParser\XmlErrorParser::class,
'smithy-rpc-v2-cbor' => ErrorParser\RpcV2CborErrorParser::class
];
if (isset($mapping[$protocol])) {
@@ -149,7 +151,8 @@ class Service extends AbstractModel
'json' => Parser\JsonRpcParser::class,
'query' => Parser\QueryParser::class,
'rest-json' => Parser\RestJsonParser::class,
'rest-xml' => Parser\RestXmlParser::class
'rest-xml' => Parser\RestXmlParser::class,
'smithy-rpc-v2-cbor' => Parser\RpcV2CborParser::class
];
$proto = $api->getProtocol();
+50 -2
View File
@@ -4,7 +4,7 @@ namespace Aws\Api;
/**
* Builds shape based on shape references.
*/
class ShapeMap
class ShapeMap implements \ArrayAccess
{
/** @var array */
private $definitions;
@@ -51,7 +51,14 @@ class ShapeMap
return $this->simple[$shape];
}
$definition = $shapeRef + $this->definitions[$shape];
$shapeDefinition = $this->definitions[$shape];
$definition = $shapeRef + $shapeDefinition;
// Property to know whether the locationName was set at member level
// or the structure level.
if (isset($shapeDefinition['locationName'])) {
$definition['locationNameAtStructureLevel'] = true;
}
$definition['name'] = $definition['shape'];
if (isset($definition['shape'])) {
unset($definition['shape']);
@@ -65,4 +72,45 @@ class ShapeMap
return $result;
}
/**
* @param mixed $offset
* @return bool
*/
public function offsetExists(mixed $offset): bool
{
return isset($this->definitions[$offset]);
}
/**
* @param mixed $offset
* @return mixed
*/
public function offsetGet(mixed $offset): mixed
{
return $this->definitions[$offset] ?? null;
}
/**
* @param mixed $offset
* @param mixed $value
* @throws \BadMethodCallException
*/
public function offsetSet(mixed $offset, mixed $value): void
{
throw new \BadMethodCallException(
'ShapeMap is read-only and cannot be modified.'
);
}
/**
* @param mixed $offset
* @throws \BadMethodCallException
*/
public function offsetUnset(mixed $offset): void
{
throw new \BadMethodCallException(
'ShapeMap is read-only and cannot be modified.'
);
}
}
+26
View File
@@ -67,6 +67,32 @@ class StructureShape extends Shape
return $members[$name];
}
/**
* Used to look up the shape's original definition.
* ShapeMap::resolve() merges properties from both
* member and target shape definitions, causing certain
* properties like `locationName` to be overwritten.
*
* @return ShapeMap
* @internal This method is for internal use only and should not be used
* by external code. It may be changed or removed without notice.
*/
public function getShapeMap(): ShapeMap
{
return $this->shapeMap;
}
/**
* Used to look up a shape's original definition.
*
* @param string $name
*
* @return array|null
*/
public function getOriginalDefinition(string $name): ?array
{
return $this->shapeMap[$name] ?? null;
}
private function generateMembersHash()
{
+1
View File
@@ -8,6 +8,7 @@ namespace Aws\Api;
enum SupportedProtocols: string
{
case JSON = 'json';
case CBOR = 'smithy-rpc-v2-cbor';
case REST_JSON = 'rest-json';
case REST_XML = 'rest-xml';
case QUERY = 'query';
+3 -3
View File
@@ -28,16 +28,16 @@ class TimestampShape extends Shape
$value = $value->getTimestamp();
} elseif (is_string($value)) {
$value = strtotime($value);
} elseif (!is_int($value)) {
} elseif (!is_int($value) && !is_float($value)) {
throw new \InvalidArgumentException('Unable to handle the provided'
. ' timestamp type: ' . gettype($value));
}
switch ($format) {
case 'iso8601':
return gmdate('Y-m-d\TH:i:s\Z', $value);
return gmdate('Y-m-d\TH:i:s\Z', (int) $value);
case 'rfc822':
return gmdate('D, d M Y H:i:s \G\M\T', $value);
return gmdate('D, d M Y H:i:s \G\M\T', (int) $value);
case 'unixTimestamp':
return $value;
default:

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