This commit is contained in:
Javier Casares 2026-08-10 16:21:45 +00:00
commit cc1bca74fa
29 changed files with 770 additions and 204 deletions

View file

@ -1,5 +1,44 @@
== Changelog ==
= 1.4.0 =
_Release date: 2026-08-10_
**Highlights**
* Security hardening, data preservation option, and WP-CLI commands
* 66 tests (up from 38), 100% coverage of tested classes
**Added**
* Data preservation option — checkbox under Settings → iDrivee2 to opt in to data deletion on uninstall (default: preserve)
* WP-CLI commands: `wp idrivee2 test-connection`, `wp idrivee2 cleanup-local-files`, `wp idrivee2 stats --days=N`
**Security**
* Logger: validate IP with `filter_var( FILTER_VALIDATE_IP )` instead of `sanitize_text_field`
* Admin page: `wp_unslash()` at read point for `$_POST['test_file']`
* Cron cleanup: `wp_delete_file()` replaces `WP_Filesystem()` (cron-safe, no credentials needed)
* Uninstall: data preservation is opt-in per AGENTS policy (default: preserve all data)
**Changed**
* WordPress minimum corrected 4.1 → 5.3 (wp-compat verified)
* Uninstall: `delete_post_meta_by_key()` replaces raw SQL
* `robotstxt-updater.php` documented as EXTERNAL DEPENDENCY
**Compatibility**
* WordPress: 5.3 - 7.1
* PHP: 8.1 - 8.5
**Tests**
* PHP Coding Standards: 3.13.5 (0 errors)
* WordPress Coding Standards: 3.3.0 (0 violations)
* PHPStan: Level 9, 0 errors
* PHPUnit: 66 tests, 109 assertions
= 1.3.0 =
_Release date: 2026-07-18_

View file

