mirror of
https://github.com/linuxserver/Heimdall.git
synced 2026-08-07 07:16:13 +00:00
Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 238452c104 | |||
| 91f7a2ec8d | |||
| 62823aef63 | |||
| 2ceec61e17 | |||
| 8fc5f3ccbf | |||
| 6a45d44f69 | |||
| 327af60d1c | |||
| a38ecd4864 | |||
| 1f7432fa26 | |||
| 55bda9daf0 | |||
| 667dd4c129 | |||
| d16b7f60f3 | |||
| 57fa9f26fb | |||
| bdf9160fdc | |||
| 93a321ff5b | |||
| 3f483c9ca7 | |||
| 1c7ae3e335 | |||
| 2555ab1b3c | |||
| 49c29c8681 | |||
| 6f932918aa | |||
| 13642d59d9 | |||
| 25d1dc3c6a | |||
| f1eec81591 | |||
| 46e09d172a | |||
| 9cfa2548fa | |||
| 8a622545ff | |||
| 39191885e5 | |||
| cb59689ead | |||
| 6d0242dead | |||
| caa4f39edd | |||
| a1f0d8f75d | |||
| 076348478e | |||
| ad9baffa62 | |||
| 9a9877a0dc | |||
| c0c202c5ff | |||
| f69cbba6cd | |||
| ec229ea0af | |||
| 5dcf462542 | |||
| d1c52b886b | |||
| e2215fe42b | |||
| 5247349d33 | |||
| f547ae42bb | |||
| 881533baa5 | |||
| fb9af1b216 | |||
| 5907a1f231 | |||
| df0eba046b | |||
| 6a776e30f8 | |||
| cbf099be2c | |||
| 56c53ab9c5 | |||
| 98b6d96cd1 | |||
| 5be7a65677 | |||
| 44be3cb319 | |||
| 243ad00810 | |||
| 7861ae1512 | |||
| 66dfe95c9f | |||
| 130661bd34 | |||
| 900fc83e79 | |||
| 4f30332854 | |||
| 852c231724 | |||
| 045bdf0deb | |||
| 755c3e59e1 | |||
| 32bf1d034f | |||
| 6d12c547e7 | |||
| ae4ce92dab | |||
| 966279b252 | |||
| 54cf2b88ca | |||
| 31f1ba8192 | |||
| eadd9d1dd8 | |||
| c9ea2cdeb3 | |||
| 517f51ba90 | |||
| 825f67a4a4 | |||
| 05a552ffcf | |||
| ad4584e548 | |||
| 7d93099f2c | |||
| 31ca05f74f | |||
| cd95fc3b92 | |||
| 63e777b338 | |||
| fd926e983d | |||
| dce37c1412 | |||
| 6b9f61b0e6 | |||
| d1a96dd752 | |||
| 1ccc0da2a7 | |||
| 41aa255b88 | |||
| a8e4ab448b | |||
| 31db31d0f7 |
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
- name: Setup PHP, with composer and extensions
|
||||
uses: shivammathur/setup-php@v2 #https://github.com/shivammathur/setup-php
|
||||
with:
|
||||
php-version: '8.3'
|
||||
php-version: '8.4'
|
||||
extensions: mbstring, dom, fileinfo, mysql, libxml, xml, xmlwriter, dom, tokenizer, filter, json, phar, pcre, openssl, pdo, intl, curl
|
||||
|
||||
- name: Cache composer dependencies
|
||||
@@ -31,17 +31,17 @@ jobs:
|
||||
cp .env.example .env
|
||||
php artisan key:generate
|
||||
|
||||
- name: Cache yarn dependencies
|
||||
uses: actions/cache@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: yarn-${{ hashFiles('yarn.lock') }}
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Run yarn
|
||||
run: yarn && yarn dev
|
||||
- name: Install node modules and build assets
|
||||
run: npm ci && npm run dev
|
||||
|
||||
- name: Run ESLint
|
||||
run: yarn lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Run tests
|
||||
run: php artisan test
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -29,3 +29,4 @@ yarn-error.log
|
||||
storage/app/public/avatars/*
|
||||
.env
|
||||
.phpunit.result.cache
|
||||
/.phpunit.cache
|
||||
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 2.3.x | :white_check_mark: |
|
||||
| < 2.3 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
You can report any vulnerabilities on our discord server by DM-ing a team member, or asking a team member to DM you.
|
||||
|
||||
https://discord.com/invite/YWrKVTn
|
||||
@@ -27,6 +27,17 @@ function format_bytes($bytes, bool $is_drive_size = true, string $beforeunit = '
|
||||
}
|
||||
}
|
||||
|
||||
function parse_size($size) {
|
||||
$unit = strtolower(substr($size, -1));
|
||||
$bytes = (int)$size;
|
||||
switch($unit) {
|
||||
case 'g': $bytes *= 1024 * 1024 * 1024; break;
|
||||
case 'm': $bytes *= 1024 * 1024; break;
|
||||
case 'k': $bytes *= 1024; break;
|
||||
}
|
||||
return $bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $title
|
||||
* @param string $separator
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -194,6 +194,7 @@ class ItemController extends Controller
|
||||
public function create(): View
|
||||
{
|
||||
//
|
||||
$data['item'] = new \App\Item();
|
||||
$data['tags'] = Item::ofType('tag')->orderBy('title', 'asc')->pluck('title', 'id');
|
||||
$data['tags']->prepend(__('app.dashboard'), 0);
|
||||
$data['current_tags'] = '0';
|
||||
@@ -266,9 +267,16 @@ class ItemController extends Controller
|
||||
],
|
||||
];
|
||||
|
||||
// Proxy management
|
||||
$httpsProxy = getenv('HTTPS_PROXY');
|
||||
$httpsProxyLower = getenv('https_proxy');
|
||||
if ($httpsProxy !== false || $httpsProxyLower !== false) {
|
||||
$options['http']['proxy'] = $httpsProxy ?: $httpsProxyLower;
|
||||
}
|
||||
|
||||
$file = $request->input('icon');
|
||||
$path_parts = pathinfo($file);
|
||||
if (!isset($path_parts['extension'])) {
|
||||
if (!array_key_exists('extension', $path_parts)) {
|
||||
throw ValidationException::withMessages(['file' => 'Icon URL must have a valid file extension.']);
|
||||
}
|
||||
$extension = $path_parts['extension'];
|
||||
@@ -311,17 +319,27 @@ class ItemController extends Controller
|
||||
$storedConfigObject = json_decode($storedItem->getAttribute('description'));
|
||||
|
||||
$configObject = json_decode($config);
|
||||
$configObject->password = $storedConfigObject->password;
|
||||
if ($storedConfigObject && property_exists($storedConfigObject, 'password')) {
|
||||
$configObject->password = $storedConfigObject->password;
|
||||
} else {
|
||||
$configObject->password = null;
|
||||
}
|
||||
|
||||
$config = json_encode($configObject);
|
||||
}
|
||||
|
||||
$current_user = User::currentUser();
|
||||
$request->merge([
|
||||
'description' => $config,
|
||||
'user_id' => $current_user->getId(),
|
||||
]);
|
||||
|
||||
// Only assign ownership when creating; updates must keep the existing owner.
|
||||
if ($id === null) {
|
||||
$current_user = User::currentUser();
|
||||
$request->merge([
|
||||
'user_id' => $current_user->getId(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($request->input('appid') === 'null' || $request->input('appid') === null) {
|
||||
$request->merge([
|
||||
'class' => null,
|
||||
@@ -336,7 +354,8 @@ class ItemController extends Controller
|
||||
$item = Item::create($request->all());
|
||||
} else {
|
||||
$item = Item::find($id);
|
||||
$item->update($request->all());
|
||||
// Exclude user_id so an update can never reassign ownership
|
||||
$item->update($request->except(['user_id']));
|
||||
}
|
||||
|
||||
$item->parents()->sync($request->tags);
|
||||
@@ -418,27 +437,41 @@ class ItemController extends Controller
|
||||
*
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
public function appload(Request $request): ?string
|
||||
public function appload(Request $request): \Illuminate\Http\JsonResponse|string|null
|
||||
{
|
||||
$output = [];
|
||||
$appid = $request->input('app');
|
||||
$itemId = $request->input('item_id');
|
||||
|
||||
if ($appid === 'null') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$output['config'] = null;
|
||||
$output['custom'] = null;
|
||||
|
||||
$app = Application::single($appid);
|
||||
|
||||
if (!$app) {
|
||||
return response()->json(['error' => 'Application not found.'], 404);
|
||||
}
|
||||
|
||||
$output = (array)$app;
|
||||
|
||||
$appdetails = Application::getApp($appid);
|
||||
|
||||
if (!$appdetails) {
|
||||
return response()->json(['error' => 'Application details not found.'], 404);
|
||||
}
|
||||
|
||||
if ((bool)$app->enhanced === true) {
|
||||
// if(!isset($app->config)) { // class based config
|
||||
$output['custom'] = className($appdetails->name) . '.config';
|
||||
// }
|
||||
$item = $itemId ? Item::find($itemId) : Item::where('appid', $appid)->first();
|
||||
|
||||
if ($item) {
|
||||
$output['custom'] = className($appdetails->name) . '.config';
|
||||
$output['appvalue'] = $item->description;
|
||||
} else {
|
||||
// Ensure the app is installed if not found
|
||||
$output['custom'] = className($appdetails->name) . '.config';
|
||||
$output['appvalue'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
$output['colour'] = ($app->tile_background == 'light') ? '#fafbfc' : '#161b1f';
|
||||
@@ -446,14 +479,12 @@ class ItemController extends Controller
|
||||
if (strpos($app->icon, '://') !== false) {
|
||||
$output['iconview'] = $app->icon;
|
||||
} elseif (strpos($app->icon, 'icons/') !== false) {
|
||||
// Private apps have the icon locally
|
||||
$output['iconview'] = URL::to('/') . '/storage/' . $app->icon;
|
||||
$output['icon'] = str_replace('icons/', '', $output['icon']);
|
||||
} else {
|
||||
$output['iconview'] = config('app.appsource') . 'icons/' . $app->icon;
|
||||
}
|
||||
|
||||
|
||||
return json_encode($output);
|
||||
}
|
||||
|
||||
@@ -563,18 +594,46 @@ class ItemController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $id
|
||||
* @return void
|
||||
* Return live stats for an enhanced application tile.
|
||||
*
|
||||
* Always responds with HTTP 200 and valid JSON so the frontend refresh
|
||||
* loop (liveStatRefresh.js) keeps re-queueing the tile. On any failure we
|
||||
* degrade gracefully to an inactive, empty tile instead of a 500.
|
||||
*
|
||||
* @param int|string $id
|
||||
* @return \Illuminate\Http\JsonResponse|\Illuminate\Http\Response
|
||||
*/
|
||||
public function getStats($id)
|
||||
{
|
||||
$item = Item::find($id);
|
||||
$graceful = response()->json(['status' => 'inactive', 'html' => '']);
|
||||
|
||||
$item = Item::find($id);
|
||||
if ($item === null) {
|
||||
return $graceful;
|
||||
}
|
||||
|
||||
// Non-enhanced items (or stale records) have no live-stats class.
|
||||
if (empty($item->class)) {
|
||||
return $graceful;
|
||||
}
|
||||
|
||||
try {
|
||||
$config = $item->getconfig();
|
||||
|
||||
// Guard against a stale/renamed class string from the remote apps repo.
|
||||
if (! class_exists($item->class)) {
|
||||
return $graceful;
|
||||
}
|
||||
|
||||
$config = $item->getconfig();
|
||||
if (isset($item->class)) {
|
||||
$application = new $item->class;
|
||||
$application->config = $config;
|
||||
echo $application->livestats();
|
||||
|
||||
// livestats() returns a JSON string; return it verbatim (no re-encoding).
|
||||
return response($application->livestats());
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('getStats failed for item '.$id.' ('.$item->class.'): '.$e->getMessage());
|
||||
|
||||
return $graceful;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -4,9 +4,11 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Search;
|
||||
use Illuminate\Contracts\Foundation\Application;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Redirector;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class SearchController extends Controller
|
||||
{
|
||||
@@ -18,10 +20,8 @@ class SearchController extends Controller
|
||||
$requestprovider = $request->input('provider');
|
||||
$query = $request->input('q');
|
||||
|
||||
// Validate the presence and non-emptiness of the query parameter
|
||||
if (!$query || trim($query) === '') {
|
||||
abort(400, 'Missing or empty query parameter');
|
||||
}
|
||||
// Sanitize the query to prevent XSS
|
||||
$query = htmlspecialchars($query, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$provider = Search::providerDetails($requestprovider);
|
||||
|
||||
@@ -29,6 +29,11 @@ class SearchController extends Controller
|
||||
abort(404, 'Invalid provider');
|
||||
}
|
||||
|
||||
// If the query is empty, redirect to the provider's base URL
|
||||
if (!$query || trim($query) === '') {
|
||||
return redirect($provider->url);
|
||||
}
|
||||
|
||||
if ($provider->type == 'standard') {
|
||||
return redirect($provider->url.'?'.$provider->query.'='.urlencode($query));
|
||||
} elseif ($provider->type == 'external') {
|
||||
@@ -36,5 +41,99 @@ class SearchController extends Controller
|
||||
return $class->getResults($query, $provider);
|
||||
}
|
||||
|
||||
abort(404, 'Provider type not supported');}
|
||||
abort(404, 'Provider type not supported');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get autocomplete suggestions for a search query
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function autocomplete(Request $request)
|
||||
{
|
||||
$requestprovider = $request->input('provider');
|
||||
$query = $request->input('q');
|
||||
|
||||
if (!$query || trim($query) === '') {
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
$provider = Search::providerDetails($requestprovider);
|
||||
|
||||
if (!$provider || !isset($provider->autocomplete)) {
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
// Replace {query} placeholder with actual query
|
||||
$autocompleteUrl = str_replace('{query}', urlencode($query), $provider->autocomplete);
|
||||
|
||||
try {
|
||||
$response = Http::timeout(5)->get($autocompleteUrl);
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->body();
|
||||
|
||||
// Parse the response based on provider
|
||||
$suggestions = $this->parseAutocompleteResponse($data, $provider->id);
|
||||
|
||||
return response()->json($suggestions);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Return empty array on error
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse autocomplete response based on provider format
|
||||
*
|
||||
* @param string $data
|
||||
* @param string $providerId
|
||||
* @return array
|
||||
*/
|
||||
private function parseAutocompleteResponse($data, $providerId)
|
||||
{
|
||||
$suggestions = [];
|
||||
|
||||
switch ($providerId) {
|
||||
case 'google':
|
||||
// Google returns XML format
|
||||
if (strpos($data, '<?xml') === 0) {
|
||||
$xml = simplexml_load_string($data);
|
||||
if ($xml && isset($xml->CompleteSuggestion)) {
|
||||
foreach ($xml->CompleteSuggestion as $suggestion) {
|
||||
if (isset($suggestion->suggestion['data'])) {
|
||||
$suggestions[] = (string) $suggestion->suggestion['data'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'bing':
|
||||
case 'ddg':
|
||||
// Bing and DuckDuckGo return JSON array format
|
||||
$json = json_decode($data, true);
|
||||
if (is_array($json) && isset($json[1]) && is_array($json[1])) {
|
||||
$suggestions = $json[1];
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// Try to parse as JSON array
|
||||
$json = json_decode($data, true);
|
||||
if (is_array($json)) {
|
||||
if (isset($json[1]) && is_array($json[1])) {
|
||||
$suggestions = $json[1];
|
||||
} else {
|
||||
$suggestions = $json;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return $suggestions;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ class SettingsController extends Controller
|
||||
if (! is_null($setting)) {
|
||||
return view('settings.edit')->with([
|
||||
'setting' => $setting,
|
||||
'value' => $setting->value,
|
||||
]);
|
||||
} else {
|
||||
$route = route('settings.list', []);
|
||||
|
||||
@@ -101,6 +101,8 @@ class TagController extends Controller
|
||||
$data['tag'] = $item->id;
|
||||
$data['all_apps'] = $item->children;
|
||||
|
||||
$data['taglist'] = Item::ofType('tag')->where('id', '>', 0)->orderBy('title', 'asc')->get();
|
||||
|
||||
return view('welcome', $data);
|
||||
}
|
||||
|
||||
@@ -140,7 +142,8 @@ class TagController extends Controller
|
||||
'url' => $slug,
|
||||
]);
|
||||
|
||||
Item::find($id)->update($request->all());
|
||||
// Exclude user_id so an update can never reassign ownership
|
||||
Item::find($id)->update($request->except(['user_id']));
|
||||
|
||||
$route = route('dash', []);
|
||||
|
||||
|
||||
@@ -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', []);
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -14,11 +14,24 @@ use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Throwable;
|
||||
|
||||
class ProcessApps implements ShouldQueue, ShouldBeUnique
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Most failures here are GitHub rate-limit responses; retries inside the
|
||||
* same window do not help, so a single attempt is enough.
|
||||
*/
|
||||
public int $tries = 1;
|
||||
|
||||
/**
|
||||
* Expire the ShouldBeUnique lock after 10 minutes so a crashed worker
|
||||
* does not permanently block future ProcessApps dispatches.
|
||||
*/
|
||||
public int $uniqueFor = 600;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
@@ -57,4 +70,13 @@ class ProcessApps implements ShouldQueue, ShouldBeUnique
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(Throwable $exception): void
|
||||
{
|
||||
Log::error(static::class . ' permanently failed', [
|
||||
'exception_class' => $exception::class,
|
||||
'exception_message' => $exception->getMessage(),
|
||||
'file' => $exception->getFile() . ':' . $exception->getLine(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
+22
-1
@@ -12,11 +12,26 @@ use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class UpdateApps implements ShouldQueue, ShouldBeUnique
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Most failures here are GitHub rate-limit responses; retries inside the
|
||||
* same window do not help, so a single attempt is enough. The throttle
|
||||
* loop in handle() means the job is intentionally long-running, so we
|
||||
* leave $timeout unset and let the operator's worker config govern.
|
||||
*/
|
||||
public int $tries = 1;
|
||||
|
||||
/**
|
||||
* Expire the ShouldBeUnique lock after 10 minutes so a crashed worker
|
||||
* does not permanently block future UpdateApps dispatches.
|
||||
*/
|
||||
public int $uniqueFor = 600;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
@@ -49,8 +64,14 @@ class UpdateApps implements ShouldQueue, ShouldBeUnique
|
||||
Cache::lock('updateApps')->forceRelease();
|
||||
}
|
||||
|
||||
public function failed($exception): void
|
||||
public function failed(Throwable $exception): void
|
||||
{
|
||||
Cache::lock('updateApps')->forceRelease();
|
||||
|
||||
Log::error(static::class . ' permanently failed', [
|
||||
'exception_class' => $exception::class,
|
||||
'exception_message' => $exception->getMessage(),
|
||||
'file' => $exception->getFile() . ':' . $exception->getLine(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,9 +150,10 @@ class AppServiceProvider extends ServiceProvider
|
||||
$db_type = config()->get('database.default');
|
||||
|
||||
if ($db_type == 'sqlite') {
|
||||
$db_file = database_path(env('DB_DATABASE', 'app.sqlite'));
|
||||
$db_file = config()->get('database.connections.sqlite.database');
|
||||
Log::debug('SQLite Database Path: ' . $db_file);
|
||||
if (! is_file($db_file)) {
|
||||
// Do not create a file for the in-memory database identifier.
|
||||
if ($db_file !== ':memory:' && ! is_file($db_file)) {
|
||||
touch($db_file);
|
||||
}
|
||||
}
|
||||
|
||||
+21
-6
@@ -111,17 +111,32 @@ abstract class Search
|
||||
if ((bool) $user_search_provider) {
|
||||
$name = 'app.options.'.$user_search_provider;
|
||||
$provider = self::providerDetails($user_search_provider);
|
||||
$providers = self::providers();
|
||||
$providerCount = count($providers);
|
||||
|
||||
// If there's only one provider, use its key instead of the user's setting
|
||||
if ($providerCount === 1) {
|
||||
$user_search_provider = $providers->keys()->first();
|
||||
}
|
||||
|
||||
$output .= '<div class="searchform">';
|
||||
$output .= '<form action="'.url('search').'"'.getLinkTargetAttribute().' method="get">';
|
||||
$output .= '<div id="search-container" class="input-container">';
|
||||
$output .= '<select name="provider">';
|
||||
foreach (self::providers() as $key => $searchprovider) {
|
||||
$selected = ((string) $key === (string) $user_search_provider) ? ' selected="selected"' : '';
|
||||
$output .= '<option value="'.$key.'"'.$selected.'>'.$searchprovider['name'].'</option>';
|
||||
|
||||
// Only show dropdown if there's more than one provider
|
||||
if ($providerCount > 1) {
|
||||
$output .= '<select name="provider">';
|
||||
foreach ($providers as $key => $searchprovider) {
|
||||
$selected = ((string) $key === (string) $user_search_provider) ? ' selected="selected"' : '';
|
||||
$output .= '<option value="'.$key.'"'.$selected.'>'.$searchprovider['name'].'</option>';
|
||||
}
|
||||
$output .= '</select>';
|
||||
} else {
|
||||
// Hidden input for single provider
|
||||
$output .= '<input type="hidden" name="provider" value="'.$user_search_provider.'" />';
|
||||
}
|
||||
$output .= '</select>';
|
||||
$output .= '<input type="text" name="q" value="'.(Input::get('q') ?? '').'" class="homesearch" autofocus placeholder="'.__('app.settings.search').'..." />';
|
||||
|
||||
$output .= '<input type="text" name="q" value="'.e(Input::get('q') ?? '').'" class="homesearch" autofocus placeholder="'.__('app.settings.search').'..." />';
|
||||
$output .= '<button type="submit">'.ucwords(__('app.settings.search')).'</button>';
|
||||
$output .= '</div>';
|
||||
$output .= '</form>';
|
||||
|
||||
@@ -21,6 +21,27 @@ class CustomFormBuilder
|
||||
);
|
||||
}
|
||||
|
||||
public function password($name, $options = [])
|
||||
{
|
||||
return new HtmlString(
|
||||
$this->html->input('password', $name)->attributes($options)
|
||||
);
|
||||
}
|
||||
|
||||
public function hidden($name, $value = null, $options = [])
|
||||
{
|
||||
return new HtmlString(
|
||||
$this->html->input('hidden', $name, $value)->attributes($options)
|
||||
);
|
||||
}
|
||||
|
||||
public function checkbox($name, $value = null, $checked = false, $options = [])
|
||||
{
|
||||
return new HtmlString(
|
||||
$this->html->checkbox($name, $value, $checked)->attributes($options)
|
||||
);
|
||||
}
|
||||
|
||||
public function select($name, $list = [], $selected = null, $options = [])
|
||||
{
|
||||
return new HtmlString(
|
||||
|
||||
+36
-24
@@ -123,6 +123,12 @@ class Setting extends Model
|
||||
$options = (array) json_decode($this->options);
|
||||
if ($this->key === 'search_provider') {
|
||||
$options = Search::providers()->pluck('name', 'id')->toArray();
|
||||
} elseif ($this->key === 'default_tag') {
|
||||
$options = [];
|
||||
$tags = Item::where('type', 1)->where('id', '>', 0)->pinned()->orderBy('title', 'asc')->get();
|
||||
foreach ($tags as $tag) {
|
||||
$options[$tag->tag_url] = $tag->title;
|
||||
}
|
||||
}
|
||||
$value = (array_key_exists($this->value, $options))
|
||||
? __($options[$this->value])
|
||||
@@ -150,64 +156,70 @@ class Setting extends Model
|
||||
switch ($this->type) {
|
||||
case 'image':
|
||||
$value = '';
|
||||
if (isset($this->value) && ! empty($this->value)) {
|
||||
$value .= '<a class="setting-view-image" href="'.
|
||||
asset('storage/'.$this->value).
|
||||
'" title="'.
|
||||
__('app.settings.view').
|
||||
'" target="_blank"><img src="'.
|
||||
asset('storage/'.
|
||||
$this->value).
|
||||
if (isset($this->value) && !empty($this->value)) {
|
||||
$value .= '<a class="setting-view-image" href="' .
|
||||
asset('storage/' . $this->value) .
|
||||
'" title="' .
|
||||
__('app.settings.view') .
|
||||
'" target="_blank"><img src="' .
|
||||
asset('storage/' .
|
||||
$this->value) .
|
||||
'" /></a>';
|
||||
}
|
||||
$value .= '<input type="file" name="value" class="form-control" />';
|
||||
if (isset($this->value) && ! empty($this->value)) {
|
||||
$value .= '<a class="settinglink" href="'.
|
||||
route('settings.clear', $this->id).
|
||||
'" title="'.
|
||||
__('app.settings.remove').
|
||||
'">'.
|
||||
__('app.settings.reset').
|
||||
if (isset($this->value) && !empty($this->value)) {
|
||||
$value .= '<a class="settinglink" href="' .
|
||||
route('settings.clear', $this->id) .
|
||||
'" title="' .
|
||||
__('app.settings.remove') .
|
||||
'">' .
|
||||
__('app.settings.reset') .
|
||||
'</a>';
|
||||
}
|
||||
|
||||
|
||||
break;
|
||||
case 'boolean':
|
||||
$checked = false;
|
||||
if (isset($this->value) && (bool) $this->value === true) {
|
||||
if (isset($this->value) && (bool)$this->value === true) {
|
||||
$checked = true;
|
||||
}
|
||||
$set_checked = ($checked) ? ' checked="checked"' : '';
|
||||
$value = '
|
||||
<input type="hidden" name="value" value="0" />
|
||||
<label class="switch">
|
||||
<input type="checkbox" name="value" value="1"'.$set_checked.' />
|
||||
<input type="checkbox" name="value" value="1"' . $set_checked . ' />
|
||||
<span class="slider round"></span>
|
||||
</label>';
|
||||
|
||||
|
||||
break;
|
||||
case 'select':
|
||||
$options = json_decode($this->options);
|
||||
if ($this->key === 'search_provider') {
|
||||
$options = Search::providers()->pluck('name', 'id');
|
||||
} elseif ($this->key === 'default_tag') {
|
||||
$options = ['' => 'app.options.none'];
|
||||
$tags = Item::where('type', 1)->where('id', '>', 0)->pinned()->orderBy('title', 'asc')->get();
|
||||
foreach ($tags as $tag) {
|
||||
$options[$tag->tag_url] = $tag->title;
|
||||
}
|
||||
}
|
||||
$value = '<select name="value" class="form-control">';
|
||||
foreach ($options as $key => $opt) {
|
||||
$value .= '<option value="'.$key.'" '.(($this->value == $key) ? 'selected' : '').'>'.__($opt).'</option>';
|
||||
$value .= '<option value="' . $key . '" ' . (($this->value == $key) ? 'selected' : '') . '>' . __($opt) . '</option>';
|
||||
}
|
||||
$value .= '</select>';
|
||||
break;
|
||||
case 'textarea':
|
||||
$value = '<textarea name="value" class="form-control" cols="44" rows="15"></textarea>';
|
||||
$value = '<textarea name="value" class="form-control" cols="44" rows="15">' . htmlspecialchars($this->value, ENT_QUOTES, 'UTF-8') . '</textarea>';
|
||||
break;
|
||||
default:
|
||||
$value = '<input type="text" name="value" class="form-control" />';
|
||||
$value = '<input type="text" name="value" class="form-control" value="' . htmlspecialchars($this->value, ENT_QUOTES, 'UTF-8') . '" />';
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
|
||||
public function group(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\SettingGroup::class, 'group_id');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -5,7 +5,7 @@ use Illuminate\Support\Facades\Facade;
|
||||
|
||||
return [
|
||||
|
||||
'version' => '2.7.1',
|
||||
'version' => '2.8.1',
|
||||
|
||||
'appsource' => env('APP_SOURCE', 'https://appslist.heimdall.site/'),
|
||||
|
||||
|
||||
+5
-1
@@ -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
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -349,5 +349,34 @@ class SettingsSeeder extends Seeder
|
||||
$setting->label = 'app.settings.treat_tags_as';
|
||||
$setting->save();
|
||||
}
|
||||
|
||||
if (! $setting = Setting::find(15)) {
|
||||
$setting = new Setting;
|
||||
$setting->id = 15;
|
||||
$setting->group_id = 4;
|
||||
$setting->key = 'default_tag';
|
||||
$setting->type = 'select';
|
||||
$setting->label = 'app.settings.default_tag';
|
||||
$setting->value = '';
|
||||
$setting->save();
|
||||
} else {
|
||||
$setting->group_id = 4;
|
||||
$setting->label = 'app.settings.default_tag';
|
||||
$setting->save();
|
||||
}
|
||||
|
||||
if (! $setting = Setting::find(16)) {
|
||||
$setting = new Setting;
|
||||
$setting->id = 16;
|
||||
$setting->group_id = 4;
|
||||
$setting->key = 'skip_tls_verification';
|
||||
$setting->type = 'boolean';
|
||||
$setting->label = 'app.settings.skip_tls_verification';
|
||||
$setting->value = '0';
|
||||
$setting->save();
|
||||
} else {
|
||||
$setting->label = 'app.settings.skip_tls_verification';
|
||||
$setting->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-5
@@ -20,7 +20,7 @@ return array (
|
||||
'settings.language' => 'Sprache',
|
||||
'settings.reset' => 'Zurücksetzen auf Standard',
|
||||
'settings.remove' => 'Entfernen',
|
||||
'settings.search' => 'suche',
|
||||
'settings.search' => 'Suche',
|
||||
'settings.no_items' => 'Keine Elemente gefunden',
|
||||
'settings.label' => 'Bezeichnung',
|
||||
'settings.value' => 'Wert',
|
||||
@@ -28,12 +28,18 @@ return array (
|
||||
'settings.view' => 'Ansicht',
|
||||
'settings.custom_css' => 'Angepasstes CSS',
|
||||
'settings.custom_js' => 'Angepasstes JavaScript',
|
||||
'settings.treat_tags_as' => 'Tags behandeln als:',
|
||||
'settings.default_tag' => 'Standard Tag',
|
||||
'settings.folders' => 'Ordner',
|
||||
'settings.tags' => 'Tags',
|
||||
'settings.categories' => 'Kategorien',
|
||||
'settings.skip_tls_verification' => 'TLS-Überprüfung überspringen (selbstsignierte Zertifikate)',
|
||||
'options.none' => '- nicht festgelegt -',
|
||||
'options.google' => 'Google',
|
||||
'options.ddg' => 'DuckDuckGo',
|
||||
'options.bing' => 'Bing',
|
||||
'options.qwant' => 'Qwant',
|
||||
'options.startpage' => 'StartSeite',
|
||||
'options.startpage' => 'Startseite',
|
||||
'options.yes' => 'Ja',
|
||||
'options.no' => 'Nein',
|
||||
'options.nzbhydra' => 'NZBHydra',
|
||||
@@ -46,7 +52,7 @@ return array (
|
||||
'dash.pin_item' => 'Element auf dem Dashboard anheften',
|
||||
'dash.no_apps' => 'Derzeit gibt es keine angeheftete Anwendungen. :link1 oder :link2',
|
||||
'dash.link1' => 'Anwendung neu hinzufügen',
|
||||
'dash.link2' => 'anheften',
|
||||
'dash.link2' => 'Anheften',
|
||||
'dash.pinned_items' => 'Angeheftete Elemente',
|
||||
'apps.app_list' => 'Anwendungsliste',
|
||||
'apps.view_trash' => 'Ansicht Papierkorb',
|
||||
@@ -66,7 +72,7 @@ return array (
|
||||
'apps.add_tag' => 'Tag hinzufügen',
|
||||
'apps.tag_name' => 'Tag Name',
|
||||
'apps.tags' => 'Tags',
|
||||
'apps.override' => 'Fals anders zur Haupt-URL',
|
||||
'apps.override' => 'Falls anders zur Haupt-URL',
|
||||
'apps.preview' => 'Vorschau',
|
||||
'apps.apptype' => 'Anwendungstyp',
|
||||
'apps.website' => 'Webseite',
|
||||
@@ -74,6 +80,7 @@ return array (
|
||||
'apps.only_admin_account' => 'Nur mit Admin-Konto!',
|
||||
'apps.autologin_url' => 'Auto Login URL',
|
||||
'apps.show_deleted' => 'Gelöschte Anwendung anzeigen',
|
||||
'app.import' => 'Importieren',
|
||||
'dashboard' => 'Home Dashboard',
|
||||
'user.user_list' => 'Nutzer',
|
||||
'user.add_user' => 'Nutzer hinzufügen',
|
||||
@@ -81,13 +88,15 @@ return array (
|
||||
'user.avatar' => 'Avatar',
|
||||
'user.email' => 'Email',
|
||||
'user.password_confirm' => 'Passwort bestätigen',
|
||||
'user.secure_front' => 'Öffentlichen Zugang erlauben - Tritt nur bei gesetztem Passwort in kraft.',
|
||||
'user.secure_front' => 'Öffentlichen Zugang erlauben - Tritt nur bei gesetztem Passwort in Kraft.',
|
||||
'user.autologin' => 'Anmelden von spezieller URL erlauben. Jeder mit diesem Link kann sich anmelden.',
|
||||
'url' => 'URL',
|
||||
'title' => 'Titel',
|
||||
'delete' => 'Löschen',
|
||||
'optional' => 'Optional',
|
||||
'restore' => 'Wiederherstellen',
|
||||
'export' => 'Exportieren',
|
||||
'import' => 'Importieren',
|
||||
'alert.success.item_created' => 'Element erfolgreich erstellt',
|
||||
'alert.success.item_updated' => 'Element erfolgreich aktualisiert',
|
||||
'alert.success.item_deleted' => 'Element erfolgreich gelöscht',
|
||||
@@ -99,6 +108,8 @@ return array (
|
||||
'alert.success.tag_restored' => 'Tag erfolgreich wiederhergestellt',
|
||||
'alert.success.setting_updated' => 'Die Einstellungen wurden übernommen',
|
||||
'alert.error.not_exist' => 'Diese Einstellung existiert nicht.',
|
||||
'alert.error.file_too_big' => 'Datei zu groß',
|
||||
'alert.error.file_not_stored' => 'Datei konnte nicht gespeichert werden',
|
||||
'alert.success.user_created' => 'Nutzer erfolgreich erstellt',
|
||||
'alert.success.user_updated' => 'Nutzer erfolgreich aktualisiert',
|
||||
'alert.success.user_deleted' => 'Nutzer erfolgreich gelöscht',
|
||||
|
||||
@@ -29,9 +29,11 @@ return array (
|
||||
'settings.custom_css' => 'Custom CSS',
|
||||
'settings.custom_js' => 'Custom JavaScript',
|
||||
'settings.treat_tags_as' => 'Treat Tags As:',
|
||||
'settings.default_tag' => 'Default tag',
|
||||
'settings.folders' => 'Folders',
|
||||
'settings.tags' => 'Tags',
|
||||
'settings.categories' => 'Categories',
|
||||
'settings.skip_tls_verification' => 'Skip TLS Verification (for self-signed certificates)',
|
||||
'options.none' => '- not set -',
|
||||
'options.google' => 'Google',
|
||||
'options.ddg' => 'DuckDuckGo',
|
||||
|
||||
Generated
+273
-279
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "Heimdall",
|
||||
"name": "Heimdall-LS",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"select2": "^4.0.13",
|
||||
"select2": "~4.0.13",
|
||||
"sortablejs": "^1.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -24,26 +24,14 @@
|
||||
"webpack-cli": "^6.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
@@ -52,30 +40,32 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/compat-data": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz",
|
||||
"integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
||||
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz",
|
||||
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.0",
|
||||
"@babel/helper-compilation-targets": "^7.27.2",
|
||||
"@babel/helper-module-transforms": "^7.27.3",
|
||||
"@babel/helpers": "^7.27.6",
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/template": "^7.27.2",
|
||||
"@babel/traverse": "^7.28.0",
|
||||
"@babel/types": "^7.28.0",
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/helper-compilation-targets": "^7.29.7",
|
||||
"@babel/helper-module-transforms": "^7.29.7",
|
||||
"@babel/helpers": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debug": "^4.1.0",
|
||||
"gensync": "^1.0.0-beta.2",
|
||||
@@ -91,13 +81,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz",
|
||||
"integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
||||
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/types": "^7.28.0",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
@@ -119,13 +110,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-compilation-targets": {
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
|
||||
"integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
||||
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.27.2",
|
||||
"@babel/helper-validator-option": "^7.27.1",
|
||||
"@babel/compat-data": "^7.29.7",
|
||||
"@babel/helper-validator-option": "^7.29.7",
|
||||
"browserslist": "^4.24.0",
|
||||
"lru-cache": "^5.1.1",
|
||||
"semver": "^6.3.1"
|
||||
@@ -189,10 +181,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-globals": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
||||
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -211,27 +204,29 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-imports": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
|
||||
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
||||
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.27.1",
|
||||
"@babel/types": "^7.27.1"
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-transforms": {
|
||||
"version": "7.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz",
|
||||
"integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
||||
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.27.1",
|
||||
"@babel/traverse": "^7.27.3"
|
||||
"@babel/helper-module-imports": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -253,10 +248,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-plugin-utils": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
|
||||
"integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
|
||||
"integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -309,28 +305,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
|
||||
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-option": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
||||
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
||||
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -350,25 +349,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.27.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz",
|
||||
"integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
||||
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.27.2",
|
||||
"@babel/types": "^7.27.6"
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
|
||||
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.0"
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -955,15 +956,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-modules-systemjs": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.27.1.tgz",
|
||||
"integrity": "sha512-w5N1XzsRbc0PQStASMksmUeqECuzKuTJer7kFagK8AXgpCMkeDMO5S+aaFb7A51ZYDF7XI34qsTX+fkHiIm5yA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.7.tgz",
|
||||
"integrity": "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-transforms": "^7.27.1",
|
||||
"@babel/helper-plugin-utils": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.27.1",
|
||||
"@babel/traverse": "^7.27.1"
|
||||
"@babel/helper-module-transforms": "^7.29.7",
|
||||
"@babel/helper-plugin-utils": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -1491,31 +1493,33 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/parser": "^7.27.2",
|
||||
"@babel/types": "^7.27.1"
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz",
|
||||
"integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
|
||||
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.0",
|
||||
"@babel/helper-globals": "^7.28.0",
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/template": "^7.27.2",
|
||||
"@babel/types": "^7.28.0",
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/helper-globals": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"debug": "^4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1523,13 +1527,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz",
|
||||
"integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.27.1"
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -1658,6 +1663,17 @@
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/remapping": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
@@ -2036,15 +2052,6 @@
|
||||
"integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@trysound/sax": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
|
||||
"integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -2915,12 +2922,6 @@
|
||||
"minimalistic-assert": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1.js/node_modules/bn.js": {
|
||||
"version": "4.12.2",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
|
||||
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/assert": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/assert/-/assert-1.5.1.tgz",
|
||||
@@ -3069,7 +3070,8 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
@@ -3119,10 +3121,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz",
|
||||
"integrity": "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==",
|
||||
"dev": true
|
||||
"version": "5.2.4",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.4.tgz",
|
||||
"integrity": "sha512-QL7sb18rJ1PbdsKsqPA0guxL563vIMwRHgzNrW/uzQuRGN1Cjqd/wonUBAVqHox9KwzHA6vCbM0lXx3k4iQMow==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.3",
|
||||
@@ -3163,21 +3166,6 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/body-parser/node_modules/qs": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"side-channel": "^1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/bonjour-service": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz",
|
||||
@@ -3201,10 +3189,11 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
@@ -3730,7 +3719,8 @@
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat/node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
@@ -3858,12 +3848,6 @@
|
||||
"elliptic": "^6.5.3"
|
||||
}
|
||||
},
|
||||
"node_modules/create-ecdh/node_modules/bn.js": {
|
||||
"version": "4.12.2",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
|
||||
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/create-hash": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
|
||||
@@ -4354,12 +4338,6 @@
|
||||
"randombytes": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/diffie-hellman/node_modules/bn.js": {
|
||||
"version": "4.12.2",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
|
||||
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/dir-glob": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
|
||||
@@ -4557,12 +4535,6 @@
|
||||
"minimalistic-crypto-utils": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/elliptic/node_modules/bn.js": {
|
||||
"version": "4.12.2",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
|
||||
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
@@ -5218,21 +5190,6 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/express/node_modules/qs": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"side-channel": "^1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -5286,9 +5243,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
|
||||
"integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==",
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
|
||||
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -5299,7 +5256,8 @@
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
]
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fastest-levenshtein": {
|
||||
"version": "1.0.16",
|
||||
@@ -5492,15 +5450,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||
"dev": true
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.9",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
|
||||
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -5508,6 +5467,7 @@
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
@@ -6174,10 +6134,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-middleware": {
|
||||
"version": "2.0.9",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
|
||||
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz",
|
||||
"integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-proxy": "^1.17.8",
|
||||
"http-proxy": "^1.18.1",
|
||||
@@ -6325,10 +6286,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "5.1.3",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz",
|
||||
"integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==",
|
||||
"dev": true
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz",
|
||||
"integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
@@ -6957,10 +6919,21 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
@@ -7265,13 +7238,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/launch-editor": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.10.0.tgz",
|
||||
"integrity": "sha512-D7dBRJo/qcGX9xlvt/6wUYzQxjh5G1RvZPgPv8vi4KRU99DVQL/oW7tnVOCCTm2HGeo3C5HvGE5Yrh6UBoZ0vA==",
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz",
|
||||
"integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picocolors": "^1.0.0",
|
||||
"shell-quote": "^1.8.1"
|
||||
"picocolors": "^1.1.1",
|
||||
"shell-quote": "^1.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/levn": {
|
||||
@@ -7341,10 +7315,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"dev": true
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.debounce": {
|
||||
"version": "4.0.8",
|
||||
@@ -7520,12 +7495,6 @@
|
||||
"miller-rabin": "bin/miller-rabin"
|
||||
}
|
||||
},
|
||||
"node_modules/miller-rabin/node_modules/bn.js": {
|
||||
"version": "4.12.2",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
|
||||
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
@@ -7620,10 +7589,11 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
@@ -7660,9 +7630,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"version": "3.3.15",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -7670,6 +7640,7 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
@@ -7716,10 +7687,11 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/node-forge": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
|
||||
"integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
|
||||
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
|
||||
"dev": true,
|
||||
"license": "(BSD-3-Clause OR GPL-2.0)",
|
||||
"engines": {
|
||||
"node": ">= 6.13.0"
|
||||
}
|
||||
@@ -7966,10 +7938,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/on-headers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
|
||||
"integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -8241,10 +8214,11 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"dev": true
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "4.0.0",
|
||||
@@ -8310,10 +8284,11 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
@@ -8395,9 +8370,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"version": "8.5.16",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -8413,8 +8388,9 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -9090,12 +9066,6 @@
|
||||
"safe-buffer": "^5.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/public-encrypt/node_modules/bn.js": {
|
||||
"version": "4.12.2",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
|
||||
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
|
||||
@@ -9103,12 +9073,14 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
"es-define-property": "^1.0.1",
|
||||
"side-channel": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
@@ -9685,6 +9657,16 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
|
||||
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/schema-utils": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz",
|
||||
@@ -9785,12 +9767,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/serialize-javascript": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
|
||||
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
|
||||
"version": "7.0.7",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.7.tgz",
|
||||
"integrity": "sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"randombytes": "^2.1.0"
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-index": {
|
||||
@@ -9998,10 +9981,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
|
||||
"integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
@@ -10016,14 +10000,15 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"object-inspect": "^1.13.4",
|
||||
"side-channel-list": "^1.0.1",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
@@ -10035,13 +10020,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
"object-inspect": "^1.13.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -10454,17 +10440,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/svgo": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz",
|
||||
"integrity": "sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==",
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.2.tgz",
|
||||
"integrity": "sha512-TyzE4NVGLUFy+H/Uy4N6c3G0HEeprsVfge6Lmq+0FdQQ/zqoVYB62IsBZORsiL+o96s6ff/V6/3UQo/C0cgCAA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@trysound/sax": "0.2.0",
|
||||
"commander": "^7.2.0",
|
||||
"css-select": "^4.1.3",
|
||||
"css-tree": "^1.1.3",
|
||||
"csso": "^4.2.0",
|
||||
"picocolors": "^1.0.0",
|
||||
"sax": "^1.5.0",
|
||||
"stable": "^0.1.8"
|
||||
},
|
||||
"bin": {
|
||||
@@ -10986,12 +10973,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"version": "11.1.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
|
||||
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
@@ -11680,10 +11672,11 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
@@ -11725,10 +11718,11 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
||||
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
|
||||
"version": "1.10.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
|
||||
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
|
||||
+28
-1
@@ -26,7 +26,34 @@
|
||||
"webpack-cli": "^6.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"select2": "^4.0.13",
|
||||
"select2": "~4.0.13",
|
||||
"sortablejs": "^1.15.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@babel/core": "^7.29.7",
|
||||
"@babel/plugin-transform-modules-systemjs": "^7.29.7",
|
||||
"bn.js": "^5.2.4",
|
||||
"brace-expansion": "^1.1.16",
|
||||
"fast-uri": "^3.1.3",
|
||||
"flatted": "^3.4.2",
|
||||
"follow-redirects": "^1.16.0",
|
||||
"http-proxy-middleware": "^2.0.10",
|
||||
"immutable": "^5.1.9",
|
||||
"js-yaml": "^4.3.0",
|
||||
"launch-editor": "^2.14.1",
|
||||
"lodash": "^4.18.1",
|
||||
"minimatch": "^3.1.5",
|
||||
"node-forge": "^1.4.0",
|
||||
"on-headers": "^1.1.0",
|
||||
"path-to-regexp": "^0.1.13",
|
||||
"picomatch": "^2.3.2",
|
||||
"postcss": "^8.5.16",
|
||||
"qs": "^6.15.3",
|
||||
"serialize-javascript": "^7.0.7",
|
||||
"shell-quote": "^1.9.0",
|
||||
"svgo": "^2.8.2",
|
||||
"uuid": "^11.1.1",
|
||||
"ws": "^8.21.0",
|
||||
"yaml": "^1.10.3"
|
||||
}
|
||||
}
|
||||
|
||||
+9
-9
@@ -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>
|
||||
|
||||
Vendored
+2184
-2
File diff suppressed because one or more lines are too long
Vendored
+4652
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Generated
+2
-3
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"/js/dummy.js": "/js/dummy.js?id=daec5f3b283a510837bec36ca3868a54",
|
||||
"/css/app.css": "/css/app.css?id=8e5c9ae35dd160a37c9d33d663f996b9",
|
||||
"/js/app.js": "/js/app.js?id=19052619246fec368cad13937c62d850"
|
||||
"/css/app.css": "/css/app.css?id=271cb5f5a1f91d0a6dfbc65e374ffc14",
|
||||
"/js/app.js": "/js/app.js?id=2ebeb753597d1cbbf88d8bc652e4af5b"
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ Supported applications are recognized by the title of the application as entered
|
||||
[](https://apps.heimdall.site/applications/foundation)
|
||||
|
||||
## Installing
|
||||
Apart from the Laravel 10 dependencies, namely PHP >= 8.1, Ctype PHP Extension, cURL PHP Extension, DOM PHP Extension, Fileinfo PHP Extension, Filter PHP Extension, Hash PHP Extension, Mbstring PHP Extension, OpenSSL PHP Extension, PCRE PHP Extension, PDO PHP Extension, Session PHP Extension, Tokenizer PHP Extension, XML PHP Extension, the only other thing Heimdall needs is sqlite support and zip support (php-zip).
|
||||
Apart from the Laravel 11 dependencies, namely PHP >= 8.4, Ctype PHP Extension, cURL PHP Extension, DOM PHP Extension, Fileinfo PHP Extension, Filter PHP Extension, Hash PHP Extension, Mbstring PHP Extension, OpenSSL PHP Extension, PCRE PHP Extension, PDO PHP Extension, Session PHP Extension, Tokenizer PHP Extension, XML PHP Extension, the only other thing Heimdall needs is sqlite support and zip support (php-zip).
|
||||
|
||||
If you find you can't change the background make sure `php_fileinfo` is enabled in your php.ini. I believe `php_fileinfo` should be enabled by default, but one user came across the issue on a windows system.
|
||||
|
||||
|
||||
+116
-2
@@ -108,11 +108,90 @@ $.when($.ready).then(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// Autocomplete functionality
|
||||
let autocompleteTimeout = null;
|
||||
let currentAutocompleteRequest = null;
|
||||
|
||||
function hideAutocomplete() {
|
||||
$("#search-autocomplete").remove();
|
||||
}
|
||||
|
||||
function showAutocomplete(suggestions, inputElement) {
|
||||
hideAutocomplete();
|
||||
|
||||
if (!suggestions || suggestions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $input = $(inputElement);
|
||||
const position = $input.position();
|
||||
const width = $input.outerWidth();
|
||||
|
||||
const $autocomplete = $('<div id="search-autocomplete"></div>');
|
||||
|
||||
suggestions.forEach((suggestion) => {
|
||||
const $item = $('<div class="autocomplete-item"></div>')
|
||||
.text(suggestion)
|
||||
.on("click", () => {
|
||||
$input.val(suggestion);
|
||||
hideAutocomplete();
|
||||
$input.closest("form").submit();
|
||||
});
|
||||
$autocomplete.append($item);
|
||||
});
|
||||
|
||||
$autocomplete.css({
|
||||
position: "absolute",
|
||||
top: `${position.top + $input.outerHeight()}px`,
|
||||
left: `${position.left}px`,
|
||||
width: `${width}px`,
|
||||
});
|
||||
|
||||
$input.closest("#search-container").append($autocomplete);
|
||||
}
|
||||
|
||||
function fetchAutocomplete(query, provider) {
|
||||
// Cancel previous request if any
|
||||
if (currentAutocompleteRequest) {
|
||||
currentAutocompleteRequest.abort();
|
||||
}
|
||||
|
||||
if (!query || query.trim().length < 2) {
|
||||
hideAutocomplete();
|
||||
return;
|
||||
}
|
||||
|
||||
currentAutocompleteRequest = $.ajax({
|
||||
url: `${base}search/autocomplete`,
|
||||
method: "GET",
|
||||
data: {
|
||||
q: query,
|
||||
provider,
|
||||
},
|
||||
success(data) {
|
||||
const inputElement = $("#search-container input[name=q]")[0];
|
||||
showAutocomplete(data, inputElement);
|
||||
},
|
||||
error() {
|
||||
hideAutocomplete();
|
||||
},
|
||||
complete() {
|
||||
currentAutocompleteRequest = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
$("#search-container")
|
||||
.on("input", "input[name=q]", function () {
|
||||
const search = this.value;
|
||||
const items = $("#sortable").find(".item-container");
|
||||
if ($("#search-container select[name=provider]").val() === "tiles") {
|
||||
// Get provider from either select or hidden input
|
||||
const provider =
|
||||
$("#search-container select[name=provider]").val() ||
|
||||
$("#search-container input[name=provider]").val();
|
||||
|
||||
if (provider === "tiles") {
|
||||
hideAutocomplete();
|
||||
if (search.length > 0) {
|
||||
items.hide();
|
||||
items
|
||||
@@ -126,6 +205,12 @@ $.when($.ready).then(() => {
|
||||
}
|
||||
} else {
|
||||
items.show();
|
||||
|
||||
// Debounce autocomplete requests
|
||||
clearTimeout(autocompleteTimeout);
|
||||
autocompleteTimeout = setTimeout(() => {
|
||||
fetchAutocomplete(search, provider);
|
||||
}, 300);
|
||||
}
|
||||
})
|
||||
.on("change", "select[name=provider]", function () {
|
||||
@@ -147,9 +232,24 @@ $.when($.ready).then(() => {
|
||||
} else {
|
||||
$("#search-container button").show();
|
||||
items.show();
|
||||
hideAutocomplete();
|
||||
}
|
||||
});
|
||||
|
||||
// Hide autocomplete when clicking outside
|
||||
$(document).on("click", (e) => {
|
||||
if (!$(e.target).closest("#search-container").length) {
|
||||
hideAutocomplete();
|
||||
}
|
||||
});
|
||||
|
||||
// Hide autocomplete on Escape key
|
||||
$(document).on("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
hideAutocomplete();
|
||||
}
|
||||
});
|
||||
|
||||
$("#search-container select[name=provider]").trigger("change");
|
||||
|
||||
$("#app")
|
||||
@@ -210,7 +310,12 @@ $.when($.ready).then(() => {
|
||||
data.url = apiurl;
|
||||
$(".config-item").each(function () {
|
||||
const config = $(this).data("config");
|
||||
data[config] = $(this).val();
|
||||
// For checkboxes, use checked state instead of value attribute
|
||||
if ($(this).is(":checkbox")) {
|
||||
data[config] = $(this).is(":checked") ? "1" : "0";
|
||||
} else {
|
||||
data[config] = $(this).val();
|
||||
}
|
||||
});
|
||||
|
||||
data.id = $("form[data-item-id]").data("item-id");
|
||||
@@ -234,6 +339,15 @@ $.when($.ready).then(() => {
|
||||
);
|
||||
});
|
||||
});
|
||||
// Auto-select the configured default tag on load (tags mode only)
|
||||
const taglist = document.getElementById("taglist");
|
||||
if (taglist !== null) {
|
||||
const defaultTag = taglist.getAttribute("data-default-tag");
|
||||
if (typeof defaultTag === "string" && defaultTag !== "") {
|
||||
$(`#taglist .tag[data-tag="tag-${defaultTag}"]`).trigger("click");
|
||||
}
|
||||
}
|
||||
|
||||
$("#pinlist").on("click", "a", function (e) {
|
||||
e.preventDefault();
|
||||
const current = $(this);
|
||||
|
||||
Vendored
+11
-2
@@ -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();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -722,9 +722,10 @@ div.create {
|
||||
flex: 0 0 60px;
|
||||
}
|
||||
.app-icon {
|
||||
max-width: 60px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
max-height: 60px;
|
||||
}
|
||||
|
||||
.sidenav {
|
||||
@@ -926,6 +927,12 @@ div.create {
|
||||
max-width: 620px;
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
|
||||
// Reduce width when there's no select dropdown (only has hidden input)
|
||||
&:has(input[name="provider"][type="hidden"]) {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
form {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -933,7 +940,6 @@ div.create {
|
||||
background: white;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0px 0px 5px 0 rgba(0,0,0,0.4);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -945,6 +951,11 @@ div.create {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
// When there's no select dropdown, round the input's left corners
|
||||
input[name="q"]:first-child {
|
||||
border-top-left-radius: 5px;
|
||||
border-bottom-left-radius: 5px;
|
||||
}
|
||||
button {
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
@@ -965,7 +976,42 @@ div.create {
|
||||
background: #f5f5f5;
|
||||
border: none;
|
||||
border-right: 1px solid #ddd;
|
||||
border-top-left-radius: 5px;
|
||||
border-bottom-left-radius: 5px;
|
||||
}
|
||||
// When select exists, remove input's left border radius
|
||||
select ~ input[name="q"] {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
#search-autocomplete {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-top: none;
|
||||
border-radius: 0 0 5px 5px;
|
||||
box-shadow: 0px 4px 8px 0 rgba(0,0,0,0.2);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
|
||||
.autocomplete-item {
|
||||
padding: 12px 15px;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ui-autocomplete {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<section class="module-container">
|
||||
@if($enable_auth_admin_controls)
|
||||
<header>
|
||||
{{ html()->hidden('app_id', $item->id) }}
|
||||
<div class="section-title">{{ __('app.apps.preview') }}</div>
|
||||
<div class="module-actions">
|
||||
<div class="toggleinput">
|
||||
@@ -120,7 +121,7 @@
|
||||
@if(isset($item) && $item->enhanced())
|
||||
|
||||
<div id="sapconfig" style="display: block;">
|
||||
@if(isset($item))
|
||||
@if(isset($item) && $item->class)
|
||||
@include('SupportedApps::'.App\Item::nameFromClass($item->class).'.config')
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -171,13 +171,14 @@
|
||||
})
|
||||
|
||||
function appload(appvalue) {
|
||||
const itemId = $('input[name="item_id"]').val();
|
||||
if(appvalue == 'null') {
|
||||
$('#sapconfig').html('').hide();
|
||||
$('#tile-preview .app-icon').attr('src', '/img/heimdall-icon-small.png');
|
||||
$('#appimage').html("<img src='/img/heimdall-icon-small.png' />");
|
||||
$('#sapconfig').html('').hide();
|
||||
} else {
|
||||
$.post('{{ route('appload') }}', { app: appvalue }, function(data) {
|
||||
$.post('{{ route('appload') }}', { app: appvalue, item_id: itemId }, function(data) {
|
||||
// Main details
|
||||
$('#appimage').html("<img src='"+data.iconview+"' /><input type='hidden' name='icon' value='"+data.iconview+"' />");
|
||||
$('input[name=colour]').val(data.colour);
|
||||
@@ -194,6 +195,21 @@
|
||||
if(data.custom != null) {
|
||||
$.get(base+'view/'+data.custom, function(getdata) {
|
||||
$('#sapconfig').html(getdata).show();
|
||||
// Populate fields in the loaded form with description data
|
||||
if (data.description) {
|
||||
const description = JSON.parse(data.appvalue);
|
||||
Object.keys(description).forEach(function(key) {
|
||||
const value = description[key];
|
||||
const field = $(`#sapconfig [name="config[${key}]"]`);
|
||||
if (field.length) {
|
||||
if (field.is(':checkbox')) {
|
||||
field.prop('checked', value);
|
||||
} else {
|
||||
field.val(value);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
$('#sapconfig').html('').hide();
|
||||
|
||||
@@ -3,7 +3,7 @@ $treat_tags_as = \App\Setting::fetch('treat_tags_as');
|
||||
?>
|
||||
@if( $treat_tags_as == 'tags')
|
||||
@if($taglist->first())
|
||||
<div id="taglist" class="taglist">
|
||||
<div id="taglist" class="taglist" data-default-tag="{{ \App\Setting::fetch('default_tag') }}">
|
||||
<div class="tag white current" data-tag="all">All</div>
|
||||
@foreach($taglist as $tag)
|
||||
<div class="tag link{{ title_color($tag->colour) }}" style="background-color: {{ $tag->colour }}" data-tag="tag-{{ $tag->tag_url }}">{{ $tag->title }}</div>
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
<section class="module-container">
|
||||
@if($enable_auth_admin_controls)
|
||||
|
||||
<header>
|
||||
<div class="section-title">{{ __($setting->label) }}</div>
|
||||
<div class="section-title">
|
||||
{{ __($setting->label) }}
|
||||
@if($setting->type === 'image')
|
||||
@php
|
||||
$max_upload = ini_get('upload_max_filesize');
|
||||
$max_upload_bytes = parse_size($max_upload);
|
||||
@endphp
|
||||
<a class="settinglink" target="_blank" rel="nofollow noreferer" href="https://github.com/linuxserver/Heimdall?tab=readme-ov-file#new-background-image-not-being-set">({{ format_bytes($max_upload_bytes, false) }})</a>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
<div class="module-actions">
|
||||
<button type="submit"class="button"><i class="fa fa-save"></i><span>{{ __('app.buttons.save') }}</span></button>
|
||||
<a href="{{ route('settings.index', []) }}" class="button"><i class="fa fa-ban"></i><span>{{ __('app.buttons.cancel') }}</span></a>
|
||||
|
||||
@@ -75,6 +75,7 @@ Route::post('test_config', [ItemController::class,'testConfig'])->name('test_con
|
||||
Route::get('get_stats/{id}', [ItemController::class,'getStats'])->name('get_stats');
|
||||
|
||||
Route::get('/search', [SearchController::class,'index'])->name('search');
|
||||
Route::get('/search/autocomplete', [SearchController::class,'autocomplete'])->name('search.autocomplete');
|
||||
|
||||
Route::get('view/{name_view}', function ($name_view) {
|
||||
return view('SupportedApps::'.$name_view)->render();
|
||||
|
||||
@@ -18,6 +18,7 @@ bing:
|
||||
method: get
|
||||
target: _blank
|
||||
query: q
|
||||
autocomplete: https://api.bing.com/osjson.aspx?query={query}
|
||||
|
||||
ddg:
|
||||
id: ddg
|
||||
@@ -26,6 +27,7 @@ ddg:
|
||||
method: get
|
||||
target: _blank
|
||||
query: q
|
||||
autocomplete: https://duckduckgo.com/ac/?q={query}&type=list
|
||||
|
||||
google:
|
||||
id: google
|
||||
@@ -34,6 +36,7 @@ google:
|
||||
method: get
|
||||
target: _blank
|
||||
query: q
|
||||
autocomplete: https://suggestqueries.google.com/complete/search?output=toolbar&hl=en&q={query}
|
||||
|
||||
startpage:
|
||||
id: startpage
|
||||
|
||||
@@ -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.']);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace Tests\Feature;
|
||||
|
||||
use App\Item;
|
||||
use App\ItemTag;
|
||||
use App\Setting;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -80,4 +81,25 @@ class DashTest extends TestCase
|
||||
$response->assertSee('Tag 1');
|
||||
$response->assertSee('Tag 2');
|
||||
}
|
||||
|
||||
public function test_dash_exposes_the_configured_default_tag(): void
|
||||
{
|
||||
$this->seed();
|
||||
|
||||
Setting::where('key', 'treat_tags_as')->update(['value' => 'tags']);
|
||||
Setting::where('key', 'default_tag')->update(['value' => 'home-dashboard']);
|
||||
|
||||
Item::factory()->create([
|
||||
'title' => 'Home',
|
||||
'url' => 'home-dashboard',
|
||||
'type' => 1,
|
||||
'pinned' => 1,
|
||||
'user_id' => 0,
|
||||
]);
|
||||
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('data-default-tag="home-dashboard"', false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>']);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -32,22 +32,4 @@ class SearchTest extends TestCase
|
||||
$response->assertStatus(404); // Assert that the response status is 404
|
||||
}
|
||||
|
||||
public function test_search_page_without_query_parameter(): void
|
||||
{
|
||||
$provider = 'google'; // Example provider
|
||||
|
||||
$response = $this->get(route('search', ['provider' => $provider]));
|
||||
|
||||
$response->assertStatus(400); // Assert that the response status is 400 (Bad Request)
|
||||
}
|
||||
|
||||
public function test_search_page_with_empty_query(): void
|
||||
{
|
||||
$provider = 'google'; // Example provider
|
||||
$query = ''; // Empty search term
|
||||
|
||||
$response = $this->get(route('search', ['provider' => $provider, 'q' => $query]));
|
||||
|
||||
$response->assertStatus(400); // Assert that the response status is 400 (Bad Request)
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -6,5 +6,42 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
//
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->guardAgainstRealDatabase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse to run the test suite against anything other than an
|
||||
* in-memory SQLite database.
|
||||
*
|
||||
* The suite uses RefreshDatabase (migrate:fresh), which would wipe
|
||||
* whatever database it is pointed at. The only supported test
|
||||
* configuration for this repo is sqlite + ':memory:'. Any other
|
||||
* connection (a real sqlite file, mysql, pgsql, ...) is rejected so
|
||||
* we can never destroy real data.
|
||||
*/
|
||||
private function guardAgainstRealDatabase(): void
|
||||
{
|
||||
$default = config('database.default');
|
||||
$driver = config("database.connections.{$default}.driver");
|
||||
$database = config("database.connections.{$default}.database");
|
||||
|
||||
if ($driver === 'sqlite' && $database === ':memory:') {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new \RuntimeException(sprintf(
|
||||
'Refusing to run tests: the default database connection (%s) is '
|
||||
. 'driver "%s" pointing at "%s". Tests only run against an '
|
||||
. 'in-memory SQLite database (driver "sqlite", database ":memory:") '
|
||||
. 'to avoid wiping real data via RefreshDatabase. Check phpunit.xml '
|
||||
. 'DB_CONNECTION/DB_DATABASE overrides.',
|
||||
$default,
|
||||
$driver,
|
||||
is_scalar($database) ? (string) $database : gettype($database)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
|
||||
namespace Tests\Unit\database\seeders;
|
||||
|
||||
use App\Item;
|
||||
use App\Setting;
|
||||
use Database\Seeders\SettingsSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SettingsSeederTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* All language keys are defined in all languages based on the en language file.
|
||||
*/
|
||||
@@ -18,4 +23,60 @@ class SettingsSeederTest extends TestCase
|
||||
|
||||
$this->assertTrue(count($languageMap) === count($languageDirectories));
|
||||
}
|
||||
|
||||
public function test_seeds_the_default_tag_setting(): void
|
||||
{
|
||||
$this->seed();
|
||||
|
||||
$setting = Setting::where('key', 'default_tag')->first();
|
||||
|
||||
$this->assertNotNull($setting);
|
||||
$this->assertSame('select', $setting->type);
|
||||
$this->assertSame(4, (int) $setting->group_id);
|
||||
}
|
||||
|
||||
public function test_default_tag_edit_value_lists_all_tags_and_a_none_option(): void
|
||||
{
|
||||
$this->seed();
|
||||
|
||||
Item::factory()->create([
|
||||
'title' => 'Home',
|
||||
'url' => 'home-dashboard',
|
||||
'type' => 1,
|
||||
'pinned' => 1,
|
||||
'user_id' => 0,
|
||||
]);
|
||||
Item::factory()->create([
|
||||
'title' => 'Media',
|
||||
'url' => 'media',
|
||||
'type' => 1,
|
||||
'pinned' => 1,
|
||||
'user_id' => 0,
|
||||
]);
|
||||
// An unpinned tag is not rendered in the dashboard taglist, so it must
|
||||
// not be offered as a default (selecting it would silently do nothing).
|
||||
Item::factory()->create([
|
||||
'title' => 'Archive',
|
||||
'url' => 'archive',
|
||||
'type' => 1,
|
||||
'pinned' => 0,
|
||||
'user_id' => 0,
|
||||
]);
|
||||
|
||||
$setting = Setting::where('key', 'default_tag')->first();
|
||||
$editValue = $setting->edit_value;
|
||||
|
||||
// A "none" option with an empty value, using the shared translation key.
|
||||
$this->assertStringContainsString('<option value="" ', $editValue);
|
||||
$this->assertStringContainsString(__('app.options.none'), $editValue);
|
||||
|
||||
// One option per pinned tag: the slug as the value, the raw title as the label.
|
||||
$this->assertStringContainsString('value="home-dashboard"', $editValue);
|
||||
$this->assertStringContainsString('>Home</option>', $editValue);
|
||||
$this->assertStringContainsString('value="media"', $editValue);
|
||||
$this->assertStringContainsString('>Media</option>', $editValue);
|
||||
|
||||
// The unpinned tag is excluded.
|
||||
$this->assertStringNotContainsString('value="archive"', $editValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('中文 测试'));
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
Vendored
+12
-2
@@ -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';
|
||||
|
||||
Vendored
+8
-8
@@ -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 {}
|
||||
+9
@@ -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 = [])
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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':
|
||||
|
||||
+13
-2
@@ -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
@@ -40,7 +40,17 @@ class QueryParser extends AbstractParser
|
||||
ResponseInterface $response
|
||||
) {
|
||||
$output = $this->api->getOperation($command->getName())->getOutput();
|
||||
$xml = $this->parseXml($response->getBody(), $response);
|
||||
// Read the full payload, even in non-seekable streams
|
||||
$rawBody = AbstractParser::getBodyContents($response);
|
||||
// Just parse when the body is not empty
|
||||
$xml = !empty($rawBody)
|
||||
? $this->parseXml($rawBody, $response)
|
||||
: null;
|
||||
|
||||
// Empty request bodies should not be deserialized.
|
||||
if (is_null($xml)) {
|
||||
return new Result();
|
||||
}
|
||||
|
||||
if ($this->honorResultWrapper && $output['resultWrapper']) {
|
||||
$xml = $xml->{$output['resultWrapper']};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user