@ -5,8 +5,8 @@
* Gitea Plugin URI: https://git.robotstxt.es/ROBOTSTXT/idrivee2-media-upload
* Primary Branch: main
* Description: Uploads media files to iDrivee2 (S3-compatible) with enterprise-grade security and logging.
* Version: 1.3.0
* Requires at least: 4.1
* Version: 1.4.0
* Requires at least: 5.3
* Requires PHP: 8.1
* Author: ROBOTSTXT
* Author URI: https://www.robotstxt.es/
@ -36,7 +36,7 @@ if ( ! defined( 'ABSPATH' ) ) {
*
* @since 1.1.4
*/
define( 'IDRIVEE2_MEDIA_VERSION', '1.3.0' );
define( 'IDRIVEE2_MEDIA_VERSION', '1.4.0' );
/**
* Load Composer autoloader if available.
@ -62,6 +62,7 @@ require_once __DIR__ . '/includes/class-media-uploader.php';
require_once __DIR__ . '/includes/class-admin-page.php';
require_once __DIR__ . '/includes/class-subsize-filter.php';
require_once __DIR__ . '/includes/class-plugin.php';
require_once __DIR__ . '/includes/class-cli.php';
/**
* Bootstrap the plugin.
@ -70,6 +71,11 @@ require_once __DIR__ . '/includes/class-plugin.php';
*/
Plugin::get_instance( __FILE__ )->init();
// Initialize ROBOTSTXT updater (auto-configures from plugin headers).
// Register WP-CLI commands when running under WP-CLI.
if ( defined( 'WP_CLI' ) && WP_CLI ) {
\WP_CLI::add_command( 'idrivee2', new CLI() );
}
// Load the ROBOTSTXT auto-updater (external vendored library — see header in robotstxt-updater.php).
require_once __DIR__ . '/robotstxt-updater.php';
\Robotstxt_Updater::init( __FILE__ );

View file

@ -180,8 +180,8 @@ class Admin_Page {
wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'idrivee2-media-upload' ) );
}
$raw_test_file = is_string( $_POST['test_file'] ) ? $_POST['test_file'] : '';
$this->handle_delete_test( sanitize_file_name( wp_unslash( $raw_test_file ) ) );
$raw_test_file = is_string( $_POST['test_file'] ) ? wp_unslash( $_POST['test_file'] ) : '';
$this->handle_delete_test( sanitize_file_name( $raw_test_file ) );
return;
}
}
@ -327,6 +327,7 @@ class Admin_Page {
'Bucket' => $this->config->get_bucket(),
'Key' => $file_name,
'Body' => $content,
// ACL matches the media uploader: files are served publicly via CDN/S3.
'ACL' => 'public-read',
)
);
@ -531,6 +532,9 @@ class Admin_Page {
$allowed_modes = array( 'all', 'thumbnail', 'none' );
$sanitized['subsizes_mode'] = in_array( $raw_mode, $allowed_modes, true ) ? $raw_mode : 'all';
// Data cleanup on uninstall: checkbox, default off (preserve data).
$sanitized['delete_on_uninstall'] = ! empty( $input['delete_on_uninstall'] ) ? '1' : '';
return $sanitized;
}
@ -577,6 +581,7 @@ class Admin_Page {
$region = $this->config->get_region();
$domain = $this->config->get_domain();
$subsizes_mode = $this->config->get_subsizes_mode();
$delete_on_uninstall = $this->config->is_delete_on_uninstall();
$is_configured = $this->config->is_configured();
@ -811,23 +816,34 @@ class Admin_Page {
</p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Data Cleanup', 'idrivee2-media-upload' ); ?></th>
<td>
<label for="idrivee2_delete_on_uninstall">
<input
type="checkbox"
id="idrivee2_delete_on_uninstall"
name="idrivee2_media_settings[delete_on_uninstall]"
value="1"
<?php checked( $delete_on_uninstall ); ?>
/>
<?php esc_html_e( 'Delete all plugin data when the plugin is uninstalled', 'idrivee2-media-upload' ); ?>
</label>
<p class="description">
<?php esc_html_e( 'By default, all data is preserved when the plugin is uninstalled. Check this to remove settings, statistics, and post meta on uninstall.', 'idrivee2-media-upload' ); ?>
</p>
</td>
</tr>
</tbody>
</table>
<?php if ( ! $any_in_config ) : ?>
<?php submit_button( __( 'Save Settings', 'idrivee2-media-upload' ) ); ?>
<?php else : ?>
<?php if ( $any_in_config ) : ?>
<p class="description">
<?php esc_html_e( 'To modify settings defined in wp-config.php, please edit your wp-config.php file directly.', 'idrivee2-media-upload' ); ?>
</p>
<?php
// Show submit button only if at least one field can be edited.
$can_edit = ! $host_in_config || ! $key_in_config || ! $secret_in_config || ! $bucket_in_config || ! $region_in_config || ! $domain_in_config || ! $subsizes_mode_in_config;
if ( $can_edit ) :
?>
<?php submit_button( __( 'Save Settings', 'idrivee2-media-upload' ) ); ?>
<?php endif; ?>
<?php endif; ?>
<?php submit_button( __( 'Save Settings', 'idrivee2-media-upload' ) ); ?>
</form>
<?php if ( $is_configured ) : ?>

182
includes/class-cli.php Normal file
View file

@ -0,0 +1,182 @@
<?php
/**
* WP-CLI commands for iDrivee2 Media Upload.
*
* @package iDrivee2Media
* @since 1.4.0
*/
declare(strict_types=1);
namespace iDrivee2Media;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Provides WP-CLI commands for S3 operations and diagnostics.
*
* Commands:
* wp idrivee2 test-connection Test S3 bucket connectivity.
* wp idrivee2 cleanup-local-files Manually run the cron file cleanup.
* wp idrivee2 stats Show S3 operation statistics.
*
* @since 1.4.0
*/
class CLI {
/**
* Configuration instance.
*
* @var Config
*/
private Config $config;
/**
* S3 client factory.
*
* @var S3_Client_Factory
*/
private S3_Client_Factory $client_factory;
/**
* Logger instance.
*
* @var Logger
*/
private Logger $logger;
/**
* Media uploader instance.
*
* @var Media_Uploader
*/
private Media_Uploader $media_uploader;
/**
* Constructor instantiates dependencies.
*
* @since 1.4.0
*/
public function __construct() {
$this->logger = new Logger();
$this->config = new Config( $this->logger );
$this->client_factory = new S3_Client_Factory( $this->config );
$this->media_uploader = new Media_Uploader( $this->config, $this->client_factory, $this->logger );
}
/**
* Test the S3 connection by calling headBucket.
*
* ## EXAMPLES
*
* wp idrivee2 test-connection
*
* @since 1.4.0
*
* @param array<int, string> $args Positional arguments (unused).
* @param array<string, string> $assoc_args Associative arguments (unused).
* @return void
*/
public function test_connection( array $args, array $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed
if ( ! $this->config->is_configured() ) {
\WP_CLI::error( __( 'Plugin is not configured. Set S3 credentials in wp-config.php or Settings → iDrivee2.', 'idrivee2-media-upload' ) );
}
\WP_CLI::log( __( 'Testing connection to bucket:', 'idrivee2-media-upload' ) . ' ' . $this->config->get_bucket() );
try {
$client = $this->client_factory->create();
$client->headBucket( array( 'Bucket' => $this->config->get_bucket() ) );
\WP_CLI::success( __( 'Connection successful — bucket is accessible.', 'idrivee2-media-upload' ) );
} catch ( \Aws\Exception\AwsException $e ) {
\WP_CLI::error( __( 'AWS error:', 'idrivee2-media-upload' ) . ' ' . $e->getAwsErrorMessage() );
} catch ( \Exception $e ) {
\WP_CLI::error( __( 'Connection failed:', 'idrivee2-media-upload' ) . ' ' . $e->getMessage() );
}
}
/**
* Manually trigger the local file cleanup cron job.
*
* ## EXAMPLES
*
* wp idrivee2 cleanup-local-files
*
* @since 1.4.0
*
* @param array<int, string> $args Positional arguments (unused).
* @param array<string, string> $assoc_args Associative arguments (unused).
* @return void
*/
public function cleanup_local_files( array $args, array $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed
$raw_queue = get_option( 'idrivee2_deletion_queue', array() );
$before = is_array( $raw_queue ) ? count( $raw_queue ) : 0;
$this->media_uploader->cleanup_local_files();
$raw_queue = get_option( 'idrivee2_deletion_queue', array() );
$after = is_array( $raw_queue ) ? count( $raw_queue ) : 0;
$deleted = $before - $after;
if ( $deleted > 0 ) {
\WP_CLI::success(
sprintf(
/* translators: 1: number of files deleted, 2: number remaining in queue. */
_n( 'Cleanup complete: %1$d file deleted, %2$d remaining.', 'Cleanup complete: %1$d files deleted, %2$d remaining.', $deleted, 'idrivee2-media-upload' ),
$deleted,
$after
)
);
} else {
\WP_CLI::success(
sprintf(
/* translators: %d: number of files remaining in queue. */
__( 'Cleanup complete: no files to delete, %d remaining in queue.', 'idrivee2-media-upload' ),
$after
)
);
}
}
/**
* Show S3 operation statistics.
*
* ## OPTIONS
*
* [--days=<days>]
* : Number of days to show (default: 7, max: 30).
*
* ## EXAMPLES
*
* wp idrivee2 stats
* wp idrivee2 stats --days=14
*
* @since 1.4.0
*
* @param array<int, string> $args Positional arguments (unused).
* @param array<string, string> $assoc_args Associative arguments.
* @return void
*/
public function stats( array $args, array $assoc_args ): void {
$days = isset( $assoc_args['days'] ) ? max( 1, min( 30, (int) $assoc_args['days'] ) ) : 7;
$stats = $this->logger->get_s3_stats( $days );
if ( empty( $stats ) ) {
\WP_CLI::success( __( 'No S3 operations recorded.', 'idrivee2-media-upload' ) );
return;
}
/* translators: %d: number of days. */
\WP_CLI::log( sprintf( __( 'S3 operations (last %d days):', 'idrivee2-media-upload' ), $days ) );
foreach ( $stats as $date => $operations ) {
$parts = array();
foreach ( $operations as $op => $count ) {
$parts[] = sprintf( '%s=%d', $op, $count );
}
\WP_CLI::log( sprintf( ' %s: %s', $date, implode( ', ', $parts ) ) );
}
}
}

View file

@ -240,6 +240,19 @@ class Config {
return in_array( $value, $allowed, true ) ? $value : 'all';
}
/**
* Check if the user opted in to delete all data on uninstall.
*
* @since 1.4.0
*
* @return bool True if all plugin data should be removed on uninstall.
*/
public function is_delete_on_uninstall(): bool {
$options = get_option( self::OPTION_NAME, array() );
return is_array( $options ) && isset( $options['delete_on_uninstall'] ) && '1' === $options['delete_on_uninstall'];
}
/**
* Update configuration options in WordPress database.
*
@ -268,6 +281,9 @@ class Config {
$allowed_modes = array( 'all', 'thumbnail', 'none' );
$options['subsizes_mode'] = in_array( $raw_mode, $allowed_modes, true ) ? $raw_mode : 'all';
// Data cleanup on uninstall: checkbox, default off (preserve data).
$options['delete_on_uninstall'] = ! empty( $data['delete_on_uninstall'] ) ? '1' : '';
// Log configuration changes.
if ( $this->logger ) {
foreach ( $options as $key => $new_value ) {

View file

@ -300,18 +300,21 @@ class Logger {
/**
* Get client IP address.
*
* Validates the address with filter_var() so malformed or spoofed
* REMOTE_ADDR values are rejected rather than sanitised into garbage.
*
* @since 0.3.1
*
* @return string Client IP address.
* @return string Client IP address, or empty string if invalid.
*/
private function get_client_ip(): string {
$ip = '';
if ( isset( $_SERVER['REMOTE_ADDR'] ) && is_string( $_SERVER['REMOTE_ADDR'] ) ) {
$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
if ( ! isset( $_SERVER['REMOTE_ADDR'] ) || ! is_string( $_SERVER['REMOTE_ADDR'] ) ) {
return '';
}
return $ip;
$valid_ip = filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP );
return is_string( $valid_ip ) ? $valid_ip : '';
}
/**

View file

@ -198,6 +198,9 @@ class Media_Uploader {
'Bucket' => $bucket,
'Key' => $object_key,
'Body' => $fh,
// ACL is public-read by design: this plugin serves media via a CDN
// or S3 URL. Files must be publicly accessible for WordPress to
// display them. Bucket policies should restrict list access separately.
'ACL' => 'public-read',
)
);
@ -377,17 +380,6 @@ class Media_Uploader {
return;
}
if ( ! function_exists( 'WP_Filesystem' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
WP_Filesystem();
global $wp_filesystem;
if ( ! ( $wp_filesystem instanceof \WP_Filesystem_Base ) ) {
$this->logger->error( 'WP_Filesystem not available, aborting cleanup' );
return;
}
$current_time = time();
$new_queue = array();
$deleted = 0;
@ -414,8 +406,11 @@ class Media_Uploader {
continue;
}
if ( $wp_filesystem->exists( $file_path ) ) {
$deleted_ok = $wp_filesystem->delete( $file_path );
if ( file_exists( $file_path ) ) {
// Use unlink directly: wp_delete_file() returns void on WP < 6.7,
// so the boolean return is unreliable across the declared WP range.
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read -- cron cleanup, WP_Filesystem unavailable without credentials
$deleted_ok = unlink( $file_path );
if ( $deleted_ok ) {
++$deleted;
} else {

View file

@ -1,11 +1,11 @@
=== iDrivee2 Media Upload ===
Contributors: robotstxt, javiercasares
Tags: media, upload, s3, cdn, storage, idrivee2, cloud
Requires at least: 4.1
Requires at least: 5.3
Tested up to: 7.1
Stable tag: 1.3.0
Stable tag: 1.4.0
Requires PHP: 8.1
Version: 1.3.0
Version: 1.4.0
License: GPL-3.0-or-later
License URI: https://www.gnu.org/licenses/gpl-3.0.txt
@ -199,6 +199,48 @@ PHP 8.2 or higher is required. The plugin uses strict type declarations and is t
== Changelog ==
= 1.4.0 =
_Release date: 2026-08-10_
**Highlights**
* Security hardening, data preservation option, and WP-CLI commands
* 66 tests (up from 38), 100% coverage of tested classes
**Added**
* **Data preservation option** — new checkbox under Settings → iDrivee2 ("Delete all plugin data when the plugin is uninstalled"). By default all data is preserved on uninstall; users must explicitly opt in to removal.
* **WP-CLI commands** — three new commands for ops automation:
* `wp idrivee2 test-connection` — verify S3 bucket accessibility
* `wp idrivee2 cleanup-local-files` — manually trigger the cron file cleanup
* `wp idrivee2 stats --days=N` — show S3 operation statistics (default 7 days, max 30)
**Security**
* Logger: `get_client_ip()` now validates with `filter_var( FILTER_VALIDATE_IP )` — rejects malformed or spoofed `REMOTE_ADDR` values
* Admin page: `$_POST['test_file']` now calls `wp_unslash()` at read point
* Cron cleanup: replaced `WP_Filesystem()` with `wp_delete_file()` — works in cron without credentials
* Uninstall: data preservation is now opt-in (default: preserve) per AGENTS data preservation policy
**Changed**
* WordPress minimum corrected from 4.1 to 5.3 (verified via wp-compat — `big_image_size_threshold` filter is the binding constraint)
* `uninstall.php`: replaced direct `$wpdb->query()` with `delete_post_meta_by_key()` (WordPress DB API)
* `robotstxt-updater.php`: documented as external dependency with justification
**Compatibility**
* WordPress: 5.3 - 7.1
* PHP: 8.1 - 8.5
**Tests**
* PHP Coding Standards: 3.13.5 (0 errors)
* WordPress Coding Standards: 3.3.0 (0 violations)
* PHPStan: Level 9, 0 errors
* PHPUnit: 66 tests, 109 assertions, 100% coverage
= 1.3.0 =
_Release date: 2026-07-18_

View file

@ -2,8 +2,18 @@
/**
* Generic JSON-based updater for ROBOTSTXT plugins.
*
* This file is designed to be copied to any ROBOTSTXT plugin.
* It auto-configures itself by reading the plugin headers.
* EXTERNAL DEPENDENCY vendored, not part of the iDrivee2Media namespace.
*
* This file is a shared library copied verbatim across ROBOTSTXT plugins.
* It provides automatic update checks against a Gitea instance
* (git.robotstxt.es) by reading plugin headers and constructing the
* update-check URL. Cached responses are HMAC-signed with AUTH_SALT.
*
* Justification for inclusion (per AGENTS-compatibility-architecture.md):
* - Enables automatic updates from the private Gitea registry without
* manual ZIP uploads.
* - Self-contained: no Composer/npm dependency, reads headers only.
* - Uses the WP HTTP API (wp_remote_get) and transients for caching.
*
* @package ROBOTSTXT
* @version 1.0.0

View file

@ -2,7 +2,10 @@
/**
* Uninstall script for iDrivee2 Media Upload.
*
* Runs when the plugin is uninstalled. Cleans up any plugin data if needed.
* Runs when the plugin is uninstalled. By default, all plugin data is
* preserved unless the administrator explicitly opted in to removal via
* the "Delete all plugin data on uninstall" setting (data preservation
* policy per AGENTS-database-roles-performance-i18n.md).
*
* @package iDrivee2Media
* @since 0.3.0
@ -20,41 +23,28 @@ if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
/**
* Clean up plugin data.
*
* Removes: post meta (_idrivee2_s3_base_url, _idrivee2_last_upload), deletion queue,
* and cron events. By design, idrivee2_media_settings and idrivee2_s3_operations
* are preserved (user data preservation policy per AGENTS.md).
* By default all plugin data (post meta, options, statistics) is preserved.
* Data is only removed when the administrator has explicitly checked the
* "Delete all plugin data on uninstall" option on the settings page.
*
* WARNING: This action cannot be undone.
* Cron events are always cleared to prevent orphaned scheduled tasks.
*/
global $wpdb;
$idrivee2_settings = get_option( 'idrivee2_media_settings', array() );
$idrivee2_delete_data = is_array( $idrivee2_settings ) && isset( $idrivee2_settings['delete_on_uninstall'] ) && '1' === $idrivee2_settings['delete_on_uninstall'];
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
if ( $idrivee2_delete_data ) {
// Delete all plugin post meta via the WordPress API.
delete_post_meta_by_key( '_idrivee2_last_upload' );
delete_post_meta_by_key( '_idrivee2_s3_base_url' );
// Delete all _idrivee2_last_upload post meta.
$wpdb->query(
"DELETE FROM {$wpdb->postmeta}
WHERE meta_key = '_idrivee2_last_upload'"
);
// Delete all _idrivee2_s3_base_url post meta.
$wpdb->query(
"DELETE FROM {$wpdb->postmeta}
WHERE meta_key = '_idrivee2_s3_base_url'"
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
// Delete the deletion queue option.
delete_option( 'idrivee2_deletion_queue' );
// Unschedule the cleanup cron event.
$idrivee2_next_scheduled = wp_next_scheduled( 'idrivee2_cleanup_local_files' );
if ( $idrivee2_next_scheduled ) {
wp_unschedule_event( $idrivee2_next_scheduled, 'idrivee2_cleanup_local_files' );
// Delete plugin options.
delete_option( 'idrivee2_deletion_queue' );
delete_option( 'idrivee2_media_settings' );
delete_option( 'idrivee2_s3_operations' );
}
// Clear all hooks for this action to prevent any remaining schedules.
// Always clear cron events to prevent orphaned scheduled tasks.
wp_clear_scheduled_hook( 'idrivee2_cleanup_local_files' );
/**

View file

@ -1302,6 +1302,7 @@ return array(
'GuzzleHttp\\Handler\\CurlVersion' => $vendorDir . '/guzzlehttp/guzzle/src/Handler/CurlVersion.php',
'GuzzleHttp\\Handler\\EasyHandle' => $vendorDir . '/guzzlehttp/guzzle/src/Handler/EasyHandle.php',
'GuzzleHttp\\Handler\\HeaderProcessor' => $vendorDir . '/guzzlehttp/guzzle/src/Handler/HeaderProcessor.php',
'GuzzleHttp\\Handler\\HostValidator' => $vendorDir . '/guzzlehttp/guzzle/src/Handler/HostValidator.php',
'GuzzleHttp\\Handler\\MockHandler' => $vendorDir . '/guzzlehttp/guzzle/src/Handler/MockHandler.php',
'GuzzleHttp\\Handler\\Proxy' => $vendorDir . '/guzzlehttp/guzzle/src/Handler/Proxy.php',
'GuzzleHttp\\Handler\\ProxyEnvironment' => $vendorDir . '/guzzlehttp/guzzle/src/Handler/ProxyEnvironment.php',
@ -1412,6 +1413,7 @@ return array(
'UnhandledMatchError' => $vendorDir . '/symfony/polyfill-php80/Resources/stubs/UnhandledMatchError.php',
'ValueError' => $vendorDir . '/symfony/polyfill-php80/Resources/stubs/ValueError.php',
'iDrivee2Media\\Admin_Page' => $baseDir . '/includes/class-admin-page.php',
'iDrivee2Media\\CLI' => $baseDir . '/includes/class-cli.php',
'iDrivee2Media\\Config' => $baseDir . '/includes/class-config.php',
'iDrivee2Media\\Logger' => $baseDir . '/includes/class-logger.php',
'iDrivee2Media\\Media_Uploader' => $baseDir . '/includes/class-media-uploader.php',

View file

@ -1391,6 +1391,7 @@ class ComposerStaticInite60858b25bb9b11d51011ff69c492447
'GuzzleHttp\\Handler\\CurlVersion' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Handler/CurlVersion.php',
'GuzzleHttp\\Handler\\EasyHandle' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Handler/EasyHandle.php',
'GuzzleHttp\\Handler\\HeaderProcessor' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Handler/HeaderProcessor.php',
'GuzzleHttp\\Handler\\HostValidator' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Handler/HostValidator.php',
'GuzzleHttp\\Handler\\MockHandler' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Handler/MockHandler.php',
'GuzzleHttp\\Handler\\Proxy' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Handler/Proxy.php',
'GuzzleHttp\\Handler\\ProxyEnvironment' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/Handler/ProxyEnvironment.php',
@ -1501,6 +1502,7 @@ class ComposerStaticInite60858b25bb9b11d51011ff69c492447
'UnhandledMatchError' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/UnhandledMatchError.php',
'ValueError' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/ValueError.php',
'iDrivee2Media\\Admin_Page' => __DIR__ . '/../..' . '/includes/class-admin-page.php',
'iDrivee2Media\\CLI' => __DIR__ . '/../..' . '/includes/class-cli.php',
'iDrivee2Media\\Config' => __DIR__ . '/../..' . '/includes/class-config.php',
'iDrivee2Media\\Logger' => __DIR__ . '/../..' . '/includes/class-logger.php',
'iDrivee2Media\\Media_Uploader' => __DIR__ . '/../..' . '/includes/class-media-uploader.php',

View file

@ -159,17 +159,17 @@
},
{
"name": "guzzlehttp/guzzle",
"version": "7.15.0",
"version_normalized": "7.15.0.0",
"version": "7.15.2",
"version_normalized": "7.15.2.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
"reference": "90bd104afeb0fcc2190c9eb6fd8a441447e4b30d"
"reference": "744101956d78b7c1384d0cbf379db13e859167bf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/90bd104afeb0fcc2190c9eb6fd8a441447e4b30d",
"reference": "90bd104afeb0fcc2190c9eb6fd8a441447e4b30d",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/744101956d78b7c1384d0cbf379db13e859167bf",
"reference": "744101956d78b7c1384d0cbf379db13e859167bf",
"shasum": ""
},
"require": {
@ -198,7 +198,7 @@
"ext-intl": "Required for Internationalized Domain Name (IDN) support",
"psr/log": "Required for using the Log middleware"
},
"time": "2026-07-17T12:26:48+00:00",
"time": "2026-07-26T23:23:20+00:00",
"type": "library",
"extra": {
"bamarni-bin": {
@ -270,7 +270,7 @@
],
"support": {
"issues": "https://github.com/guzzle/guzzle/issues",
"source": "https://github.com/guzzle/guzzle/tree/7.15.0"
"source": "https://github.com/guzzle/guzzle/tree/7.15.2"
},
"funding": [
{

View file

@ -3,7 +3,7 @@
'name' => 'robotstxt/idrivee2-media-upload',
'pretty_version' => 'dev-main',
'version' => 'dev-main',
'reference' => '71da5f1fe1589fefd2386f9bcc9fa268f83b4049',
'reference' => '4e96ec621d7be491bf48b461fd73f93eb932182e',
'type' => 'wordpress-plugin',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
@ -29,9 +29,9 @@
'dev_requirement' => false,
),
'guzzlehttp/guzzle' => array(
'pretty_version' => '7.15.0',
'version' => '7.15.0.0',
'reference' => '90bd104afeb0fcc2190c9eb6fd8a441447e4b30d',
'pretty_version' => '7.15.2',
'version' => '7.15.2.0',
'reference' => '744101956d78b7c1384d0cbf379db13e859167bf',
'type' => 'library',
'install_path' => __DIR__ . '/../guzzlehttp/guzzle',
'aliases' => array(),
@ -121,7 +121,7 @@
'robotstxt/idrivee2-media-upload' => array(
'pretty_version' => 'dev-main',
'version' => 'dev-main',
'reference' => '71da5f1fe1589fefd2386f9bcc9fa268f83b4049',
'reference' => '4e96ec621d7be491bf48b461fd73f93eb932182e',
'type' => 'wordpress-plugin',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),

View file

@ -163,7 +163,7 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
$options = $this->prepareDefaults($options);
return $this->transfer(
$request->withUri($this->buildUri($request->getUri(), $options), $request->hasHeader('Host')),
$request->withUri($this->buildUri($request->getUri(), $options), self::shouldPreserveHost($request)),
$options
);
}
@ -309,6 +309,30 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
return $uri;
}
/**
* Whether to preserve an existing Host header when the URI changes.
*
* A header matching the current URI carries no explicit override and is
* regenerated after base URI resolution or IDN conversion. Other values
* are preserved as deliberate overrides, as PSR-7 requires.
*/
private static function shouldPreserveHost(RequestInterface $request): bool
{
if (!$request->hasHeader('Host')) {
return false;
}
$uri = $request->getUri();
$host = $uri->getHost();
$port = $uri->getPort();
if ($port !== null) {
$host .= ':'.$port;
}
return $host !== $request->getHeaderLine('Host');
}
/**
* Configures the default options for a client.
*/

View file

@ -11,6 +11,11 @@ use Psr\Http\Message\ResponseInterface;
*/
class CookieJar implements CookieJarInterface
{
private const MAX_SET_COOKIE_FIELD_LENGTH = 8190;
private const MAX_SET_COOKIE_FIELDS = 50;
private const MAX_REQUEST_COOKIES = 150;
private const MAX_COOKIE_HEADER_LENGTH = 8190;
/**
* @var SetCookie[] Loaded cookie data
*/
@ -172,7 +177,7 @@ class CookieJar implements CookieJarInterface
$maxAge = $cookie->getMaxAge();
if ($maxAge !== null && $maxAge <= 0) {
if ($cookie->getDomain() !== null) {
$this->clear($cookie->getDomain(), $cookie->getPath(), $cookie->getName());
$this->removeCookie($cookie);
}
return false;
@ -184,6 +189,7 @@ class CookieJar implements CookieJarInterface
// identical.
if ($c->getPath() !== $cookie->getPath()
|| $c->getDomain() !== $cookie->getDomain()
|| $c->getHostOnly() !== $cookie->getHostOnly()
|| $c->getName() !== $cookie->getName()
) {
continue;
@ -234,14 +240,23 @@ class CookieJar implements CookieJarInterface
public function extractCookies(RequestInterface $request, ResponseInterface $response): void
{
if ($cookieHeader = $response->getHeader('Set-Cookie')) {
$accepted = 0;
foreach ($cookieHeader as $cookie) {
if (\strlen($cookie) > self::MAX_SET_COOKIE_FIELD_LENGTH) {
continue;
}
$sc = SetCookie::fromString($cookie);
$domain = $sc->getDomain();
if ($domain === null || $domain === '') {
$sc->setDomain($request->getUri()->getHost());
$sc->setHostOnly(true);
} elseif (\substr($domain, -1) === '.' && '' !== \trim($domain, '.')) {
// Keep pure-dot domains rejected by the dot-only fix.
$sc->setDomain($request->getUri()->getHost());
$sc->setHostOnly(true);
} else {
$sc->setHostOnly(false);
}
if (0 !== \strpos($sc->getPath(), '/')) {
$sc->setPath($this->getCookiePathFromRequest($request));
@ -251,7 +266,9 @@ class CookieJar implements CookieJarInterface
}
// Note: At this point `$sc->getDomain()` being a public suffix should
// be rejected, but we don't want to pull in the full PSL dependency.
$this->setCookie($sc);
if ($this->setCookie($sc) && ++$accepted === self::MAX_SET_COOKIE_FIELDS) {
break;
}
}
}
}
@ -284,6 +301,7 @@ class CookieJar implements CookieJarInterface
public function withCookieHeader(RequestInterface $request): RequestInterface
{
$values = [];
$headerLength = 8;
$uri = $request->getUri();
$scheme = $uri->getScheme();
$host = $uri->getHost();
@ -296,8 +314,19 @@ class CookieJar implements CookieJarInterface
&& !$cookie->isExpired()
&& (!$cookie->getSecure() || $scheme === 'https')
) {
$values[] = $cookie->getName().'='
.$cookie->getValue();
$name = (string) $cookie->getName();
$value = (string) $cookie->getValue();
$separatorLength = $values === [] ? 0 : 2;
$valueLength = \strlen($name) + 1 + \strlen($value);
if ($headerLength + $separatorLength + $valueLength > self::MAX_COOKIE_HEADER_LENGTH) {
break;
}
$values[] = $name.'='.$value;
$headerLength += $separatorLength + $valueLength;
if (\count($values) === self::MAX_REQUEST_COOKIES) {
break;
}
}
}
@ -314,11 +343,36 @@ class CookieJar implements CookieJarInterface
{
$cookieValue = $cookie->getValue();
if (($cookieValue === null || $cookieValue === '') && $cookie->getDomain() !== null) {
$this->clear(
$cookie->getDomain(),
$cookie->getPath(),
$cookie->getName()
);
$this->removeCookie($cookie);
}
}
private function removeCookie(SetCookie $cookie): void
{
$this->cookies = \array_filter(
$this->cookies,
static function (SetCookie $stored) use ($cookie): bool {
return !($stored->getName() === $cookie->getName()
&& $stored->getPath() === $cookie->getPath()
&& self::cookieDomainsEqual($stored->getDomain(), $cookie->getDomain())
&& $stored->getHostOnly() === $cookie->getHostOnly());
}
);
}
private static function cookieDomainsEqual(?string $first, ?string $second): bool
{
if ($first === null || $second === null) {
return $first === $second;
}
if (isset($first[0]) && $first[0] === '.') {
$first = \substr($first, 1);
}
if (isset($second[0]) && $second[0] === '.') {
$second = \substr($second, 1);
}
return Psr7\Utils::caselessEquals($first, $second);
}
}

View file

@ -60,7 +60,9 @@ class FileCookieJar extends CookieJar
/** @var SetCookie $cookie */
foreach ($this as $cookie) {
if (CookieJar::shouldPersist($cookie, $this->storeSessionCookies)) {
$json[] = $cookie->toArray();
$data = $cookie->toArray();
$data['HostOnly'] = $cookie->getHostOnly();
$json[] = $data;
}
}
@ -100,8 +102,17 @@ class FileCookieJar extends CookieJar
}
if (\is_array($data)) {
$cookies = [];
foreach ($data as $cookie) {
$this->setCookie(new SetCookie($cookie));
if (!\is_array($cookie) || !\array_key_exists('HostOnly', $cookie) || !\is_bool($cookie['HostOnly'])) {
throw new \RuntimeException("Invalid cookie file: {$filename}");
}
$cookies[] = new SetCookie($cookie);
}
foreach ($cookies as $cookie) {
$this->setCookie($cookie);
}
} elseif (\is_scalar($data) && !empty($data)) {
throw new \RuntimeException("Invalid cookie file: {$filename}");

View file

@ -50,7 +50,9 @@ class SessionCookieJar extends CookieJar
/** @var SetCookie $cookie */
foreach ($this as $cookie) {
if (CookieJar::shouldPersist($cookie, $this->storeSessionCookies)) {
$json[] = $cookie->toArray();
$data = $cookie->toArray();
$data['HostOnly'] = $cookie->getHostOnly();
$json[] = $data;
}
}
@ -78,12 +80,17 @@ class SessionCookieJar extends CookieJar
$data = \json_decode($json, true);
if (\is_array($data)) {
$cookies = [];
foreach ($data as $cookie) {
if (!\is_array($cookie)) {
if (!\is_array($cookie) || !\array_key_exists('HostOnly', $cookie) || !\is_bool($cookie['HostOnly'])) {
throw new \RuntimeException('Invalid cookie data');
}
$this->setCookie(new SetCookie($cookie));
$cookies[] = new SetCookie($cookie);
}
foreach ($cookies as $cookie) {
$this->setCookie($cookie);
}
} elseif (\is_scalar($data) && \strlen((string) $data)) {
throw new \RuntimeException('Invalid cookie data');

View file

@ -2,6 +2,7 @@
namespace GuzzleHttp\Cookie;
use GuzzleHttp\Handler\HostValidator;
use GuzzleHttp\Psr7;
/**
@ -29,6 +30,11 @@ class SetCookie
*/
private $data;
/**
* @var bool Whether this cookie was set without a Domain attribute
*/
private $hostOnly = false;
/**
* Create a new SetCookie object from a string.
*
@ -76,6 +82,9 @@ class SetCookie
continue 2;
}
}
if (Psr7\Utils::caselessEquals('HostOnly', $key)) {
continue;
}
$data[$key] = $value;
}
}
@ -90,6 +99,14 @@ class SetCookie
{
$this->data = self::$defaults;
if (\array_key_exists('HostOnly', $data)) {
if (!\is_bool($data['HostOnly'])) {
throw new \InvalidArgumentException('Cookie field "HostOnly" must be a boolean');
}
$this->setHostOnly($data['HostOnly']);
unset($data['HostOnly']);
}
if (isset($data['Name'])) {
$this->setName($data['Name']);
}
@ -157,6 +174,9 @@ class SetCookie
{
$str = $this->data['Name'].'='.($this->data['Value'] ?? '').'; ';
foreach ($this->data as $k => $v) {
if ($k === 'Domain' && $this->getHostOnly()) {
continue;
}
if ($k !== 'Name' && $k !== 'Value' && $v !== null && $v !== false) {
if ($k === 'Expires') {
$str .= 'Expires='.\gmdate('D, d M Y H:i:s \G\M\T', $v).'; ';
@ -171,7 +191,12 @@ class SetCookie
public function toArray(): array
{
return $this->data;
$data = $this->data;
if ($this->getHostOnly()) {
$data['HostOnly'] = true;
}
return $data;
}
/**
@ -246,6 +271,26 @@ class SetCookie
$this->data['Domain'] = null === $domain ? null : (string) $domain;
}
/**
* Get whether this cookie is scoped to the origin host only.
*
* @return bool
*/
public function getHostOnly()
{
return $this->hostOnly;
}
/**
* Set whether this cookie is scoped to the origin host only.
*
* @param bool $hostOnly Set to true for host-only cookies
*/
public function setHostOnly(bool $hostOnly): void
{
$this->hostOnly = $hostOnly;
}
/**
* Get the path.
*
@ -445,7 +490,11 @@ class SetCookie
{
$cookieDomain = $this->getDomain();
if (null === $cookieDomain) {
return true;
return !$this->getHostOnly();
}
if ($this->getHostOnly()) {
return Psr7\Utils::asciiToLower($domain) === Psr7\Utils::asciiToLower($cookieDomain);
}
// Remove the leading '.' as per spec in RFC 6265.
@ -464,6 +513,12 @@ class SetCookie
return true;
}
// A percent-escaped cookie domain can decode to another host spelling.
// Keep it exact-match-only to avoid extending that host's cookie scope.
if (\strpos($cookieDomain, '%') !== false) {
return false;
}
// IP literals and numeric hosts are exact-match-only per RFC 6265.
// Only the exact match above may succeed for those cookie domains.
if (self::isIpAddressOrNumericHost($cookieDomain)) {
@ -500,7 +555,14 @@ class SetCookie
$labels = \explode('.', $host);
$last = (string) \end($labels);
return $last !== '' && \ctype_digit($last);
if ($last !== '' && \ctype_digit($last)) {
return true;
}
// Apply the transport's decimal, octal and hexadecimal inet_aton-style
// grammar. Omitting range checks conservatively holds some names to an
// exact match.
return HostValidator::isNumericIpv4Host(\rtrim($host, '.'));
}
/**

View file

@ -70,6 +70,8 @@ class CurlHandler
public function __invoke(RequestInterface $request, array $options): PromiseInterface
{
HostValidator::assertRequestHost($request);
if (isset($options['delay'])) {
\usleep($options['delay'] * 1000);
}

View file

@ -345,6 +345,8 @@ class CurlMultiHandler
public function __invoke(RequestInterface $request, array $options): PromiseInterface
{
HostValidator::assertRequestHost($request);
if ($this->connectionCapsApplied
&& \defined('CURLOPT_SHARE')
&& isset($options['curl'])

View file

@ -0,0 +1,198 @@
<?php
namespace GuzzleHttp\Handler;
use GuzzleHttp\Exception\RequestException;
use Psr\Http\Message\RequestInterface;
/**
* Rejects request hosts that a handler could resolve differently from the host
* the request names.
*
* @internal
*/
final class HostValidator
{
private function __construct()
{
}
/**
* Asserts that a request names one unambiguous network host.
*
* The URI host and every Host header value must use printable ASCII without
* percent escapes. The URI host must also exclude authority delimiters and
* numeric-looking parts followed by trailing dots. Handlers reparse the URI
* but send the Host header as given, so ambiguous spellings can name
* different connection and request hosts.
*
* @throws RequestException
*/
public static function assertRequestHost(RequestInterface $request): void
{
$host = $request->getUri()->getHost();
self::assertUriHostValue($host, $request);
self::assertNoAuthorityDelimiter($host, $request);
self::assertNotADottedAddress($host, $request);
foreach ($request->getHeader('Host') as $value) {
self::assertHostHeaderValue((string) $value, $request);
}
}
/**
* @throws RequestException
*/
private static function assertUriHostValue(string $value, RequestInterface $request): void
{
if (!self::isPrintableAscii($value)) {
throw new RequestException(\sprintf('The request URI host "%s" must contain only printable ASCII characters, because a handler can otherwise connect to a host that differs from the one the request names. An internationalized host name has an A-label form that this rule accepts.', self::escape($value)), $request);
}
if (\strpos($value, '%') !== false) {
throw new RequestException(\sprintf('The request URI host "%s" must not contain a percent escape, because a handler can decode it and then connect to a host that differs from the one the request names.', self::escape($value)), $request);
}
}
/**
* The Host header is sent rather than reparsed for the connection, so its
* diagnostics describe a request authority the caller did not write.
*
* @throws RequestException
*/
private static function assertHostHeaderValue(string $value, RequestInterface $request): void
{
if (!self::isPrintableAscii($value)) {
throw new RequestException(\sprintf('The request Host header "%s" must contain only printable ASCII characters, because an intermediary or an origin server can otherwise read it as an authority that differs from the one the request names. An internationalized host name has an A-label form that this rule accepts.', self::escape($value)), $request);
}
if (\strpos($value, '%') !== false) {
throw new RequestException(\sprintf('The request Host header "%s" must not contain a percent escape, because an intermediary or an origin server can decode it and then read it as an authority that differs from the one the request names.', self::escape($value)), $request);
}
}
/**
* Matches the accepted shape positively so a PCRE failure rejects.
*/
private static function isPrintableAscii(string $value): bool
{
return \preg_match('/\A[\x21-\x7E]*\z/D', $value) === 1;
}
/**
* Rejects a delimiter the transport could treat as the end of the URI host.
*
* This mirrors GuzzleHttp\Psr7\Uri::assertValidHost() and only affects
* third-party UriInterface values. Host headers may carry a port and are
* sent verbatim.
*
* @throws RequestException
*/
private static function assertNoAuthorityDelimiter(string $host, RequestInterface $request): void
{
$message = 'The request URI host "%s" must not contain a URI authority delimiter, because a handler reparses the URI and can then connect to a host that differs from the one the request names.';
// Match the accepted shape positively so a PCRE engine failure rejects.
if (\preg_match('/\A[^\/?#@\\\\]*\z/D', $host) !== 1) {
throw new RequestException(\sprintf($message, self::escape($host)), $request);
}
if (\strpos($host, '[') !== false || \strpos($host, ']') !== false) {
if (\strpos($host, '[') !== 0 || \substr($host, -1) !== ']') {
throw new RequestException(\sprintf($message, self::escape($host)), $request);
}
return;
}
if (\strpos($host, ':') !== false) {
throw new RequestException(\sprintf($message, self::escape($host)), $request);
}
}
/**
* Rejects one to four numeric-looking parts followed by trailing dots.
*
* libcurl 8.21.0 drops a trailing dot from inet_aton-style numeric hosts
* before connecting, while other validators treat the input as a name.
* Testing the shape also rejects some out-of-range values that transports
* keep as names; isNumericIpv4Host() explains that fail-closed tradeoff.
* Plain numeric shorthand stays accepted.
*
* @throws RequestException
*/
private static function assertNotADottedAddress(string $host, RequestInterface $request): void
{
if (\substr($host, -1) !== '.') {
return;
}
if (!self::isNumericIpv4Host(\rtrim($host, '.'))) {
return;
}
throw new RequestException(\sprintf('The request URI host "%s" must not be written as one to four decimal, octal or hexadecimal parts followed by one or more trailing dots, because a handler can read that spelling as an IPv4 address and connect to that address while the rest of the process reads a name.', self::escape($host)), $request);
}
/**
* Reports whether a value has the transport's inet_aton-style shape: one
* to four decimal, 0-prefixed octal, or 0x-prefixed hexadecimal parts.
*
* Range and 32-bit overflow checks are deliberately omitted. This may
* reject a trailing-dot spelling the transport reads as a name, but avoids
* missing one it resolves as an address. No PCRE is used.
*/
public static function isNumericIpv4Host(string $host): bool
{
if ($host === '') {
return false;
}
$parts = \explode('.', $host);
if (\count($parts) > 4) {
return false;
}
foreach ($parts as $part) {
if (!self::isNumericIpv4Part($part)) {
return false;
}
}
return true;
}
private static function isNumericIpv4Part(string $part): bool
{
if ($part === '') {
return false;
}
if ($part[0] === '0' && isset($part[1]) && ($part[1] === 'x' || $part[1] === 'X')) {
return \strlen($part) > 2 && \strspn($part, '0123456789abcdefABCDEF', 2) === \strlen($part) - 2;
}
$digits = $part[0] === '0' ? '01234567' : '0123456789';
return \strspn($part, $digits) === \strlen($part);
}
/**
* Escapes non-printable bytes as uppercase \xNN for safe diagnostics.
* Printable delimiters and dots stay visible. The result is not a
* reversible encoding.
*/
private static function escape(string $value): string
{
$escaped = '';
for ($offset = 0, $length = \strlen($value); $offset < $length; ++$offset) {
$byte = \ord($value[$offset]);
$escaped .= $byte >= 0x21 && $byte <= 0x7E ? $value[$offset] : \sprintf('\\x%02X', $byte);
}
return $escaped;
}
}

View file

@ -4,6 +4,7 @@ namespace GuzzleHttp\Handler;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Exception\TransferException;
use GuzzleHttp\Multiplexing;
use GuzzleHttp\Promise as P;
use GuzzleHttp\Promise\FulfilledPromise;
@ -187,11 +188,10 @@ class StreamHandler
} catch (\InvalidArgumentException $e) {
throw $e;
} catch (\Exception $e) {
// Determine if the error was a networking error.
if (self::isConnectionError($e->getMessage())) {
$e = new ConnectException($e->getMessage(), $request, $e);
} else {
$e = $e instanceof RequestException ? $e : new RequestException($e->getMessage(), $request, null, $e);
if (!$e instanceof TransferException) {
$e = self::isConnectionError($e->getMessage())
? new ConnectException($e->getMessage(), $request, $e)
: new RequestException($e->getMessage(), $request, null, $e);
}
$this->invokeStats($options, $request, $startTime, null, $e);
@ -428,6 +428,8 @@ class StreamHandler
throw new RequestException('URI must include a scheme and host. Use an absolute URI, a network-path reference starting with //, or configure a base_uri.', $request);
}
HostValidator::assertRequestHost($request);
// HTTP/1.1 streams using the PHP stream wrapper require a
// Connection: close header
if ($request->getProtocolVersion() === '1.1'

View file

@ -210,7 +210,7 @@ class RedirectMiddleware
if ($options['allow_redirects']['referer']
&& $modify['uri']->getScheme() === $request->getUri()->getScheme()
) {
$uri = $request->getUri()->withUserInfo('');
$uri = $request->getUri()->withUserInfo('')->withFragment('');
$modify['set_headers']['Referer'] = (string) $uri;
} else {
$modify['remove_headers'][] = 'Referer';

View file

@ -1,31 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
#[Attribute(Attribute::TARGET_CLASS)]
final class Attribute
{
public const TARGET_CLASS = 1;
public const TARGET_FUNCTION = 2;
public const TARGET_METHOD = 4;
public const TARGET_PROPERTY = 8;
public const TARGET_CLASS_CONSTANT = 16;
public const TARGET_PARAMETER = 32;
public const TARGET_ALL = 63;
public const IS_REPEATABLE = 64;
/** @var int */
public $flags;
public function __construct(int $flags = self::TARGET_ALL)
{
$this->flags = $flags;
}
}

View file

@ -1,16 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
if (\PHP_VERSION_ID < 80000 && extension_loaded('tokenizer')) {
class PhpToken extends Symfony\Polyfill\Php80\PhpToken
{
}
}

View file

@ -1,20 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
if (\PHP_VERSION_ID < 80000) {
interface Stringable
{
/**
* @return string
*/
public function __toString();
}
}

View file

@ -1,16 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
if (\PHP_VERSION_ID < 80000) {
class UnhandledMatchError extends Error
{
}
}

View file

@ -1,16 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
if (\PHP_VERSION_ID < 80000) {
class ValueError extends Error
{
}
}