initial
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* A redirect action - what happens after a URL is matched.
|
||||
*
|
||||
* @phpstan-type UrlActionData array{
|
||||
* code: int,
|
||||
* target: string,
|
||||
* type: string
|
||||
* }
|
||||
* @phpstan-type ErrorActionData array{
|
||||
* code: int,
|
||||
* type: string
|
||||
* }
|
||||
* @phpstan-type NothingActionData array{
|
||||
* code: int,
|
||||
* type: string
|
||||
* }
|
||||
* @phpstan-type RandomActionData array{
|
||||
* code: int,
|
||||
* type: string
|
||||
* }
|
||||
* @phpstan-type PassActionData array{
|
||||
* code: int,
|
||||
* target: string,
|
||||
* type: string
|
||||
* }
|
||||
* @phpstan-type RedActionData UrlActionData|ErrorActionData|NothingActionData|RandomActionData|PassActionData
|
||||
*/
|
||||
abstract class Red_Action {
|
||||
/**
|
||||
* The action code (i.e. HTTP code)
|
||||
*
|
||||
* @var integer
|
||||
*/
|
||||
protected $code = 0;
|
||||
|
||||
/**
|
||||
* The action type
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $type = '';
|
||||
|
||||
/**
|
||||
* Target URL, if any
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $target = null;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param RedActionData|array{} $values Values.
|
||||
*/
|
||||
public function __construct( $values = [] ) {
|
||||
if ( isset( $values['code'] ) ) {
|
||||
$this->code = $values['code'];
|
||||
}
|
||||
|
||||
if ( isset( $values['target'] ) ) {
|
||||
$this->target = $values['target'];
|
||||
}
|
||||
|
||||
if ( isset( $values['type'] ) ) {
|
||||
$this->type = $values['type'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
abstract public function name();
|
||||
|
||||
/**
|
||||
* Create an action object
|
||||
*
|
||||
* @param string $name Action type.
|
||||
* @param integer $code Action code.
|
||||
* @return Red_Action|null
|
||||
*/
|
||||
public static function create( $name, $code ) {
|
||||
$avail = self::available();
|
||||
|
||||
if ( isset( $avail[ $name ] ) ) {
|
||||
if ( ! class_exists( strtolower( $avail[ $name ][1] ) ) ) {
|
||||
include_once __DIR__ . '/../actions/' . $avail[ $name ][0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @var Red_Action
|
||||
*/
|
||||
$obj = new $avail[ $name ][1]( [ 'code' => $code ] );
|
||||
$obj->type = $name;
|
||||
return $obj;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available actions
|
||||
*
|
||||
* @return array<string, array{string, string}>
|
||||
*/
|
||||
public static function available() {
|
||||
return [
|
||||
'url' => [ 'url.php', 'Url_Action' ],
|
||||
'error' => [ 'error.php', 'Error_Action' ],
|
||||
'nothing' => [ 'nothing.php', 'Nothing_Action' ],
|
||||
'random' => [ 'random.php', 'Random_Action' ],
|
||||
'pass' => [ 'pass.php', 'Pass_Action' ],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the action code
|
||||
*
|
||||
* @return integer
|
||||
*/
|
||||
public function get_code() {
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get action type
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_type() {
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the target for this action
|
||||
*
|
||||
* @param string $target_url The original URL from the client.
|
||||
* @return void
|
||||
*/
|
||||
public function set_target( $target_url ) {
|
||||
$this->target = $target_url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the target for this action
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function get_target() {
|
||||
return $this->target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this action need a target?
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
public function needs_target() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run this action. May not return from this function.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
abstract public function run();
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Canonical redirects.
|
||||
*/
|
||||
class Redirection_Canonical {
|
||||
/**
|
||||
* Aliased domains. These are domains that should be redirected to the WP domain.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private $aliases = [];
|
||||
|
||||
/**
|
||||
* Force HTTPS.
|
||||
*
|
||||
* @var boolean
|
||||
*/
|
||||
private $force_https = false;
|
||||
|
||||
/**
|
||||
* Preferred domain. WWW or no WWW.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $preferred_domain = '';
|
||||
|
||||
/**
|
||||
* Current WP domain.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $actual_domain = '';
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param boolean $force_https `true` to force https, `false` otherwise.
|
||||
* @param string $preferred_domain `www`, `nowww`, or empty string.
|
||||
* @param string[] $aliases Array of domain aliases.
|
||||
* @param string $configured_domain Current domain.
|
||||
*/
|
||||
public function __construct( $force_https, $preferred_domain, $aliases, $configured_domain ) {
|
||||
$this->force_https = $force_https;
|
||||
$this->aliases = $aliases;
|
||||
$this->preferred_domain = $preferred_domain;
|
||||
$this->actual_domain = $configured_domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the canonical redirect.
|
||||
*
|
||||
* @param string $server Current server URL.
|
||||
* @param string $request Current request.
|
||||
* @return string|false
|
||||
*/
|
||||
public function get_redirect( $server, $request ) {
|
||||
$aliases = array_merge(
|
||||
$this->get_preferred_aliases( $server ),
|
||||
$this->aliases
|
||||
);
|
||||
|
||||
if ( $this->force_https && ! is_ssl() ) {
|
||||
$aliases[] = $server;
|
||||
}
|
||||
|
||||
$aliases = array_unique( $aliases );
|
||||
if ( count( $aliases ) > 0 ) {
|
||||
foreach ( $aliases as $alias ) {
|
||||
if ( $server === $alias ) {
|
||||
// Redirect this to the WP url
|
||||
$target = $this->get_canonical_target( get_bloginfo( 'url' ) );
|
||||
if ( $target === false ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$target = esc_url_raw( $target ) . $request;
|
||||
|
||||
return apply_filters( 'redirect_canonical_target', $target );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the preferred alias
|
||||
*
|
||||
* @param string $server Current server.
|
||||
* @return string[]
|
||||
*/
|
||||
private function get_preferred_aliases( $server ) {
|
||||
if ( $this->need_force_www( $server ) || $this->need_remove_www( $server ) ) {
|
||||
return [ $server ];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* A final check to prevent obvious site errors.
|
||||
*
|
||||
* @param string $server Current server.
|
||||
* @return boolean
|
||||
*/
|
||||
private function is_configured_domain( $server ) {
|
||||
return $server === $this->actual_domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the canonical target
|
||||
*
|
||||
* @param string $server Current server.
|
||||
* @return string|false
|
||||
*/
|
||||
private function get_canonical_target( $server ) {
|
||||
$canonical = rtrim( red_parse_domain_only( $server ), '/' );
|
||||
|
||||
if ( $this->need_force_www( $server ) ) {
|
||||
$canonical = 'www.' . ltrim( $canonical, 'www.' );
|
||||
} elseif ( $this->need_remove_www( $server ) ) {
|
||||
$canonical = ltrim( $canonical, 'www.' );
|
||||
}
|
||||
|
||||
$canonical = ( is_ssl() ? 'https://' : 'http://' ) . $canonical;
|
||||
|
||||
if ( $this->force_https ) {
|
||||
$canonical = str_replace( 'http://', 'https://', $canonical );
|
||||
}
|
||||
|
||||
if ( $this->is_configured_domain( $canonical ) ) {
|
||||
return $canonical;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do we need to force WWW?
|
||||
*
|
||||
* @param string $server Current server.
|
||||
* @return boolean
|
||||
*/
|
||||
private function need_force_www( $server ) {
|
||||
$has_www = substr( $server, 0, 4 ) === 'www.';
|
||||
|
||||
return $this->preferred_domain === 'www' && ! $has_www;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do we need to remove WWW?
|
||||
*
|
||||
* @param string $server Current server.
|
||||
* @return boolean
|
||||
*/
|
||||
private function need_remove_www( $server ) {
|
||||
$has_www = substr( $server, 0, 4 ) === 'www.';
|
||||
|
||||
return $this->preferred_domain === 'nowww' && $has_www;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the full URL relocated to another domain. Certain URLs are protected from this.
|
||||
*
|
||||
* @param string $relocate Target domain.
|
||||
* @param string $domain Current domain.
|
||||
* @param string $request Current request.
|
||||
* @return string|false
|
||||
*/
|
||||
public function relocate_request( $relocate, $domain, $request ) {
|
||||
$relocate = rtrim( $relocate, '/' );
|
||||
|
||||
$protected = apply_filters(
|
||||
'redirect_relocate_protected',
|
||||
[
|
||||
'/wp-admin',
|
||||
'/wp-login.php',
|
||||
'/wp-json/',
|
||||
]
|
||||
);
|
||||
|
||||
$not_protected = array_filter(
|
||||
$protected,
|
||||
function ( $base ) use ( $request ) {
|
||||
if ( substr( $request, 0, strlen( $base ) ) === $base ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
if ( $domain !== red_parse_domain_only( $relocate ) && count( $not_protected ) === 0 ) {
|
||||
return apply_filters( 'redirect_relocate_target', $relocate . $request );
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Base class for file import/export operations
|
||||
*
|
||||
* @phpstan-import-type GroupJson from Red_Group
|
||||
* @phpstan-type UploadedFile array{
|
||||
* name: string,
|
||||
* type: string,
|
||||
* tmp_name: string,
|
||||
* error: int,
|
||||
* size: int
|
||||
* }
|
||||
* @phpstan-type ExportResult array{
|
||||
* data: string,
|
||||
* total: int,
|
||||
* exporter: Red_FileIO
|
||||
* }
|
||||
*/
|
||||
abstract class Red_FileIO {
|
||||
/**
|
||||
* Create a file IO handler for the specified type
|
||||
*
|
||||
* @param string $type File format type (rss, csv, apache, nginx, json).
|
||||
* @return Red_FileIO|false
|
||||
*/
|
||||
public static function create( $type ) {
|
||||
$exporter = false;
|
||||
|
||||
if ( $type === 'rss' ) {
|
||||
include_once dirname( __DIR__ ) . '/fileio/rss.php';
|
||||
$exporter = new Red_Rss_File();
|
||||
} elseif ( $type === 'csv' ) {
|
||||
include_once dirname( __DIR__ ) . '/fileio/csv.php';
|
||||
$exporter = new Red_Csv_File();
|
||||
} elseif ( $type === 'apache' ) {
|
||||
include_once dirname( __DIR__ ) . '/fileio/apache.php';
|
||||
$exporter = new Red_Apache_File();
|
||||
} elseif ( $type === 'nginx' ) {
|
||||
include_once dirname( __DIR__ ) . '/fileio/nginx.php';
|
||||
$exporter = new Red_Nginx_File();
|
||||
} elseif ( $type === 'json' ) {
|
||||
include_once dirname( __DIR__ ) . '/fileio/json.php';
|
||||
$exporter = new Red_Json_File();
|
||||
}
|
||||
|
||||
return $exporter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import redirects from an uploaded file
|
||||
*
|
||||
* @param int $group_id Group ID to import into.
|
||||
* @param UploadedFile $file Uploaded file data from $_FILES.
|
||||
* @return int
|
||||
*/
|
||||
public static function import( $group_id, $file ) {
|
||||
$parts = pathinfo( $file['name'] );
|
||||
$extension = isset( $parts['extension'] ) ? $parts['extension'] : '';
|
||||
$extension = strtolower( $extension );
|
||||
|
||||
if ( $extension === 'csv' || $extension === 'txt' ) {
|
||||
include_once dirname( __DIR__ ) . '/fileio/csv.php';
|
||||
$importer = new Red_Csv_File();
|
||||
$data = '';
|
||||
} elseif ( $extension === 'json' ) {
|
||||
include_once dirname( __DIR__ ) . '/fileio/json.php';
|
||||
$importer = new Red_Json_File();
|
||||
$data = @file_get_contents( $file['tmp_name'] );
|
||||
} else {
|
||||
include_once dirname( __DIR__ ) . '/fileio/apache.php';
|
||||
$importer = new Red_Apache_File();
|
||||
$data = @file_get_contents( $file['tmp_name'] );
|
||||
}
|
||||
|
||||
if ( $extension !== 'json' ) {
|
||||
$group = Red_Group::get( $group_id );
|
||||
if ( $group === false ) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return $importer->load( $group_id, $file['tmp_name'], $data );
|
||||
}
|
||||
|
||||
/**
|
||||
* Set headers to force file download
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function force_download() {
|
||||
header( 'Cache-Control: no-cache, must-revalidate' );
|
||||
header( 'Expires: Mon, 26 Jul 1997 05:00:00 GMT' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate export filename
|
||||
*
|
||||
* @param string $extension File extension (without dot).
|
||||
* @return string Generated filename.
|
||||
*/
|
||||
protected function export_filename( $extension ) {
|
||||
$name = wp_parse_url( home_url(), PHP_URL_HOST );
|
||||
if ( $name === false || $name === null || $name === '' ) {
|
||||
$name = 'export';
|
||||
}
|
||||
|
||||
$name = sanitize_text_field( $name );
|
||||
$name = str_replace( '.', '-', $name );
|
||||
$date = strtolower( date_i18n( get_option( 'date_format' ) ) );
|
||||
$date = str_replace( [ ',', ' ', '--' ], '-', $date );
|
||||
|
||||
return 'redirection-' . $name . '-' . $date . '.' . sanitize_text_field( $extension );
|
||||
}
|
||||
|
||||
/**
|
||||
* Export redirects to a file format
|
||||
*
|
||||
* @param string|int $module_name_or_id Module name, ID, or 'all' for all modules.
|
||||
* @param string $format Export format (rss, csv, apache, nginx, json).
|
||||
* @return ExportResult|false Export data or false on failure.
|
||||
*/
|
||||
public static function export( $module_name_or_id, $format ) {
|
||||
$groups = false;
|
||||
$items = false;
|
||||
|
||||
if ( $module_name_or_id === 'all' || $module_name_or_id === 0 ) {
|
||||
$groups = Red_Group::get_all();
|
||||
$items = Red_Item::get_all();
|
||||
} else {
|
||||
$module_name_or_id = is_numeric( $module_name_or_id ) ? $module_name_or_id : Red_Module::get_id_for_name( $module_name_or_id );
|
||||
$module = Red_Module::get( intval( $module_name_or_id, 10 ) );
|
||||
|
||||
if ( $module !== false ) {
|
||||
$groups = Red_Group::get_all_for_module( $module->get_id() );
|
||||
$items = Red_Item::get_all_for_module( $module->get_id() );
|
||||
}
|
||||
}
|
||||
|
||||
$exporter = self::create( $format );
|
||||
if ( $exporter !== false && $items !== false && $groups !== false ) {
|
||||
return [
|
||||
'data' => $exporter->get_data( $items, $groups ),
|
||||
'total' => count( $items ),
|
||||
'exporter' => $exporter,
|
||||
];
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get export data for items and groups
|
||||
*
|
||||
* @param array<Red_Item> $items Redirect items to export.
|
||||
* @param array<GroupJson> $groups Groups to export.
|
||||
* @return string Formatted export data.
|
||||
*/
|
||||
abstract public function get_data( array $items, array $groups );
|
||||
|
||||
/**
|
||||
* Load and import data from a file
|
||||
*
|
||||
* @param int $group Group ID to import into.
|
||||
* @param string $filename Path to the file to import.
|
||||
* @param string|false $data File contents (or false if not pre-loaded).
|
||||
* @return int
|
||||
*/
|
||||
abstract public function load( $group, $filename, $data );
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
<?php
|
||||
|
||||
require_once dirname( REDIRECTION_FILE ) . '/database/database.php';
|
||||
|
||||
/**
|
||||
* Diagnostic and repair tool for Redirection plugin
|
||||
*
|
||||
* @phpstan-type StatusItem array{
|
||||
* id: string,
|
||||
* name: string,
|
||||
* message: string,
|
||||
* status: string
|
||||
* }
|
||||
* @phpstan-type DebugInfo array{
|
||||
* database: array{
|
||||
* current: string,
|
||||
* latest: string
|
||||
* },
|
||||
* ip_header: array<string, string|false>
|
||||
* }
|
||||
* @phpstan-type FixerJson array{
|
||||
* status: array<StatusItem>,
|
||||
* debug: DebugInfo
|
||||
* }
|
||||
*/
|
||||
class Red_Fixer {
|
||||
const REGEX_LIMIT = 200;
|
||||
|
||||
/**
|
||||
* Get JSON representation of fixer status and debug info
|
||||
*
|
||||
* @return FixerJson
|
||||
*/
|
||||
public function get_json() {
|
||||
return [
|
||||
'status' => $this->get_status(),
|
||||
'debug' => $this->get_debug(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get debug information
|
||||
*
|
||||
* @return DebugInfo
|
||||
*/
|
||||
public function get_debug() {
|
||||
$status = new Red_Database_Status();
|
||||
$ip = [];
|
||||
|
||||
foreach ( Redirection_Request::get_ip_headers() as $var ) {
|
||||
$ip[ $var ] = isset( $_SERVER[ $var ] ) ? sanitize_text_field( $_SERVER[ $var ] ) : false;
|
||||
}
|
||||
|
||||
return [
|
||||
'database' => [
|
||||
'current' => $status->get_current_version(),
|
||||
'latest' => REDIRECTION_DB_VERSION,
|
||||
],
|
||||
'ip_header' => $ip,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Save debug setting
|
||||
*
|
||||
* @param string $name Setting name.
|
||||
* @param string $value Setting value.
|
||||
* @return void
|
||||
*/
|
||||
public function save_debug( $name, $value ) {
|
||||
if ( $name === 'database' ) {
|
||||
$database = new Red_Database();
|
||||
$status = new Red_Database_Status();
|
||||
|
||||
foreach ( $database->get_upgrades() as $upgrade ) {
|
||||
if ( $value === $upgrade->get_version() ) {
|
||||
$status->finish();
|
||||
$status->save_db_version( $value );
|
||||
|
||||
// Switch to prompt mode
|
||||
red_set_options( [ 'plugin_update' => 'prompt' ] );
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of all diagnostic checks
|
||||
*
|
||||
* @return array<StatusItem>
|
||||
*/
|
||||
public function get_status() {
|
||||
global $wpdb;
|
||||
|
||||
$options = Red_Options::get();
|
||||
|
||||
$groups = intval( $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}redirection_groups" ), 10 );
|
||||
$bad_group = $this->get_missing();
|
||||
$monitor_group = $options['monitor_post'];
|
||||
$valid_monitor = Red_Group::get( $monitor_group ) !== false || $monitor_group === 0;
|
||||
|
||||
$status = [
|
||||
array_merge(
|
||||
[
|
||||
'id' => 'db',
|
||||
'name' => __( 'Database tables', 'redirection' ),
|
||||
],
|
||||
$this->get_database_status( Red_Database::get_latest_database() )
|
||||
),
|
||||
[
|
||||
'name' => __( 'Valid groups', 'redirection' ),
|
||||
'id' => 'groups',
|
||||
'message' => $groups === 0 ? __( 'No valid groups, so you will not be able to create any redirects', 'redirection' ) : __( 'Valid groups detected', 'redirection' ),
|
||||
'status' => $groups === 0 ? 'problem' : 'good',
|
||||
],
|
||||
[
|
||||
'name' => __( 'Valid redirect group', 'redirection' ),
|
||||
'id' => 'redirect_groups',
|
||||
'message' => count( $bad_group ) > 0 ? __( 'Redirects with invalid groups detected', 'redirection' ) : __( 'All redirects have a valid group', 'redirection' ),
|
||||
'status' => count( $bad_group ) > 0 ? 'problem' : 'good',
|
||||
],
|
||||
[
|
||||
'name' => __( 'Post monitor group', 'redirection' ),
|
||||
'id' => 'monitor',
|
||||
'message' => $valid_monitor === false ? __( 'Post monitor group is invalid', 'redirection' ) : __( 'Post monitor group is valid', 'redirection' ),
|
||||
'status' => $valid_monitor === false ? 'problem' : 'good',
|
||||
],
|
||||
$this->get_http_settings(),
|
||||
];
|
||||
|
||||
$regex_count = $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}redirection_items WHERE regex=1" );
|
||||
if ( $regex_count > self::REGEX_LIMIT ) {
|
||||
$status[] = [
|
||||
'name' => __( 'Regular Expressions', 'redirection' ),
|
||||
'id' => 'regex',
|
||||
'message' => __( 'Too many regular expressions may impact site performance', 'redirection' ),
|
||||
'status' => 'problem',
|
||||
];
|
||||
}
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database table status
|
||||
*
|
||||
* @param Red_Latest_Database $database Database instance.
|
||||
* @return array{status: string, message: string}
|
||||
*/
|
||||
private function get_database_status( $database ) {
|
||||
$missing = $database->get_missing_tables();
|
||||
|
||||
return array(
|
||||
'status' => count( $missing ) === 0 ? 'good' : 'error',
|
||||
'message' => count( $missing ) === 0 ? __( 'All tables present', 'redirection' ) : __( 'The following tables are missing:', 'redirection' ) . ' ' . join( ',', $missing ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get HTTP settings status
|
||||
*
|
||||
* @return StatusItem
|
||||
*/
|
||||
private function get_http_settings() {
|
||||
$site = wp_parse_url( get_site_url(), PHP_URL_SCHEME );
|
||||
$home = wp_parse_url( get_home_url(), PHP_URL_SCHEME );
|
||||
|
||||
$message = __( 'Site and home are consistent', 'redirection' );
|
||||
if ( $site !== $home ) {
|
||||
/* translators: 1: Site URL, 2: Home URL */
|
||||
$message = sprintf( __( 'Site and home URL are inconsistent. Please correct from your Settings > General page: %1$1s is not %2$2s', 'redirection' ), get_site_url(), get_home_url() );
|
||||
}
|
||||
|
||||
return array(
|
||||
'name' => __( 'Site and home protocol', 'redirection' ),
|
||||
'id' => 'redirect_url',
|
||||
'message' => $message,
|
||||
'status' => $site === $home ? 'good' : 'problem',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix all issues found in status
|
||||
*
|
||||
* @param array<StatusItem> $status Status items to fix.
|
||||
* @return array<StatusItem>|WP_Error Updated status or error.
|
||||
*/
|
||||
public function fix( $status ) {
|
||||
foreach ( $status as $item ) {
|
||||
if ( $item['status'] !== 'good' ) {
|
||||
$fixer = 'fix_' . $item['id'];
|
||||
|
||||
$result = true;
|
||||
if ( method_exists( $this, $fixer ) ) {
|
||||
// @phpstan-ignore method.dynamicName
|
||||
$result = $this->$fixer();
|
||||
}
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->get_status();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get redirects with missing groups
|
||||
*
|
||||
* @return list<object{id: string}>
|
||||
*/
|
||||
private function get_missing() {
|
||||
global $wpdb;
|
||||
|
||||
return $wpdb->get_results( "SELECT {$wpdb->prefix}redirection_items.id FROM {$wpdb->prefix}redirection_items LEFT JOIN {$wpdb->prefix}redirection_groups ON {$wpdb->prefix}redirection_items.group_id = {$wpdb->prefix}redirection_groups.id WHERE {$wpdb->prefix}redirection_groups.id IS NULL" );
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix database tables
|
||||
*
|
||||
* @return bool|WP_Error
|
||||
*/
|
||||
private function fix_db() {
|
||||
$database = Red_Database::get_latest_database();
|
||||
return $database->install();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix missing groups
|
||||
*
|
||||
* @return bool|WP_Error
|
||||
*/
|
||||
private function fix_groups() {
|
||||
if ( Red_Group::create( __( 'Redirections', 'redirection' ), 1 ) === false ) {
|
||||
return new WP_Error( 'redirect_group_create_failed', 'Unable to create group' );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix redirects with invalid groups
|
||||
*
|
||||
* @return bool|WP_Error
|
||||
*/
|
||||
private function fix_redirect_groups() {
|
||||
global $wpdb;
|
||||
|
||||
$missing = $this->get_missing();
|
||||
$group_id = $this->get_valid_group();
|
||||
|
||||
if ( is_wp_error( $group_id ) ) {
|
||||
return $group_id;
|
||||
}
|
||||
|
||||
foreach ( $missing as $row ) {
|
||||
$wpdb->update( $wpdb->prefix . 'redirection_items', array( 'group_id' => $group_id ), array( 'id' => $row->id ) );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix invalid monitor group setting
|
||||
*
|
||||
* @return bool|WP_Error
|
||||
*/
|
||||
private function fix_monitor() {
|
||||
$group_id = $this->get_valid_group();
|
||||
|
||||
if ( is_wp_error( $group_id ) ) {
|
||||
return $group_id;
|
||||
}
|
||||
|
||||
red_set_options( array( 'monitor_post' => $group_id ) );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a valid group ID
|
||||
*
|
||||
* @return int|WP_Error
|
||||
*/
|
||||
private function get_valid_group() {
|
||||
$groups = Red_Group::get_all();
|
||||
|
||||
if ( count( $groups ) === 0 ) {
|
||||
$group = Red_Group::create( __( 'Redirections', 'redirection' ), 1 );
|
||||
|
||||
if ( $group !== false ) {
|
||||
return $group->get_id();
|
||||
}
|
||||
|
||||
return new WP_Error( 'redirect_group_create_failed', 'Unable to create group' );
|
||||
}
|
||||
|
||||
return $groups[0]['id'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Handles automatic expiration and optimization of redirect and 404 logs
|
||||
*/
|
||||
class Red_Flusher {
|
||||
const DELETE_HOOK = 'redirection_log_delete';
|
||||
const DELETE_FREQ = 'daily';
|
||||
const DELETE_MAX = 20000;
|
||||
const DELETE_AGGRESSIVE = 50000; // Batch size for large backlogs (reduced from 100k for better replication)
|
||||
const DELETE_KEEP_ON = 10; // 10 minutes
|
||||
const DELETE_FAST = 3; // 3 minutes for aggressive mode (increased to reduce replication pressure)
|
||||
const AGGRESSIVE_THRESHOLD = 100000; // Switch to aggressive mode if more than 100k logs need deletion
|
||||
|
||||
/**
|
||||
* Flush expired logs and optimize tables
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function flush() {
|
||||
$options = Red_Options::get();
|
||||
|
||||
// Start with normal batch size
|
||||
$batch_size = self::DELETE_MAX;
|
||||
|
||||
// Check if we're in an ongoing aggressive deletion cycle
|
||||
$aggressive_mode = get_transient( 'redirection_aggressive_delete' );
|
||||
$is_aggressive = $aggressive_mode !== false;
|
||||
if ( $is_aggressive ) {
|
||||
$batch_size = self::DELETE_AGGRESSIVE;
|
||||
}
|
||||
|
||||
$total = $this->expire_logs( 'redirection_logs', $options['expire_redirect'], $batch_size );
|
||||
$total += $this->expire_logs( 'redirection_404', $options['expire_404'], $batch_size );
|
||||
|
||||
// If we deleted the full batch, there are likely more logs to delete
|
||||
if ( $total >= $batch_size ) {
|
||||
// Check if we should switch to aggressive mode (only if not already in it)
|
||||
if ( ! $is_aggressive ) {
|
||||
// Sample check: if we hit the normal limit, check if there's a large backlog
|
||||
$remaining = $this->estimate_remaining_logs( 'redirection_logs', $options['expire_redirect'] );
|
||||
$remaining += $this->estimate_remaining_logs( 'redirection_404', $options['expire_404'] );
|
||||
|
||||
if ( $remaining >= self::AGGRESSIVE_THRESHOLD ) {
|
||||
// Enable aggressive mode for 1 hour (will auto-expire if deletion completes)
|
||||
set_transient( 'redirection_aggressive_delete', true, HOUR_IN_SECONDS );
|
||||
$is_aggressive = true;
|
||||
$batch_size = self::DELETE_AGGRESSIVE;
|
||||
}
|
||||
}
|
||||
|
||||
$delay_minutes = $is_aggressive ? self::DELETE_FAST : self::DELETE_KEEP_ON;
|
||||
$next = time() + ( $delay_minutes * 60 );
|
||||
|
||||
// Schedule next deletion if it's before the next normal event
|
||||
$next_scheduled = wp_next_scheduled( self::DELETE_HOOK );
|
||||
if ( $next_scheduled === false || $next < $next_scheduled ) {
|
||||
wp_schedule_single_event( $next, self::DELETE_HOOK );
|
||||
}
|
||||
} else {
|
||||
// Deletion is complete or slowing down, clear aggressive mode
|
||||
delete_transient( 'redirection_aggressive_delete' );
|
||||
}
|
||||
|
||||
$this->optimize_logs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Randomly optimize log tables to improve performance
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function optimize_logs() {
|
||||
global $wpdb;
|
||||
|
||||
$rand = wp_rand( 1, 5000 );
|
||||
|
||||
if ( $rand === 11 ) {
|
||||
$wpdb->query( "OPTIMIZE TABLE {$wpdb->prefix}redirection_logs" );
|
||||
} elseif ( $rand === 12 ) {
|
||||
$wpdb->query( "OPTIMIZE TABLE {$wpdb->prefix}redirection_404" );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate remaining expired logs using a fast sampling method
|
||||
* Uses COUNT(*) over a limited subquery to avoid full table scans
|
||||
*
|
||||
* @param string $table Table name (without prefix).
|
||||
* @param int $expiry_time Number of days to keep logs.
|
||||
* @return int Estimated number of expired logs.
|
||||
*/
|
||||
private function estimate_remaining_logs( $table, $expiry_time ) {
|
||||
global $wpdb;
|
||||
|
||||
if ( $expiry_time <= 0 ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Sample approach: Check up to AGGRESSIVE_THRESHOLD + 1 expired logs
|
||||
// Use COUNT(*) over a limited subquery so only a single scalar is returned
|
||||
$count = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM ( SELECT 1 FROM {$wpdb->prefix}{$table} WHERE created < DATE_SUB(NOW(), INTERVAL %d DAY) LIMIT %d ) AS t", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$expiry_time,
|
||||
self::AGGRESSIVE_THRESHOLD + 1
|
||||
)
|
||||
);
|
||||
|
||||
// If we reached AGGRESSIVE_THRESHOLD rows, there's definitely a large backlog
|
||||
return $count ? (int) $count : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete expired logs from a table
|
||||
*
|
||||
* @param string $table Table name (without prefix).
|
||||
* @param int $expiry_time Number of days to keep logs.
|
||||
* @param int $batch_size Maximum number of logs to delete in this batch.
|
||||
* @return int Number of logs deleted.
|
||||
*/
|
||||
private function expire_logs( $table, $expiry_time, $batch_size = self::DELETE_MAX ) {
|
||||
global $wpdb;
|
||||
|
||||
if ( $expiry_time <= 0 ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Use DELETE with LIMIT - more efficient than counting first
|
||||
// The affected rows tell us how many were deleted
|
||||
$deleted = $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"DELETE FROM {$wpdb->prefix}{$table} WHERE created < DATE_SUB(NOW(), INTERVAL %d DAY) LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$expiry_time,
|
||||
$batch_size
|
||||
)
|
||||
);
|
||||
|
||||
return $deleted ? (int) $deleted : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the automatic log deletion cron job
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function schedule() {
|
||||
$options = Red_Options::get();
|
||||
|
||||
if ( $options['expire_redirect'] > 0 || $options['expire_404'] > 0 ) {
|
||||
if ( wp_next_scheduled( self::DELETE_HOOK ) === false ) {
|
||||
wp_schedule_event( time(), self::DELETE_FREQ, self::DELETE_HOOK );
|
||||
}
|
||||
} else {
|
||||
self::clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the scheduled log deletion cron job
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function clear() {
|
||||
wp_clear_scheduled_hook( self::DELETE_HOOK );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Filter builder for group queries
|
||||
*/
|
||||
class Red_Group_Filters {
|
||||
/**
|
||||
* SQL filter conditions
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private $filters = [];
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param array<string, string> $filter_params Filter parameters.
|
||||
*/
|
||||
public function __construct( $filter_params ) {
|
||||
global $wpdb;
|
||||
|
||||
foreach ( $filter_params as $filter_by => $filter ) {
|
||||
$filter_by = sanitize_text_field( $filter_by );
|
||||
$filter = sanitize_text_field( $filter );
|
||||
|
||||
if ( $filter_by === 'status' ) {
|
||||
$this->filters[] = $filter === 'enabled' ? "status='enabled'" : "status='disabled'";
|
||||
} elseif ( $filter_by === 'module' ) {
|
||||
$this->filters[] = $wpdb->prepare( 'module_id=%d', intval( $filter, 10 ) );
|
||||
} elseif ( $filter_by === 'name' ) {
|
||||
$this->filters[] = $wpdb->prepare( 'name LIKE %s', '%' . $wpdb->esc_like( trim( $filter ) ) . '%' );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filters as SQL WHERE clause
|
||||
*
|
||||
* @return string SQL WHERE clause or empty string.
|
||||
*/
|
||||
public function get_as_sql() {
|
||||
if ( count( $this->filters ) > 0 ) {
|
||||
return ' WHERE ' . implode( ' AND ', $this->filters );
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/group-filter.php';
|
||||
|
||||
/**
|
||||
* A group of redirects
|
||||
*
|
||||
* @phpstan-type GroupData object{
|
||||
* id: int,
|
||||
* name: string,
|
||||
* module_id?: int,
|
||||
* status?: string,
|
||||
* position?: int
|
||||
* }
|
||||
* @phpstan-type GroupJson array{
|
||||
* id: int,
|
||||
* name: string,
|
||||
* redirects: int,
|
||||
* module_id: int,
|
||||
* moduleName: string,
|
||||
* enabled: bool,
|
||||
* default?: bool
|
||||
* }
|
||||
* @phpstan-type GroupFilteredResult array{
|
||||
* items: array<GroupJson>,
|
||||
* total: int
|
||||
* }
|
||||
* @phpstan-type GroupSelectData array<string, array<int, string>>
|
||||
*/
|
||||
class Red_Group {
|
||||
const DEFAULT_PER_PAGE = 25;
|
||||
const MAX_PER_PAGE = 200;
|
||||
|
||||
/**
|
||||
* Group ID
|
||||
*
|
||||
* @var integer
|
||||
*/
|
||||
private $id = 0;
|
||||
|
||||
/**
|
||||
* Group name
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $name = '';
|
||||
|
||||
/**
|
||||
* Module ID
|
||||
*
|
||||
* @var integer
|
||||
*/
|
||||
private $module_id = 0;
|
||||
|
||||
/**
|
||||
* Group status - 'enabled' or 'disabled'
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $status = 'enabled';
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param GroupData|string $values Values.
|
||||
*/
|
||||
public function __construct( $values = '' ) {
|
||||
if ( is_object( $values ) ) {
|
||||
$this->name = sanitize_text_field( $values->name );
|
||||
$this->id = intval( $values->id, 10 );
|
||||
|
||||
if ( isset( $values->module_id ) ) {
|
||||
$this->module_id = intval( $values->module_id, 10 );
|
||||
}
|
||||
|
||||
if ( isset( $values->status ) ) {
|
||||
$this->status = $values->status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get group name
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_name() {
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get group ID
|
||||
*
|
||||
* @return integer
|
||||
*/
|
||||
public function get_id() {
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the group enabled or disabled?
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
public function is_enabled() {
|
||||
return $this->status === 'enabled' ? true : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a group given an ID
|
||||
*
|
||||
* @param integer $id Group ID.
|
||||
* @param bool $clear Clear cache.
|
||||
* @return Red_Group|false
|
||||
*/
|
||||
public static function get( $id, $clear = false ) {
|
||||
static $groups = [];
|
||||
global $wpdb;
|
||||
|
||||
if ( isset( $groups[ $id ] ) && ! $clear ) {
|
||||
$row = $groups[ $id ];
|
||||
} else {
|
||||
$row = $wpdb->get_row( $wpdb->prepare( "SELECT {$wpdb->prefix}redirection_groups.*,COUNT( {$wpdb->prefix}redirection_items.id ) AS items,SUM( {$wpdb->prefix}redirection_items.last_count ) AS redirects FROM {$wpdb->prefix}redirection_groups LEFT JOIN {$wpdb->prefix}redirection_items ON {$wpdb->prefix}redirection_items.group_id={$wpdb->prefix}redirection_groups.id WHERE {$wpdb->prefix}redirection_groups.id=%d GROUP BY {$wpdb->prefix}redirection_groups.id", $id ) );
|
||||
}
|
||||
|
||||
if ( $row ) {
|
||||
$groups[ $id ] = $row;
|
||||
return new Red_Group( $row );
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all groups
|
||||
*
|
||||
* @param array<string, mixed> $params Optional filter parameters.
|
||||
* @return array<GroupJson>
|
||||
*/
|
||||
public static function get_all( $params = [] ) {
|
||||
global $wpdb;
|
||||
|
||||
$where = '';
|
||||
if ( isset( $params['filterBy'] ) && is_array( $params['filterBy'] ) ) {
|
||||
$filters = new Red_Group_Filters( $params['filterBy'] );
|
||||
$where = $filters->get_as_sql();
|
||||
}
|
||||
|
||||
$data = [];
|
||||
// phpcs:ignore
|
||||
$rows = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}redirection_groups $where" );
|
||||
|
||||
if ( $rows ) {
|
||||
foreach ( $rows as $row ) {
|
||||
$group = new Red_Group( $row );
|
||||
$data[] = $group->to_json();
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all groups for a specific module
|
||||
*
|
||||
* @param int $module_id Module ID.
|
||||
* @return array<GroupJson>
|
||||
*/
|
||||
public static function get_all_for_module( $module_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$data = array();
|
||||
$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}redirection_groups WHERE module_id=%d", $module_id ) );
|
||||
|
||||
if ( $rows ) {
|
||||
foreach ( $rows as $row ) {
|
||||
$group = new Red_Group( $row );
|
||||
$data[] = $group->to_json();
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get groups formatted for select dropdown
|
||||
*
|
||||
* @return GroupSelectData
|
||||
*/
|
||||
public static function get_for_select() {
|
||||
global $wpdb;
|
||||
|
||||
$data = array();
|
||||
$rows = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}redirection_groups" );
|
||||
|
||||
if ( $rows ) {
|
||||
foreach ( $rows as $row ) {
|
||||
$module = Red_Module::get( $row->module_id );
|
||||
|
||||
if ( $module !== false ) {
|
||||
$data[ $module->get_name() ][ intval( $row->id, 10 ) ] = $row->name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new group
|
||||
*
|
||||
* @param string $name Group name.
|
||||
* @param int $module_id Module ID.
|
||||
* @param bool $enabled Whether the group is enabled.
|
||||
* @return Red_Group|false
|
||||
*/
|
||||
public static function create( $name, $module_id, $enabled = true ) {
|
||||
global $wpdb;
|
||||
|
||||
$name = trim( wp_kses( sanitize_text_field( $name ), 'strip' ) );
|
||||
$name = substr( $name, 0, 50 );
|
||||
$module_id = intval( $module_id, 10 );
|
||||
|
||||
if ( $name !== '' && Red_Module::is_valid_id( $module_id ) ) {
|
||||
$position = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT( * ) FROM {$wpdb->prefix}redirection_groups WHERE module_id=%d", $module_id ) );
|
||||
|
||||
$data = array(
|
||||
'name' => trim( $name ),
|
||||
'module_id' => intval( $module_id ),
|
||||
'position' => intval( $position ),
|
||||
'status' => $enabled ? 'enabled' : 'disabled',
|
||||
);
|
||||
|
||||
$wpdb->insert( $wpdb->prefix . 'redirection_groups', $data );
|
||||
|
||||
return self::get( $wpdb->insert_id );
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update group details
|
||||
*
|
||||
* @param array<string, mixed> $data Update data.
|
||||
* @return bool
|
||||
*/
|
||||
public function update( $data ) {
|
||||
global $wpdb;
|
||||
|
||||
$old_id = $this->module_id;
|
||||
$this->name = trim( wp_kses( sanitize_text_field( $data['name'] ), 'strip' ) );
|
||||
$this->name = substr( $this->name, 0, 50 );
|
||||
|
||||
if ( Red_Module::is_valid_id( intval( $data['moduleId'], 10 ) ) ) {
|
||||
$this->module_id = intval( $data['moduleId'], 10 );
|
||||
}
|
||||
|
||||
$wpdb->update( $wpdb->prefix . 'redirection_groups', array( 'name' => $this->name, 'module_id' => $this->module_id ), array( 'id' => intval( $this->id ) ) );
|
||||
|
||||
if ( $old_id !== $this->module_id ) {
|
||||
Red_Module::flush_by_module( $old_id );
|
||||
Red_Module::flush_by_module( $this->module_id );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete this group and all its redirects
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function delete() {
|
||||
global $wpdb;
|
||||
|
||||
// Delete all items in this group
|
||||
$wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}redirection_items WHERE group_id=%d", $this->id ) );
|
||||
|
||||
Red_Module::flush( $this->id );
|
||||
|
||||
// Delete the group
|
||||
$wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}redirection_groups WHERE id=%d", $this->id ) );
|
||||
|
||||
if ( $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}redirection_groups" ) === 0 ) {
|
||||
$wpdb->insert( $wpdb->prefix . 'redirection_groups', array( 'name' => __( 'Redirections', 'redirection' ), 'module_id' => 1, 'position' => 0 ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total number of redirects in this group
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function get_total_redirects() {
|
||||
global $wpdb;
|
||||
|
||||
return intval( $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}redirection_items WHERE group_id=%d", $this->id ) ), 10 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable this group and all its redirects
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function enable() {
|
||||
global $wpdb;
|
||||
|
||||
$wpdb->update( $wpdb->prefix . 'redirection_groups', array( 'status' => 'enabled' ), array( 'id' => $this->id ) );
|
||||
$wpdb->update( $wpdb->prefix . 'redirection_items', array( 'status' => 'enabled' ), array( 'group_id' => $this->id ) );
|
||||
|
||||
Red_Module::flush( $this->id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable this group and all its redirects
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function disable() {
|
||||
global $wpdb;
|
||||
|
||||
$wpdb->update( $wpdb->prefix . 'redirection_groups', array( 'status' => 'disabled' ), array( 'id' => $this->id ) );
|
||||
$wpdb->update( $wpdb->prefix . 'redirection_items', array( 'status' => 'disabled' ), array( 'group_id' => $this->id ) );
|
||||
|
||||
Red_Module::flush( $this->id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the module ID for this group
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function get_module_id() {
|
||||
return $this->module_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filtered groups with pagination
|
||||
*
|
||||
* @param array<string, mixed> $params Filter and pagination parameters.
|
||||
* @return GroupFilteredResult
|
||||
*/
|
||||
public static function get_filtered( array $params ) {
|
||||
global $wpdb;
|
||||
|
||||
$orderby = 'name';
|
||||
$direction = 'DESC';
|
||||
$limit = self::DEFAULT_PER_PAGE;
|
||||
$offset = 0;
|
||||
$where = '';
|
||||
|
||||
if ( isset( $params['orderby'] ) && in_array( $params['orderby'], array( 'name', 'id' ), true ) ) {
|
||||
$orderby = $params['orderby'];
|
||||
}
|
||||
|
||||
if ( isset( $params['direction'] ) && in_array( $params['direction'], array( 'asc', 'desc' ), true ) ) {
|
||||
$direction = strtoupper( $params['direction'] );
|
||||
}
|
||||
|
||||
if ( isset( $params['filterBy'] ) && is_array( $params['filterBy'] ) ) {
|
||||
$filters = new Red_Group_Filters( $params['filterBy'] );
|
||||
$where = $filters->get_as_sql();
|
||||
}
|
||||
|
||||
if ( isset( $params['per_page'] ) ) {
|
||||
$limit = intval( $params['per_page'], 10 );
|
||||
$limit = min( self::MAX_PER_PAGE, $limit );
|
||||
$limit = max( 5, $limit );
|
||||
}
|
||||
|
||||
if ( isset( $params['page'] ) ) {
|
||||
$offset = intval( $params['page'], 10 );
|
||||
$offset = max( 0, $offset );
|
||||
$offset *= $limit;
|
||||
}
|
||||
|
||||
$rows = $wpdb->get_results(
|
||||
// phpcs:ignore
|
||||
"SELECT * FROM {$wpdb->prefix}redirection_groups $where " . $wpdb->prepare( "ORDER BY $orderby $direction LIMIT %d,%d", $offset, $limit )
|
||||
);
|
||||
$total_items = intval( $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}redirection_groups " . $where ) );
|
||||
$items = array();
|
||||
|
||||
$options = Red_Options::get();
|
||||
|
||||
foreach ( $rows as $row ) {
|
||||
$group = new Red_Group( $row );
|
||||
$group_json = $group->to_json();
|
||||
|
||||
if ( $group->get_id() === $options['last_group_id'] ) {
|
||||
$group_json['default'] = true;
|
||||
}
|
||||
|
||||
$items[] = $group_json;
|
||||
}
|
||||
|
||||
return array(
|
||||
'items' => $items,
|
||||
'total' => intval( $total_items, 10 ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert group to JSON representation
|
||||
*
|
||||
* @return GroupJson
|
||||
*/
|
||||
public function to_json() {
|
||||
$module = Red_Module::get( $this->get_module_id() );
|
||||
|
||||
return array(
|
||||
'id' => $this->get_id(),
|
||||
'name' => $this->get_name(),
|
||||
'redirects' => $this->get_total_redirects(),
|
||||
'module_id' => $this->get_module_id(),
|
||||
'moduleName' => $module ? $module->get_name() : '',
|
||||
'enabled' => $this->is_enabled(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all groups matching filters
|
||||
*
|
||||
* @param array<string, mixed> $params Filter parameters.
|
||||
* @return void
|
||||
*/
|
||||
public static function delete_all( array $params ) {
|
||||
global $wpdb;
|
||||
|
||||
$filters = new Red_Group_Filters( isset( $params['filterBy'] ) ? $params['filterBy'] : [] );
|
||||
$query = $filters->get_as_sql();
|
||||
|
||||
$sql = "DELETE FROM {$wpdb->prefix}redirection_groups {$query}";
|
||||
|
||||
// phpcs:ignore
|
||||
$wpdb->query( $sql );
|
||||
}
|
||||
|
||||
/**
|
||||
* Set status for all groups matching filters
|
||||
*
|
||||
* @param string $action Action to perform ('enable' or 'disable').
|
||||
* @param array<string, mixed> $params Filter parameters.
|
||||
* @return void
|
||||
*/
|
||||
public static function set_status_all( $action, array $params ) {
|
||||
global $wpdb;
|
||||
|
||||
$filters = new Red_Group_Filters( isset( $params['filterBy'] ) ? $params['filterBy'] : [] );
|
||||
$query = $filters->get_as_sql();
|
||||
|
||||
// phpcs:ignore
|
||||
$sql = $wpdb->prepare( "UPDATE {$wpdb->prefix}redirection_groups SET status=%s {$query}", $action === 'enable' ? 'enable' : 'disable' );
|
||||
|
||||
// phpcs:ignore
|
||||
$wpdb->query( $sql );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Manages HTTP headers for site-wide and redirect-specific use
|
||||
*
|
||||
* @phpstan-type HeaderSettingChoice array{
|
||||
* label: string,
|
||||
* value: string
|
||||
* }
|
||||
* @phpstan-type HeaderSettings array<string, string|array<HeaderSettingChoice>>
|
||||
* @phpstan-type HeaderData array{
|
||||
* type: string,
|
||||
* headerName: string,
|
||||
* headerValue: string,
|
||||
* location: string,
|
||||
* headerSettings: HeaderSettings
|
||||
* }
|
||||
* @phpstan-type RawHeaderInput array{
|
||||
* headerName?: string,
|
||||
* type?: string,
|
||||
* headerValue?: string,
|
||||
* location?: string,
|
||||
* headerSettings?: array<string, mixed>
|
||||
* }
|
||||
*/
|
||||
class Red_Http_Headers {
|
||||
/**
|
||||
* Normalized headers
|
||||
*
|
||||
* @var array<HeaderData>
|
||||
*/
|
||||
private $headers = [];
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param array<RawHeaderInput> $options Raw header configuration.
|
||||
*/
|
||||
public function __construct( $options = [] ) {
|
||||
// @phpstan-ignore function.alreadyNarrowedType
|
||||
if ( is_array( $options ) ) {
|
||||
$this->headers = array_filter( array_map( [ $this, 'normalize' ], $options ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw header input
|
||||
*
|
||||
* @param RawHeaderInput $header Raw header data.
|
||||
* @return HeaderData|null Normalized header or null if invalid.
|
||||
*/
|
||||
private function normalize( $header ) {
|
||||
$location = 'site';
|
||||
if ( isset( $header['location'] ) && $header['location'] === 'redirect' ) {
|
||||
$location = 'redirect';
|
||||
}
|
||||
|
||||
$name = $this->sanitize( isset( $header['headerName'] ) ? sanitize_text_field( $header['headerName'] ) : '' );
|
||||
$type = $this->sanitize( isset( $header['type'] ) ? sanitize_text_field( $header['type'] ) : '' );
|
||||
$value = $this->sanitize( isset( $header['headerValue'] ) ? sanitize_text_field( $header['headerValue'] ) : '' );
|
||||
$settings = [];
|
||||
|
||||
// @phpstan-ignore booleanAnd.rightAlwaysTrue
|
||||
if ( isset( $header['headerSettings'] ) && is_array( $header['headerSettings'] ) ) {
|
||||
foreach ( $header['headerSettings'] as $key => $setting_value ) {
|
||||
if ( is_array( $setting_value ) ) {
|
||||
if ( isset( $setting_value['value'] ) ) {
|
||||
$settings[ $this->sanitize( sanitize_text_field( $key ) ) ] = $this->sanitize( $setting_value['value'] );
|
||||
} elseif ( isset( $setting_value['choices'] ) ) {
|
||||
$settings[ $this->sanitize( sanitize_text_field( $key ) ) ] = array_map(
|
||||
function ( $choice ) {
|
||||
return [
|
||||
'label' => $this->sanitize( isset( $choice['label'] ) ? $choice['label'] : '' ),
|
||||
'value' => $this->sanitize( isset( $choice['value'] ) ? $choice['value'] : '' ),
|
||||
];
|
||||
},
|
||||
$setting_value['choices']
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$settings[ $this->sanitize( sanitize_text_field( $key ) ) ] = $this->sanitize( $setting_value );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( strlen( $name ) > 0 && strlen( $type ) > 0 ) {
|
||||
return [
|
||||
'type' => $this->dash_case( $type ),
|
||||
'headerName' => $this->dash_case( $name ),
|
||||
'headerValue' => $value,
|
||||
'location' => $location,
|
||||
'headerSettings' => $settings,
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get headers as JSON
|
||||
*
|
||||
* @return array<HeaderData>
|
||||
*/
|
||||
public function get_json() {
|
||||
return $this->headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string to Dash-Case format
|
||||
*
|
||||
* @param string $name Input string.
|
||||
* @return string Dash-Case formatted string.
|
||||
*/
|
||||
private function dash_case( $name ) {
|
||||
$name = (string) preg_replace( '/[^A-Za-z0-9]/', ' ', $name );
|
||||
$name = (string) preg_replace( '/\s{2,}/', ' ', $name );
|
||||
$name = trim( $name, ' ' );
|
||||
$name = ucwords( $name );
|
||||
$name = str_replace( ' ', '-', $name );
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove duplicate headers by name
|
||||
*
|
||||
* @param array<HeaderData> $headers Headers to deduplicate.
|
||||
* @return array<HeaderData>
|
||||
*/
|
||||
private function remove_dupes( $headers ) {
|
||||
$new_headers = [];
|
||||
|
||||
foreach ( $headers as $header ) {
|
||||
$new_headers[ $header['headerName'] ] = $header;
|
||||
}
|
||||
|
||||
return array_values( $new_headers );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get headers for site-wide application
|
||||
*
|
||||
* @return array<HeaderData>
|
||||
*/
|
||||
public function get_site_headers() {
|
||||
$headers = array_values( $this->remove_dupes( array_filter( $this->headers, [ $this, 'is_site_header' ] ) ) );
|
||||
|
||||
return apply_filters( 'redirection_headers_site', $headers );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get headers for redirects (combines site and redirect-specific headers)
|
||||
*
|
||||
* @return array<HeaderData>
|
||||
*/
|
||||
public function get_redirect_headers() {
|
||||
// Site ones first, then redirect - redirect will override any site ones
|
||||
$headers = $this->get_site_headers();
|
||||
$headers = array_merge( $headers, array_values( array_filter( $this->headers, [ $this, 'is_redirect_header' ] ) ) );
|
||||
$headers = array_values( $this->remove_dupes( $headers ) );
|
||||
|
||||
return apply_filters( 'redirection_headers_redirect', $headers );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if header is a site header
|
||||
*
|
||||
* @param HeaderData $header Header to check.
|
||||
* @return bool
|
||||
*/
|
||||
private function is_site_header( $header ) {
|
||||
return $header['location'] === 'site';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if header is a redirect header
|
||||
*
|
||||
* @param HeaderData $header Header to check.
|
||||
* @return bool
|
||||
*/
|
||||
private function is_redirect_header( $header ) {
|
||||
return $header['location'] === 'redirect';
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply headers to the current request
|
||||
*
|
||||
* @param array<HeaderData> $headers Headers to apply.
|
||||
* @return void
|
||||
*/
|
||||
public function run( $headers ) {
|
||||
$done = [];
|
||||
|
||||
foreach ( $headers as $header ) {
|
||||
if ( ! in_array( $header['headerName'], $done, true ) ) {
|
||||
$name = $this->sanitize( $this->dash_case( $header['headerName'] ) );
|
||||
$value = $this->sanitize( $header['headerValue'] );
|
||||
|
||||
// Trigger some other action
|
||||
do_action( 'redirection_header', $name, $value );
|
||||
|
||||
header( sprintf( '%s: %s', $name, $value ) );
|
||||
$done[] = $header['headerName'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a string for use in headers
|
||||
*
|
||||
* @param string|array<mixed> $text Text to sanitize.
|
||||
* @return string Sanitized text.
|
||||
*/
|
||||
private function sanitize( $text ) {
|
||||
if ( is_array( $text ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// No new lines
|
||||
$text = (string) preg_replace( "/[\r\n\t].*?$/s", '', $text );
|
||||
|
||||
// Clean control codes
|
||||
$text = (string) preg_replace( '/[^\PC\s]/u', '', $text );
|
||||
|
||||
// Try and remove bad decoding
|
||||
// @phpstan-ignore function.alreadyNarrowedType
|
||||
if ( function_exists( 'iconv' ) && is_string( $text ) ) {
|
||||
$converted = @iconv( 'UTF-8', 'UTF-8//IGNORE', $text );
|
||||
if ( $converted !== false ) {
|
||||
$text = $converted;
|
||||
}
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Convert redirects to .htaccess format
|
||||
*
|
||||
* Ignores:
|
||||
* - Trailing slash flag
|
||||
* - Query flags
|
||||
*/
|
||||
class Red_Htaccess {
|
||||
/**
|
||||
* Array of redirect lines
|
||||
*
|
||||
* @var array<string>
|
||||
*/
|
||||
private $items = array();
|
||||
|
||||
const INSERT_REGEX = '@\n?# Created by Redirection(?:.*?)# End of Redirection\n?@sm';
|
||||
|
||||
/**
|
||||
* Encode the 'from' URL
|
||||
*
|
||||
* @param string $url From URL.
|
||||
* @param bool $ignore_trailing Ignore trailing slashes.
|
||||
* @return string
|
||||
*/
|
||||
private function encode_from( $url, $ignore_trailing ) {
|
||||
$url = $this->encode( $url );
|
||||
|
||||
// Apache 2 does not need a leading slashing
|
||||
$url = ltrim( $url, '/' );
|
||||
|
||||
if ( $ignore_trailing ) {
|
||||
$url = rtrim( $url, '/' ) . '/?';
|
||||
}
|
||||
|
||||
// Exactly match the URL
|
||||
return '^' . $url . '$';
|
||||
}
|
||||
|
||||
/**
|
||||
* URL encode some things, but other things can be passed through
|
||||
*
|
||||
* @param string $url URL.
|
||||
* @return string
|
||||
*/
|
||||
private function encode2nd( $url ) {
|
||||
$allowed = [
|
||||
'%2F' => '/',
|
||||
'%3F' => '?',
|
||||
'%3A' => ':',
|
||||
'%3D' => '=',
|
||||
'%26' => '&',
|
||||
'%25' => '%',
|
||||
'+' => '%20',
|
||||
'%24' => '$',
|
||||
'%23' => '#',
|
||||
];
|
||||
|
||||
$url = rawurlencode( $url );
|
||||
return $this->replace_encoding( $url, $allowed );
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace encoded characters in a URL
|
||||
*
|
||||
* @param string $str Source string.
|
||||
* @param array<string, string> $allowed Allowed encodings.
|
||||
* @return string
|
||||
*/
|
||||
private function replace_encoding( $str, array $allowed ) {
|
||||
foreach ( $allowed as $before => $after ) {
|
||||
$str = str_replace( $before, $after, $str );
|
||||
}
|
||||
|
||||
return $str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a URL
|
||||
*
|
||||
* @param string $url URL.
|
||||
* @return string
|
||||
*/
|
||||
private function encode( $url ) {
|
||||
$allowed = [
|
||||
'%2F' => '/',
|
||||
'%3F' => '?',
|
||||
'+' => '\\s',
|
||||
'.' => '\\.',
|
||||
'%20' => '\\s',
|
||||
];
|
||||
|
||||
return $this->replace_encoding( rawurlencode( $url ), $allowed );
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a regex URL
|
||||
*
|
||||
* @param string $url URL.
|
||||
* @return string
|
||||
*/
|
||||
private function encode_regex( $url ) {
|
||||
// Remove any newlines
|
||||
$url = (string) preg_replace( "/[\r\n\t].*?$/s", '', $url );
|
||||
|
||||
// Remove invalid characters
|
||||
$url = (string) preg_replace( '/[^\PC\s]/u', '', $url );
|
||||
|
||||
// Make sure spaces are escaped as \s for regex matching
|
||||
$url = str_replace( ' ', '\\s', $url );
|
||||
$url = str_replace( '%24', '$', $url );
|
||||
|
||||
// No leading slash
|
||||
$url = ltrim( $url, '/' );
|
||||
|
||||
// If pattern has a ^ at the start then ensure we don't have a slash immediatley after
|
||||
$url = (string) preg_replace( '@^\^/@', '^', $url );
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a referrer redirect
|
||||
*
|
||||
* @param Red_Item $item Redirect item.
|
||||
* @param Referrer_Match $match_object Redirect match.
|
||||
* @return void
|
||||
*/
|
||||
private function add_referrer( $item, $match_object ) {
|
||||
$from = $this->encode_from( ltrim( $item->get_url(), '/' ), $item->source_flags !== null && $item->source_flags->is_ignore_trailing() );
|
||||
if ( $item->is_regex() ) {
|
||||
$from = $this->encode_regex( ltrim( $item->get_url(), '/' ) );
|
||||
}
|
||||
|
||||
if ( ( $match_object->url_from !== '' || $match_object->url_notfrom !== '' ) && $match_object->referrer !== '' ) {
|
||||
$referrer = $match_object->regex ? $this->encode_regex( $match_object->referrer ) : $this->encode_from( $match_object->referrer, false );
|
||||
$to = false;
|
||||
$match_data = $item->get_match_data();
|
||||
|
||||
if ( $match_object->url_from !== '' && $match_data !== null ) {
|
||||
$to = $this->target( $item->get_action_type(), $match_object->url_from, $item->get_action_code(), $match_data );
|
||||
}
|
||||
|
||||
if ( $match_object->url_notfrom !== '' && $match_data !== null ) {
|
||||
$to = $this->target( $item->get_action_type(), $match_object->url_notfrom, $item->get_action_code(), $match_data );
|
||||
}
|
||||
|
||||
$this->items[] = sprintf( 'RewriteCond %%{HTTP_REFERER} %s [NC]', $referrer );
|
||||
if ( $to !== false ) {
|
||||
$this->items[] = sprintf( 'RewriteRule %s %s', $from, $to );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a useragent redirect
|
||||
*
|
||||
* @param Red_Item $item Redirect item.
|
||||
* @param Agent_Match $match_object Redirect match.
|
||||
* @return void
|
||||
*/
|
||||
private function add_agent( $item, $match_object ) {
|
||||
$from = $this->encode( ltrim( $item->get_url(), '/' ) );
|
||||
if ( $item->is_regex() ) {
|
||||
$from = $this->encode_regex( ltrim( $item->get_url(), '/' ) );
|
||||
}
|
||||
|
||||
if ( ( $match_object->url_from !== '' || $match_object->url_notfrom !== '' ) && $match_object->agent !== '' ) {
|
||||
$agent = ( $match_object->regex ? $this->encode_regex( $match_object->agent ) : $this->encode2nd( $match_object->agent ) );
|
||||
$to = false;
|
||||
$match_data = $item->get_match_data();
|
||||
|
||||
if ( $match_object->url_from !== '' && $match_data !== null ) {
|
||||
$to = $this->target( $item->get_action_type(), $match_object->url_from, $item->get_action_code(), $match_data );
|
||||
}
|
||||
|
||||
if ( $match_object->url_notfrom !== '' && $match_data !== null ) {
|
||||
$to = $this->target( $item->get_action_type(), $match_object->url_notfrom, $item->get_action_code(), $match_data );
|
||||
}
|
||||
|
||||
$this->items[] = sprintf( 'RewriteCond %%{HTTP_USER_AGENT} %s [NC]', $agent );
|
||||
if ( $to !== false ) {
|
||||
$this->items[] = sprintf( 'RewriteRule %s %s', $from, $to );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a server redirect
|
||||
*
|
||||
* @param Red_Item $item Redirect item.
|
||||
* @param Server_Match $match_object Redirect match.
|
||||
* @return void
|
||||
*/
|
||||
private function add_server( $item, $match_object ) {
|
||||
// Temporarily set url property for add_url method
|
||||
$host = wp_parse_url( $match_object->server, PHP_URL_HOST );
|
||||
if ( is_string( $host ) ) {
|
||||
$this->items[] = sprintf( 'RewriteCond %%{HTTP_HOST} ^%s$ [NC]', preg_quote( $host, '/' ) );
|
||||
}
|
||||
$this->add_url( $item, $match_object->url_from );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a redirect
|
||||
*
|
||||
* @param Red_Item $item Redirect item.
|
||||
* @param string $target_url Target URL.
|
||||
* @return void
|
||||
*/
|
||||
private function add_url( $item, $target_url ) {
|
||||
$url = $item->get_url();
|
||||
|
||||
if ( $item->is_regex() === false && strpos( $url, '?' ) !== false ) {
|
||||
$url_parts = wp_parse_url( $url );
|
||||
|
||||
if ( isset( $url_parts['path'] ) ) {
|
||||
$url = $url_parts['path'];
|
||||
$query = isset( $url_parts['query'] ) ? $url_parts['query'] : '';
|
||||
$this->items[] = sprintf( 'RewriteCond %%{QUERY_STRING} ^%s$', $query );
|
||||
}
|
||||
}
|
||||
|
||||
$to = '';
|
||||
$match_data = $item->get_match_data();
|
||||
if ( $match_data !== null && $target_url !== '' ) {
|
||||
$to = $this->target( $item->get_action_type(), $target_url, $item->get_action_code(), $match_data );
|
||||
}
|
||||
|
||||
$from = $this->encode_from( $url, $item->source_flags !== null && $item->source_flags->is_ignore_trailing() );
|
||||
|
||||
if ( $item->is_regex() ) {
|
||||
$from = $this->encode_regex( $item->get_url() );
|
||||
}
|
||||
|
||||
if ( $to !== '' ) {
|
||||
$this->items[] = sprintf( 'RewriteRule %s %s', trim( $from ), trim( $to ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a redirect flags
|
||||
*
|
||||
* @param string $current Current redirect rule.
|
||||
* @param array<string> $flags Flags to add.
|
||||
* @return string
|
||||
*/
|
||||
private function add_flags( string $current, array $flags ) {
|
||||
return $current . ' [' . implode( ',', $flags ) . ']';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get source flags
|
||||
*
|
||||
* @param array<string> $existing Existing flags.
|
||||
* @param array<string, mixed> $source Source flags.
|
||||
* @param string $url URL.
|
||||
* @return array<string>
|
||||
*/
|
||||
private function get_source_flags( array $existing, array $source, string $url ) {
|
||||
$flags = [];
|
||||
|
||||
if ( isset( $source['flag_case'] ) && $source['flag_case'] !== false ) {
|
||||
$flags[] = 'NC';
|
||||
}
|
||||
|
||||
if ( isset( $source['flag_query'] ) && $source['flag_query'] === 'pass' ) {
|
||||
$flags[] = 'QSA';
|
||||
}
|
||||
|
||||
if ( strpos( $url, '#' ) !== false || strpos( $url, '%' ) !== false ) {
|
||||
$flags[] = 'NE';
|
||||
}
|
||||
|
||||
return array_merge( $existing, $flags );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a random target.
|
||||
*
|
||||
* @param string $data Target URL data.
|
||||
* @param int $code HTTP status code.
|
||||
* @param array<string, mixed> $match_data Match data including source flags.
|
||||
* @return string
|
||||
*/
|
||||
private function action_random( string $data, int $code, array $match_data ) {
|
||||
// Pick a WP post at random
|
||||
global $wpdb;
|
||||
|
||||
$post = $wpdb->get_var( "SELECT ID FROM {$wpdb->posts} ORDER BY RAND() LIMIT 0,1" );
|
||||
$permalink = get_permalink( $post );
|
||||
if ( $permalink === false ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$url = wp_parse_url( $permalink );
|
||||
if ( $url === false || ! isset( $url['path'] ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$flags = [ sprintf( 'R=%d', $code ) ];
|
||||
$flags[] = 'L';
|
||||
$flags = $this->get_source_flags( $flags, $match_data['source'], $data );
|
||||
|
||||
return $this->add_flags( $this->encode( $url['path'] ), $flags );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a passthrough target.
|
||||
*
|
||||
* @param string $data Target URL data.
|
||||
* @param int $code HTTP status code.
|
||||
* @param array<string, mixed> $match_data Match data including source flags.
|
||||
* @return string
|
||||
*/
|
||||
private function action_pass( string $data, int $code, array $match_data ) {
|
||||
$flags = $this->get_source_flags( [ 'L' ], $match_data['source'], $data );
|
||||
|
||||
return $this->add_flags( $this->encode2nd( $data ), $flags );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an error target.
|
||||
*
|
||||
* @param string $data Target URL data.
|
||||
* @param int $code HTTP status code.
|
||||
* @param array<string, mixed> $match_data Match data including source flags.
|
||||
* @return string
|
||||
*/
|
||||
private function action_error( string $data, int $code, array $match_data ) {
|
||||
$flags = $this->get_source_flags( [ 'F' ], $match_data['source'], $data );
|
||||
|
||||
if ( $code === 410 ) {
|
||||
$flags = $this->get_source_flags( [ 'G' ], $match_data['source'], $data );
|
||||
}
|
||||
|
||||
return $this->add_flags( '/', $flags );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a URL target.
|
||||
*
|
||||
* @param string $data Target URL data.
|
||||
* @param int $code HTTP status code.
|
||||
* @param array<string, mixed> $match_data Match data including source flags.
|
||||
* @return string
|
||||
*/
|
||||
private function action_url( string $data, int $code, array $match_data ) {
|
||||
$flags = [ sprintf( 'R=%d', $code ) ];
|
||||
$flags[] = 'L';
|
||||
$flags = $this->get_source_flags( $flags, $match_data['source'], $data );
|
||||
|
||||
return $this->add_flags( $this->encode2nd( $data ), $flags );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return URL target
|
||||
*
|
||||
* @param string $action Action type.
|
||||
* @param string $data Target URL data.
|
||||
* @param int $code HTTP status code.
|
||||
* @param array<string, mixed> $match_data Match data including source flags.
|
||||
* @return string
|
||||
*/
|
||||
private function target( string $action, string $data, int $code, array $match_data ) {
|
||||
$target = 'action_' . $action;
|
||||
|
||||
if ( method_exists( $this, $target ) ) {
|
||||
return $this->$target( $data, $code, $match_data ); // @phpstan-ignore-line
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the .htaccess file in memory
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function generate() {
|
||||
$version = red_get_plugin_data( dirname( __DIR__ ) . '/redirection.php' );
|
||||
|
||||
if ( count( $this->items ) === 0 ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$text = [
|
||||
'# Created by Redirection',
|
||||
'# ' . gmdate( 'r' ),
|
||||
'# Redirection ' . trim( $version['Version'] ) . ' - https://redirection.me',
|
||||
'',
|
||||
'<IfModule mod_rewrite.c>',
|
||||
];
|
||||
|
||||
// Add http => https option
|
||||
$options = Red_Options::get();
|
||||
if ( $options['https'] !== false ) {
|
||||
$text[] = 'RewriteCond %{HTTPS} off';
|
||||
$text[] = 'RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]';
|
||||
}
|
||||
|
||||
// Add redirects
|
||||
$text = array_merge( $text, array_filter( array_map( [ $this, 'sanitize_redirect' ], $this->items ) ) );
|
||||
|
||||
// End of mod_rewrite
|
||||
$text[] = '</IfModule>';
|
||||
$text[] = '';
|
||||
|
||||
// End of redirection section
|
||||
$text[] = '# End of Redirection';
|
||||
|
||||
$text = implode( "\n", $text );
|
||||
return "\n" . $text . "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a redirect to the file
|
||||
*
|
||||
* @param Red_Item $item Redirect.
|
||||
* @return void
|
||||
*/
|
||||
public function add( $item ) {
|
||||
$target = 'add_' . $item->get_match_type();
|
||||
|
||||
if ( method_exists( $this, $target ) && $item->is_enabled() ) {
|
||||
// For URL matches, extract target URL from match object
|
||||
if ( $target === 'add_url' && $item->match instanceof URL_Match ) {
|
||||
$this->add_url( $item, $item->match->url );
|
||||
} else {
|
||||
$this->$target( $item, $item->match ); // @phpstan-ignore-line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the .htaccess file
|
||||
*
|
||||
* @param string|false $existing Existing .htaccess data.
|
||||
* @return string
|
||||
*/
|
||||
public function get( $existing = false ) {
|
||||
$text = $this->generate();
|
||||
|
||||
if ( $existing !== false ) {
|
||||
if ( preg_match( self::INSERT_REGEX, $existing ) > 0 ) {
|
||||
$text = (string) preg_replace( self::INSERT_REGEX, str_replace( '$', '\\$', $text ), $existing );
|
||||
} else {
|
||||
$text = $text . "\n" . trim( $existing );
|
||||
}
|
||||
}
|
||||
|
||||
return trim( $text );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize the redirect
|
||||
*
|
||||
* @param string $text Text.
|
||||
* @return string
|
||||
*/
|
||||
public function sanitize_redirect( $text ) {
|
||||
$text = str_replace( [ "\r", "\n", "\t" ], '', $text );
|
||||
$text = (string) preg_replace( '/[^\PC\s]/u', '', $text );
|
||||
|
||||
return str_replace( [ '<?', '>' ], '', $text );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize the filename
|
||||
*
|
||||
* @param string $filename Filename.
|
||||
* @return string
|
||||
*/
|
||||
public function sanitize_filename( $filename ) {
|
||||
return str_replace( '.php', '', sanitize_text_field( $filename ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the .htaccess to a file
|
||||
*
|
||||
* @param string $filename Filename to save.
|
||||
* @param boolean $content_to_save Content to save (unused parameter).
|
||||
* @return bool
|
||||
*/
|
||||
public function save( $filename, $content_to_save = false ) {
|
||||
$existing = false;
|
||||
$filename = $this->sanitize_filename( $filename );
|
||||
|
||||
// Initialize WP_Filesystem
|
||||
global $wp_filesystem;
|
||||
if ( ! function_exists( 'WP_Filesystem' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/file.php';
|
||||
}
|
||||
|
||||
// Initialize the filesystem with direct method
|
||||
if ( WP_Filesystem() === null ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read existing file contents if file exists
|
||||
if ( $wp_filesystem->exists( $filename ) ) {
|
||||
$file_contents = $wp_filesystem->get_contents( $filename );
|
||||
if ( $file_contents !== false ) {
|
||||
$existing = $file_contents;
|
||||
}
|
||||
}
|
||||
|
||||
// Write the file
|
||||
return $wp_filesystem->put_contents( $filename, $this->get( $existing ), FS_CHMOD_FILE );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
// Loader for importer classes
|
||||
require_once __DIR__ . '/importer/base.php';
|
||||
require_once __DIR__ . '/importer/pretty-links.php';
|
||||
require_once __DIR__ . '/importer/quick-redirects.php';
|
||||
require_once __DIR__ . '/importer/rank-math.php';
|
||||
require_once __DIR__ . '/importer/simple301.php';
|
||||
require_once __DIR__ . '/importer/wordpress-old-slugs.php';
|
||||
require_once __DIR__ . '/importer/seo-redirection.php';
|
||||
require_once __DIR__ . '/importer/safe-redirect-manager.php';
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @phpstan-type ImporterInfo array{
|
||||
* id: string,
|
||||
* name: string,
|
||||
* total: int
|
||||
* }
|
||||
*/
|
||||
abstract class Red_Plugin_Importer {
|
||||
/**
|
||||
* @return list<array{id: string, name: string, total: int}>
|
||||
*/
|
||||
public static function get_plugins(): array {
|
||||
$results = array();
|
||||
|
||||
$importers = array(
|
||||
'wp-simple-redirect',
|
||||
'seo-redirection',
|
||||
'safe-redirect-manager',
|
||||
'wordpress-old-slugs',
|
||||
'rank-math',
|
||||
'quick-redirects',
|
||||
'pretty-links',
|
||||
);
|
||||
|
||||
foreach ( $importers as $importer_id ) {
|
||||
$importer = self::get_importer( $importer_id );
|
||||
if ( ! $importer instanceof Red_Plugin_Importer ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = $importer->get_data();
|
||||
if ( $data === false || $data['total'] === 0 ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$results[] = $data;
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an importer instance by ID.
|
||||
*
|
||||
* @param string $id Importer identifier.
|
||||
* @return Red_Plugin_Importer|false
|
||||
*/
|
||||
public static function get_importer( string $id ) {
|
||||
if ( $id === 'wp-simple-redirect' ) {
|
||||
return new Red_Simple301_Importer();
|
||||
}
|
||||
|
||||
if ( $id === 'seo-redirection' ) {
|
||||
return new Red_SeoRedirection_Importer();
|
||||
}
|
||||
|
||||
if ( $id === 'safe-redirect-manager' ) {
|
||||
return new Red_SafeRedirectManager_Importer();
|
||||
}
|
||||
|
||||
if ( $id === 'wordpress-old-slugs' ) {
|
||||
return new Red_WordPressOldSlug_Importer();
|
||||
}
|
||||
|
||||
if ( $id === 'rank-math' ) {
|
||||
return new Red_RankMath_Importer();
|
||||
}
|
||||
|
||||
if ( $id === 'quick-redirects' ) {
|
||||
return new Red_QuickRedirect_Importer();
|
||||
}
|
||||
|
||||
if ( $id === 'pretty-links' ) {
|
||||
return new Red_PrettyLinks_Importer();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import all redirects for a plugin ID into a target group.
|
||||
*
|
||||
* @param string $plugin Importer identifier.
|
||||
* @param int $group_id Target group ID.
|
||||
* @return int Number of imported redirects.
|
||||
*/
|
||||
public static function import( $plugin, $group_id ) {
|
||||
$importer = self::get_importer( $plugin );
|
||||
if ( $importer !== false ) {
|
||||
return $importer->import_plugin( $group_id );
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import using a specific importer instance.
|
||||
*
|
||||
* @param int $group_id Target group ID.
|
||||
* @return int Number of imported redirects.
|
||||
*/
|
||||
abstract public function import_plugin( $group_id );
|
||||
|
||||
/**
|
||||
* Get importer summary data used by UI/CLI.
|
||||
*
|
||||
* @return ImporterInfo|false
|
||||
*/
|
||||
abstract public function get_data();
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @phpstan-import-type ImporterInfo from Red_Plugin_Importer
|
||||
*/
|
||||
class Red_PrettyLinks_Importer extends Red_Plugin_Importer {
|
||||
/**
|
||||
* Import redirects from Pretty Links.
|
||||
*
|
||||
* @param int $group_id Target group ID.
|
||||
* @return int Number of imported redirects.
|
||||
*/
|
||||
public function import_plugin( $group_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$count = 0;
|
||||
$redirects = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}prli_links" );
|
||||
|
||||
foreach ( $redirects as $redirect ) {
|
||||
$created = $this->create_for_item( $group_id, $redirect );
|
||||
|
||||
if ( $created instanceof Red_Item ) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Redirection item for a Pretty Links row.
|
||||
*
|
||||
* @param int $group_id Target group ID.
|
||||
* @param stdClass $link Row from prli_links.
|
||||
* @return Red_Item|WP_Error Created redirect or error.
|
||||
*/
|
||||
private function create_for_item( $group_id, $link ) {
|
||||
$item = array(
|
||||
'url' => '/' . $link->slug,
|
||||
'action_data' => array( 'url' => $link->url ),
|
||||
'regex' => false,
|
||||
'group_id' => $group_id,
|
||||
'match_type' => 'url',
|
||||
'action_type' => 'url',
|
||||
'title' => $link->name,
|
||||
'action_code' => $link->redirect_type,
|
||||
);
|
||||
|
||||
return Red_Item::create( $item );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get importer summary for Pretty Links.
|
||||
*
|
||||
* @return ImporterInfo|false
|
||||
*/
|
||||
public function get_data() {
|
||||
$data = get_option( 'prli_db_version' );
|
||||
|
||||
if ( $data !== false ) {
|
||||
global $wpdb;
|
||||
|
||||
return [
|
||||
'id' => 'pretty-links',
|
||||
'name' => 'PrettyLinks',
|
||||
'total' => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}prli_links" ),
|
||||
];
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @phpstan-import-type ImporterInfo from Red_Plugin_Importer
|
||||
*/
|
||||
class Red_QuickRedirect_Importer extends Red_Plugin_Importer {
|
||||
/**
|
||||
* Import redirects from Quick Page/Post Redirects.
|
||||
*
|
||||
* @param int $group_id Target group ID.
|
||||
* @return int Number of imported redirects.
|
||||
*/
|
||||
public function import_plugin( $group_id ) {
|
||||
$redirects = get_option( 'quickppr_redirects' );
|
||||
$count = 0;
|
||||
|
||||
foreach ( $redirects as $source => $target ) {
|
||||
$item = $this->create_for_item( $group_id, $source, $target );
|
||||
|
||||
if ( $item instanceof Red_Item ) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Redirection item for a given source/target pair.
|
||||
*
|
||||
* @param int $group_id Target group ID.
|
||||
* @param string $source Source URL.
|
||||
* @param string $target Target URL.
|
||||
* @return Red_Item|WP_Error Created redirect or error.
|
||||
*/
|
||||
private function create_for_item( $group_id, $source, $target ) {
|
||||
$item = array(
|
||||
'url' => $source,
|
||||
'action_data' => array( 'url' => $target ),
|
||||
'regex' => false,
|
||||
'group_id' => $group_id,
|
||||
'match_type' => 'url',
|
||||
'action_type' => 'url',
|
||||
'action_code' => 301,
|
||||
);
|
||||
|
||||
return Red_Item::create( $item );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get importer summary for Quick Page/Post Redirects.
|
||||
*
|
||||
* @return ImporterInfo|false
|
||||
*/
|
||||
public function get_data() {
|
||||
$data = get_option( 'quickppr_redirects' );
|
||||
|
||||
if ( $data !== false ) {
|
||||
return array(
|
||||
'id' => 'quick-redirects',
|
||||
'name' => 'Quick Page/Post Redirects',
|
||||
'total' => count( $data ),
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
class Red_RankMath_Importer extends Red_Plugin_Importer {
|
||||
/**
|
||||
* Import redirects from RankMath.
|
||||
*
|
||||
* @param int $group_id Target group ID.
|
||||
* @return int Number of imported redirects.
|
||||
*/
|
||||
public function import_plugin( $group_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$count = 0;
|
||||
$redirects = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}rank_math_redirections" );
|
||||
|
||||
foreach ( $redirects as $redirect ) {
|
||||
$created = $this->create_for_item( $group_id, $redirect );
|
||||
$count += $created;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one or more Redirection items for a RankMath row.
|
||||
*
|
||||
* @param int $group_id Target group ID.
|
||||
* @param stdClass $redirect Row from rank_math_redirections.
|
||||
* @return int Number of created redirects for this row.
|
||||
*/
|
||||
private function create_for_item( $group_id, $redirect ) {
|
||||
// phpcs:ignore
|
||||
$sources = unserialize( $redirect->sources );
|
||||
$items = [];
|
||||
|
||||
foreach ( $sources as $source ) {
|
||||
$url = $source['pattern'];
|
||||
if ( substr( $url, 0, 1 ) !== '/' ) {
|
||||
$url = '/' . $url;
|
||||
}
|
||||
|
||||
$data = array(
|
||||
'url' => $url,
|
||||
'action_data' => array( 'url' => str_replace( '\\\\', '\\', $redirect->url_to ) ),
|
||||
'regex' => $source['comparison'] === 'regex' ? true : false,
|
||||
'group_id' => $group_id,
|
||||
'match_type' => 'url',
|
||||
'action_type' => 'url',
|
||||
'action_code' => $redirect->header_code,
|
||||
);
|
||||
|
||||
$items[] = Red_Item::create( $data );
|
||||
}
|
||||
|
||||
return count( $items );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get importer summary for RankMath.
|
||||
*
|
||||
* @return array{id: string, name: string, total: int}|false
|
||||
*/
|
||||
public function get_data() {
|
||||
global $wpdb;
|
||||
|
||||
if ( defined( 'REDIRECTION_TESTS' ) && REDIRECTION_TESTS ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( ! function_exists( 'is_plugin_active' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
||||
}
|
||||
|
||||
$total = 0;
|
||||
if ( is_plugin_active( 'seo-by-rank-math/rank-math.php' ) ) {
|
||||
$total = $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}rank_math_redirections" );
|
||||
}
|
||||
|
||||
if ( $total ) {
|
||||
return array(
|
||||
'id' => 'rank-math',
|
||||
'name' => 'RankMath',
|
||||
'total' => intval( $total, 10 ),
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @phpstan-import-type ImporterInfo from Red_Plugin_Importer
|
||||
*/
|
||||
class Red_SafeRedirectManager_Importer extends Red_Plugin_Importer {
|
||||
/**
|
||||
* Import redirects from Safe Redirect Manager.
|
||||
*
|
||||
* @param int $group_id Target group ID.
|
||||
* @return int Number of imported redirects.
|
||||
*/
|
||||
public function import_plugin( $group_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$count = 0;
|
||||
$redirects = $wpdb->get_results(
|
||||
"SELECT {$wpdb->prefix}postmeta.* FROM {$wpdb->prefix}postmeta INNER JOIN {$wpdb->prefix}posts ON {$wpdb->prefix}posts.ID={$wpdb->prefix}postmeta.post_id WHERE {$wpdb->prefix}postmeta.meta_key LIKE '_redirect_rule_%' AND {$wpdb->prefix}posts.post_status='publish'"
|
||||
);
|
||||
|
||||
// Group them by post ID
|
||||
$by_post = array();
|
||||
foreach ( $redirects as $redirect ) {
|
||||
if ( ! isset( $by_post[ $redirect->post_id ] ) ) {
|
||||
$by_post[ $redirect->post_id ] = array();
|
||||
}
|
||||
|
||||
$by_post[ $redirect->post_id ][ str_replace( '_redirect_rule_', '', $redirect->meta_key ) ] = $redirect->meta_value;
|
||||
}
|
||||
|
||||
// Now go through the redirects
|
||||
foreach ( $by_post as $post ) {
|
||||
$item = $this->create_for_item( $group_id, $post );
|
||||
|
||||
if ( $item instanceof Red_Item ) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Redirection item from a collected SRM post meta map.
|
||||
*
|
||||
* @param int $group_id Target group ID.
|
||||
* @param array<string,string> $post Map of SRM fields for a single link.
|
||||
* @return Red_Item|WP_Error Created redirect or error.
|
||||
*/
|
||||
private function create_for_item( $group_id, $post ) {
|
||||
$regex = false;
|
||||
$source = $post['from'];
|
||||
|
||||
if ( strpos( $post['from'], '*' ) !== false ) {
|
||||
$regex = true;
|
||||
$source = str_replace( '*', '.*', $source );
|
||||
} elseif ( isset( $post['from_regex'] ) && $post['from_regex'] === '1' ) {
|
||||
$regex = true;
|
||||
}
|
||||
|
||||
$data = array(
|
||||
'url' => $source,
|
||||
'action_data' => array( 'url' => $post['to'] ),
|
||||
'regex' => $regex,
|
||||
'group_id' => $group_id,
|
||||
'match_type' => 'url',
|
||||
'action_type' => 'url',
|
||||
'action_code' => intval( $post['status_code'], 10 ),
|
||||
);
|
||||
|
||||
return Red_Item::create( $data );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get importer summary for Safe Redirect Manager.
|
||||
*
|
||||
* @return ImporterInfo|false
|
||||
*/
|
||||
public function get_data() {
|
||||
global $wpdb;
|
||||
|
||||
$total = $wpdb->get_var(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}postmeta INNER JOIN {$wpdb->prefix}posts ON {$wpdb->prefix}posts.ID={$wpdb->prefix}postmeta.post_id WHERE {$wpdb->prefix}postmeta.meta_key = '_redirect_rule_from' AND {$wpdb->prefix}posts.post_status='publish'"
|
||||
);
|
||||
|
||||
if ( $total !== null ) {
|
||||
return array(
|
||||
'id' => 'safe-redirect-manager',
|
||||
'name' => 'Safe Redirect Manager',
|
||||
'total' => intval( $total, 10 ),
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @phpstan-import-type ImporterInfo from Red_Plugin_Importer
|
||||
*/
|
||||
class Red_SeoRedirection_Importer extends Red_Plugin_Importer {
|
||||
/**
|
||||
* Import redirects from SEO Redirection.
|
||||
*
|
||||
* @param int $group_id Target group ID.
|
||||
* @return int Number of imported redirects.
|
||||
*/
|
||||
public function import_plugin( $group_id ) {
|
||||
global $wpdb;
|
||||
|
||||
if ( defined( 'REDIRECTION_TESTS' ) && REDIRECTION_TESTS ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$redirects = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}WP_SEO_Redirection" );
|
||||
|
||||
foreach ( $redirects as $redirect ) {
|
||||
$item = $this->create_for_item( $group_id, $redirect );
|
||||
|
||||
if ( $item instanceof Red_Item ) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Redirection item for an SEO Redirection row.
|
||||
*
|
||||
* @param int $group_id Target group ID.
|
||||
* @param stdClass $seo Row from WP_SEO_Redirection.
|
||||
* @return Red_Item|WP_Error|false Created redirect, error, or false if disabled.
|
||||
*/
|
||||
private function create_for_item( $group_id, $seo ) {
|
||||
if ( intval( $seo->enabled, 10 ) === 0 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = array(
|
||||
'url' => $seo->regex ? $seo->regex : $seo->redirect_from,
|
||||
'action_data' => array( 'url' => $seo->redirect_to ),
|
||||
'regex' => $seo->regex ? true : false,
|
||||
'group_id' => $group_id,
|
||||
'match_type' => 'url',
|
||||
'action_type' => 'url',
|
||||
'action_code' => intval( $seo->redirect_type, 10 ),
|
||||
);
|
||||
|
||||
return Red_Item::create( $data );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get importer summary for SEO Redirection.
|
||||
*
|
||||
* @return ImporterInfo|false
|
||||
*/
|
||||
public function get_data() {
|
||||
global $wpdb;
|
||||
|
||||
$plugins = get_option( 'active_plugins', array() );
|
||||
$found = false;
|
||||
|
||||
foreach ( $plugins as $plugin ) {
|
||||
if ( strpos( $plugin, 'seo-redirection.php' ) !== false ) {
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( $found ) {
|
||||
$total = $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}WP_SEO_Redirection" );
|
||||
|
||||
return array(
|
||||
'id' => 'seo-redirection',
|
||||
'name' => 'SEO Redirection',
|
||||
'total' => $total,
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @phpstan-import-type ImporterInfo from Red_Plugin_Importer
|
||||
*/
|
||||
class Red_Simple301_Importer extends Red_Plugin_Importer {
|
||||
/**
|
||||
* Import redirects from Simple 301 Redirects.
|
||||
*
|
||||
* @param int $group_id Target group ID.
|
||||
* @return int Number of imported redirects.
|
||||
*/
|
||||
public function import_plugin( $group_id ) {
|
||||
$redirects = get_option( '301_redirects' );
|
||||
$count = 0;
|
||||
|
||||
foreach ( $redirects as $source => $target ) {
|
||||
$item = $this->create_for_item( $group_id, $source, $target );
|
||||
|
||||
if ( $item instanceof Red_Item ) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Redirection item for a given source/target pair.
|
||||
*
|
||||
* @param int $group_id Target group ID.
|
||||
* @param string $source Source pattern from Simple 301.
|
||||
* @param string $target Target URL from Simple 301.
|
||||
* @return Red_Item|WP_Error Created redirect or error.
|
||||
*/
|
||||
private function create_for_item( $group_id, $source, $target ) {
|
||||
$item = array(
|
||||
'url' => str_replace( '*', '(.*?)', $source ),
|
||||
'action_data' => array( 'url' => str_replace( '*', '$1', trim( $target ) ) ),
|
||||
'regex' => strpos( $source, '*' ) === false ? false : true,
|
||||
'group_id' => $group_id,
|
||||
'match_type' => 'url',
|
||||
'action_type' => 'url',
|
||||
'action_code' => 301,
|
||||
);
|
||||
|
||||
return Red_Item::create( $item );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get importer summary for Simple 301 Redirects.
|
||||
*
|
||||
* @return ImporterInfo|false
|
||||
*/
|
||||
public function get_data() {
|
||||
$data = get_option( '301_redirects' );
|
||||
|
||||
if ( $data !== false ) {
|
||||
return array(
|
||||
'id' => 'wp-simple-redirect',
|
||||
'name' => 'Simple 301 Redirects',
|
||||
'total' => count( $data ),
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @phpstan-import-type ImporterInfo from Red_Plugin_Importer
|
||||
*/
|
||||
class Red_WordPressOldSlug_Importer extends Red_Plugin_Importer {
|
||||
/**
|
||||
* Import redirects for WordPress old slugs.
|
||||
*
|
||||
* @param int $group_id Target group ID.
|
||||
* @return int Number of imported redirects.
|
||||
*/
|
||||
public function import_plugin( $group_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$count = 0;
|
||||
$redirects = $wpdb->get_results(
|
||||
"SELECT {$wpdb->prefix}postmeta.* FROM {$wpdb->prefix}postmeta INNER JOIN {$wpdb->prefix}posts ON {$wpdb->prefix}posts.ID={$wpdb->prefix}postmeta.post_id " .
|
||||
"WHERE {$wpdb->prefix}postmeta.meta_key = '_wp_old_slug' AND {$wpdb->prefix}postmeta.meta_value != '' AND {$wpdb->prefix}posts.post_status='publish' AND {$wpdb->prefix}posts.post_type IN ('page', 'post')"
|
||||
);
|
||||
|
||||
foreach ( $redirects as $redirect ) {
|
||||
$item = $this->create_for_item( $group_id, $redirect );
|
||||
|
||||
if ( $item instanceof Red_Item ) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Redirection item for a WordPress old slug row.
|
||||
*
|
||||
* @param int $group_id Target group ID.
|
||||
* @param stdClass $redirect Row from postmeta/posts join.
|
||||
* @return Red_Item|WP_Error|false Created redirect, error, or false on permalink error.
|
||||
*/
|
||||
private function create_for_item( $group_id, $redirect ) {
|
||||
$new = get_permalink( $redirect->post_id );
|
||||
if ( $new === false ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$new_path = wp_parse_url( $new, PHP_URL_PATH );
|
||||
if ( $new_path === false || $new_path === null ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$old = rtrim( dirname( $new_path ), '/' ) . '/' . rtrim( $redirect->meta_value, '/' ) . '/';
|
||||
$old = str_replace( '\\', '', $old );
|
||||
$old = str_replace( '//', '/', $old );
|
||||
|
||||
$data = array(
|
||||
'url' => $old,
|
||||
'action_data' => array( 'url' => $new ),
|
||||
'regex' => false,
|
||||
'group_id' => $group_id,
|
||||
'match_type' => 'url',
|
||||
'action_type' => 'url',
|
||||
'action_code' => 301,
|
||||
);
|
||||
|
||||
return Red_Item::create( $data );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get importer summary for WordPress old slugs.
|
||||
*
|
||||
* @return ImporterInfo|false
|
||||
*/
|
||||
public function get_data() {
|
||||
global $wpdb;
|
||||
|
||||
$total = $wpdb->get_var(
|
||||
"SELECT COUNT(*) FROM {$wpdb->prefix}postmeta INNER JOIN {$wpdb->prefix}posts ON {$wpdb->prefix}posts.ID={$wpdb->prefix}postmeta.post_id WHERE {$wpdb->prefix}postmeta.meta_key = '_wp_old_slug' AND {$wpdb->prefix}postmeta.meta_value != '' AND {$wpdb->prefix}posts.post_status='publish' AND {$wpdb->prefix}posts.post_type IN ('page', 'post')"
|
||||
);
|
||||
|
||||
if ( $total !== null && intval( $total, 10 ) > 0 ) {
|
||||
return array(
|
||||
'id' => 'wordpress-old-slugs',
|
||||
'name' => __( 'Default WordPress "old slugs"', 'redirection' ),
|
||||
'total' => intval( $total, 10 ),
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* IP address handler for validating and normalizing IP addresses
|
||||
*/
|
||||
class Redirection_IP {
|
||||
/**
|
||||
* Validated and normalized IP address
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $ip = '';
|
||||
|
||||
/**
|
||||
* Constructor. Validates and normalizes an IP address
|
||||
*
|
||||
* @param string $ip IP address to validate (may be comma-separated list, first value will be used).
|
||||
*/
|
||||
public function __construct( $ip = '' ) {
|
||||
$ip = sanitize_text_field( $ip );
|
||||
$ip = explode( ',', $ip );
|
||||
$ip = array_shift( $ip );
|
||||
$ip = filter_var( $ip, FILTER_VALIDATE_IP );
|
||||
if ( $ip === false ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to binary
|
||||
// phpcs:ignore
|
||||
$ip = @inet_pton( trim( $ip ) );
|
||||
if ( $ip !== false ) {
|
||||
// phpcs:ignore
|
||||
$ip = @inet_ntop( $ip ); // Convert back to string;
|
||||
if ( $ip === false ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->ip = $ip;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validated IP address
|
||||
*
|
||||
* @return string Validated IP address, or empty string if invalid.
|
||||
*/
|
||||
public function get() {
|
||||
return $this->ip;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @phpstan-type Log404Row object{
|
||||
* id: int,
|
||||
* created: string,
|
||||
* url: string,
|
||||
* agent: string,
|
||||
* referrer: string,
|
||||
* ip: string,
|
||||
* domain?: string
|
||||
* }
|
||||
*
|
||||
* 404 error logging. Extends the base log class with specifics for 404s
|
||||
*/
|
||||
class Red_404_Log extends Red_Log {
|
||||
/**
|
||||
* Get's the table name for this log object
|
||||
*
|
||||
* @param \wpdb $wpdb WPDB object.
|
||||
* @return string
|
||||
*/
|
||||
protected static function get_table_name( $wpdb ) {
|
||||
return "{$wpdb->prefix}redirection_404";
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a 404 log entry
|
||||
*
|
||||
* @param string $domain Domain name of request.
|
||||
* @param string $url URL of request.
|
||||
* @param string $ip IP of client.
|
||||
* @param array<string, mixed> $details Other log details.
|
||||
* @return int|false Log ID, or false
|
||||
*/
|
||||
public static function create( $domain, $url, $ip, array $details ) {
|
||||
global $wpdb;
|
||||
|
||||
$insert = static::sanitize_create( $domain, $url, $ip, $details );
|
||||
$insert = apply_filters( 'redirection_404_data', $insert );
|
||||
|
||||
if ( $insert ) {
|
||||
do_action( 'redirection_404', $insert );
|
||||
|
||||
$wpdb->insert( $wpdb->prefix . 'redirection_404', $insert );
|
||||
if ( $wpdb->insert_id ) {
|
||||
return $wpdb->insert_id;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CSV filename for this log object
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_csv_filename() {
|
||||
return 'redirection-404';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CSV headers for this log object
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function get_csv_header() {
|
||||
return [ 'date', 'source', 'ip', 'referrer', 'useragent' ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CSV row for this log object
|
||||
*
|
||||
* @param object $row Log row.
|
||||
* @return array<int, string|int>
|
||||
*/
|
||||
public static function get_csv_row( $row ) {
|
||||
/** @var Log404Row $row */
|
||||
return [
|
||||
$row->created,
|
||||
$row->url,
|
||||
$row->ip,
|
||||
$row->referrer,
|
||||
$row->agent,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @phpstan-import-type LogJson from Red_Log
|
||||
* @phpstan-import-type LogFilterParams from Red_Log
|
||||
* @phpstan-import-type LogGetParams from Red_Log
|
||||
* @phpstan-type RedirectCsvRow object{
|
||||
* created: string,
|
||||
* url: string,
|
||||
* sent_to: string,
|
||||
* ip: string,
|
||||
* referrer: string,
|
||||
* agent: string
|
||||
* }
|
||||
* @phpstan-type RedirectLogJson array{
|
||||
* id: int,
|
||||
* created: string,
|
||||
* created_time: string,
|
||||
* url: string,
|
||||
* agent: string,
|
||||
* referrer: string,
|
||||
* domain: string,
|
||||
* ip: string,
|
||||
* http_code: int,
|
||||
* request_method: string,
|
||||
* request_data: mixed,
|
||||
* sent_to: string,
|
||||
* redirection_id: int,
|
||||
* redirect_by_slug: string,
|
||||
* redirect_by: string
|
||||
* }
|
||||
*
|
||||
* Redirect logging. Extends the base log class with specifics for redirects
|
||||
*/
|
||||
class Red_Redirect_Log extends Red_Log {
|
||||
/**
|
||||
* The redirect associated with this log entry.
|
||||
*
|
||||
* @var integer
|
||||
*/
|
||||
protected $redirection_id = 0;
|
||||
|
||||
/**
|
||||
* The URL the client was redirected to.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $sent_to = '';
|
||||
|
||||
/**
|
||||
* Who redirected this URL?
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $redirect_by = '';
|
||||
|
||||
/**
|
||||
* Get's the table name for this log object
|
||||
*
|
||||
* @param \wpdb $wpdb WPDB object.
|
||||
* @return string
|
||||
*/
|
||||
protected static function get_table_name( $wpdb ) {
|
||||
return "{$wpdb->prefix}redirection_logs";
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a redirect log entry
|
||||
*
|
||||
* @param string $domain Domain name of request.
|
||||
* @param string $url URL of request.
|
||||
* @param string $ip IP of client.
|
||||
* @param array<string, mixed> $details Other log details.
|
||||
* @return int|false Log ID, or false
|
||||
*/
|
||||
public static function create( $domain, $url, $ip, $details ) {
|
||||
global $wpdb;
|
||||
|
||||
$insert = self::sanitize_create( $domain, $url, $ip, $details );
|
||||
$insert['redirection_id'] = 0;
|
||||
|
||||
if ( isset( $details['redirect_id'] ) ) {
|
||||
$insert['redirection_id'] = intval( $details['redirect_id'], 10 );
|
||||
}
|
||||
|
||||
if ( isset( $details['target'] ) ) {
|
||||
$insert['sent_to'] = $details['target'];
|
||||
}
|
||||
|
||||
if ( isset( $details['redirect_by'] ) ) {
|
||||
$insert['redirect_by'] = strtolower( substr( $details['redirect_by'], 0, 50 ) );
|
||||
}
|
||||
|
||||
$insert = apply_filters( 'redirection_log_data', $insert );
|
||||
if ( $insert ) {
|
||||
do_action( 'redirection_log', $insert );
|
||||
|
||||
$wpdb->insert( $wpdb->prefix . 'redirection_logs', $insert );
|
||||
if ( $wpdb->insert_id ) {
|
||||
return $wpdb->insert_id;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get query filters as a SQL `WHERE` statement. SQL will be sanitized
|
||||
*
|
||||
* @phpstan-param LogFilterParams & array{target?: string, redirect_by?: string} $filter
|
||||
* @phpstan-return list<string>
|
||||
* @return array
|
||||
*/
|
||||
protected static function get_query_filter( array $filter ) {
|
||||
global $wpdb;
|
||||
|
||||
$where = parent::get_query_filter( $filter );
|
||||
|
||||
/** @var array{target?: string, redirect_by?: string} $filter */
|
||||
if ( isset( $filter['target'] ) ) {
|
||||
$where[] = $wpdb->prepare( 'sent_to LIKE %s', '%' . $wpdb->esc_like( trim( $filter['target'] ) ) . '%' );
|
||||
}
|
||||
|
||||
if ( isset( $filter['redirect_by'] ) ) {
|
||||
$where[] = $wpdb->prepare( 'redirect_by = %s', $filter['redirect_by'] );
|
||||
}
|
||||
|
||||
return $where;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CSV filename for this log object
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_csv_filename() {
|
||||
return 'redirection-log';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CSV headers for this log object
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function get_csv_header() {
|
||||
return [ 'date', 'source', 'target', 'ip', 'referrer', 'agent' ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CSV row for this log object
|
||||
*
|
||||
* @param object $row Log row.
|
||||
* @phpstan-param object $row
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function get_csv_row( $row ) {
|
||||
/** @var RedirectCsvRow $row */
|
||||
return [
|
||||
$row->created,
|
||||
$row->url,
|
||||
$row->sent_to,
|
||||
$row->ip,
|
||||
$row->referrer,
|
||||
$row->agent,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a displayable name for the originator of the redirect.
|
||||
*
|
||||
* @param string $agent Redirect agent.
|
||||
* @return string
|
||||
*/
|
||||
private function get_redirect_name( $agent ) {
|
||||
// phpcs:ignore
|
||||
if ( $agent === 'wordpress' ) {
|
||||
return 'WordPress';
|
||||
}
|
||||
|
||||
return ucwords( $agent );
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a log entry to JSON
|
||||
*
|
||||
* @phpstan-return RedirectLogJson
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function to_json() {
|
||||
return array_merge(
|
||||
parent::to_json(),
|
||||
[
|
||||
'sent_to' => $this->sent_to,
|
||||
'redirection_id' => intval( $this->redirection_id, 10 ),
|
||||
'redirect_by_slug' => $this->redirect_by,
|
||||
'redirect_by' => $this->get_redirect_name( $this->redirect_by === null ? '' : $this->redirect_by ),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/log-404.php';
|
||||
require_once __DIR__ . '/log-redirect.php';
|
||||
|
||||
/**
|
||||
* Base log class
|
||||
*
|
||||
* @phpstan-type LogJson array{
|
||||
* id: int,
|
||||
* created: string,
|
||||
* created_time: string,
|
||||
* url: string,
|
||||
* agent: string,
|
||||
* referrer: string,
|
||||
* domain: string,
|
||||
* ip: string,
|
||||
* http_code: int,
|
||||
* request_method: string,
|
||||
* request_data: mixed
|
||||
* }
|
||||
* @phpstan-type LogQuery array{
|
||||
* orderby: string,
|
||||
* direction: string,
|
||||
* limit: int,
|
||||
* offset: int,
|
||||
* where: string
|
||||
* }
|
||||
* @phpstan-type LogFilterParams array{
|
||||
* ip?: string,
|
||||
* domain?: string,
|
||||
* 'url-exact'?: string,
|
||||
* url?: string,
|
||||
* referrer?: string,
|
||||
* agent?: string,
|
||||
* http?: int,
|
||||
* method?: string
|
||||
* }
|
||||
* @phpstan-type LogGetParams array{
|
||||
* orderby?: 'ip'|'url',
|
||||
* direction?: 'ASC'|'DESC',
|
||||
* per_page?: int,
|
||||
* page?: int,
|
||||
* filterBy?: LogFilterParams
|
||||
* }
|
||||
*/
|
||||
abstract class Red_Log {
|
||||
const MAX_IP_LENGTH = 45;
|
||||
const MAX_DOMAIN_LENGTH = 255;
|
||||
const MAX_URL_LENGTH = 2000;
|
||||
const DEFAULT_PER_PAGE = 25;
|
||||
const MAX_PER_PAGE = 200;
|
||||
const MAX_AGENT_LENGTH = 255;
|
||||
const MAX_REFERRER_LENGTH = 255;
|
||||
|
||||
/**
|
||||
* Supported HTTP methods
|
||||
*
|
||||
* @phpstan-var list<string>
|
||||
* @var array
|
||||
*/
|
||||
protected static $supported_methods = [ 'GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE', 'PATCH' ];
|
||||
|
||||
/**
|
||||
* Log ID
|
||||
*
|
||||
* @var integer
|
||||
*/
|
||||
protected $id = 0;
|
||||
|
||||
/**
|
||||
* Created date time
|
||||
*
|
||||
* @var integer
|
||||
*/
|
||||
protected $created = 0;
|
||||
|
||||
/**
|
||||
* Requested URL
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $url = '';
|
||||
|
||||
/**
|
||||
* Client user agent
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $agent = '';
|
||||
|
||||
/**
|
||||
* Client referrer
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $referrer = '';
|
||||
|
||||
/**
|
||||
* Client IP
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $ip = '';
|
||||
|
||||
/**
|
||||
* Requested domain
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $domain = '';
|
||||
|
||||
/**
|
||||
* Response HTTP code
|
||||
*
|
||||
* @var integer
|
||||
*/
|
||||
protected $http_code = 0;
|
||||
|
||||
/**
|
||||
* Request method
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $request_method = '';
|
||||
|
||||
/**
|
||||
* Additional request data
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $request_data = '';
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param LogJson $values Array of log values.
|
||||
*/
|
||||
final public function __construct( $values ) {
|
||||
foreach ( $values as $key => $value ) {
|
||||
// @phpstan-ignore property.notFound, assign.propertyType
|
||||
$this->$key = $value;
|
||||
}
|
||||
|
||||
// @phpstan-ignore isset.offset
|
||||
if ( isset( $values['created'] ) ) {
|
||||
$converted = mysql2date( 'U', $values['created'] );
|
||||
|
||||
if ( $converted !== false ) {
|
||||
$this->created = intval( $converted, 10 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get's the table name for this log object
|
||||
*
|
||||
* @param \wpdb $wpdb WPDB object.
|
||||
* @return string
|
||||
*/
|
||||
protected static function get_table_name( $wpdb ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a log item by ID
|
||||
*
|
||||
* @param integer $id Log ID.
|
||||
* @return Red_Log|false
|
||||
*/
|
||||
public static function get_by_id( $id ) {
|
||||
global $wpdb;
|
||||
|
||||
$table = static::get_table_name( $wpdb );
|
||||
|
||||
// Table name is internally generated.
|
||||
// phpcs:ignore
|
||||
$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id=%d", $id ), ARRAY_A );
|
||||
if ( $row ) {
|
||||
return new static( $row );
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a log entry
|
||||
*
|
||||
* @param integer $id Log ID.
|
||||
* @return integer|false
|
||||
*/
|
||||
public static function delete( $id ) {
|
||||
global $wpdb;
|
||||
|
||||
return $wpdb->delete( static::get_table_name( $wpdb ), [ 'id' => $id ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all matching log entries
|
||||
*
|
||||
* @phpstan-param LogGetParams $params Array of filter parameters.
|
||||
* @return integer|false
|
||||
*/
|
||||
public static function delete_all( array $params = [] ) {
|
||||
global $wpdb;
|
||||
|
||||
$query = self::get_query( $params );
|
||||
$table = static::get_table_name( $wpdb );
|
||||
|
||||
$sql = "DELETE FROM {$table} {$query['where']}";
|
||||
|
||||
// phpcs:ignore
|
||||
return $wpdb->query( $sql );
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a log entry to JSON
|
||||
*
|
||||
* @phpstan-return LogJson
|
||||
* @return array
|
||||
*/
|
||||
public function to_json() {
|
||||
return [
|
||||
'id' => intval( $this->id, 10 ),
|
||||
'created' => date_i18n( get_option( 'date_format' ), $this->created ),
|
||||
'created_time' => gmdate( get_option( 'time_format' ), $this->created ),
|
||||
'url' => $this->url,
|
||||
'agent' => $this->agent,
|
||||
'referrer' => $this->referrer,
|
||||
'domain' => $this->domain,
|
||||
'ip' => $this->ip,
|
||||
'http_code' => intval( $this->http_code, 10 ),
|
||||
'request_method' => $this->request_method,
|
||||
'request_data' => $this->request_data ? json_decode( $this->request_data, true ) : '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filtered log entries
|
||||
*
|
||||
* @param array<string, mixed> $params Filters.
|
||||
* @phpstan-return array{items: list<LogJson>, total: int}
|
||||
* @return array
|
||||
*/
|
||||
public static function get_filtered( array $params ) {
|
||||
global $wpdb;
|
||||
|
||||
$query = self::get_query( $params );
|
||||
$table = static::get_table_name( $wpdb );
|
||||
|
||||
$sql = "SELECT * FROM {$table} {$query['where']}";
|
||||
|
||||
// Already escaped
|
||||
// phpcs:ignore
|
||||
$sql .= $wpdb->prepare( ' ORDER BY ' . $query['orderby'] . ' ' . $query['direction'] . ' LIMIT %d,%d', $query['offset'], $query['limit'] );
|
||||
|
||||
// Already escaped
|
||||
// phpcs:ignore
|
||||
$rows = $wpdb->get_results( $sql, ARRAY_A );
|
||||
|
||||
// Already escaped
|
||||
// phpcs:ignore
|
||||
$total_items = $wpdb->get_var( "SELECT COUNT(*) FROM $table " . $query['where'] );
|
||||
$items = array();
|
||||
|
||||
foreach ( $rows as $row ) {
|
||||
$item = new static( $row );
|
||||
$items[] = $item->to_json();
|
||||
}
|
||||
|
||||
/** @var list<LogJson> $items */
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => intval( $total_items, 10 ),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get grouped log entries
|
||||
*
|
||||
* @param string $group Group type ('ip'|'url'|'agent').
|
||||
* @param array<string, mixed> $params Filter params.
|
||||
* @phpstan-return array{items: list<object>, total: int}
|
||||
* @return array
|
||||
*/
|
||||
public static function get_grouped( $group, array $params ) {
|
||||
global $wpdb;
|
||||
|
||||
$table = static::get_table_name( $wpdb );
|
||||
$query = self::get_query( $params );
|
||||
|
||||
if ( ! in_array( $group, [ 'ip', 'url', 'agent' ], true ) ) {
|
||||
$group = 'url';
|
||||
}
|
||||
|
||||
// Already escaped
|
||||
// phpcs:ignore
|
||||
$sql = $wpdb->prepare( "SELECT COUNT(*) as count,$group FROM {$table} {$query['where']} GROUP BY $group ORDER BY count {$query['direction']}, $group LIMIT %d,%d", $query['offset'], $query['limit'] );
|
||||
|
||||
// Already escaped
|
||||
// phpcs:ignore
|
||||
$rows = $wpdb->get_results( $sql );
|
||||
|
||||
// Already escaped
|
||||
// phpcs:ignore
|
||||
$total_items = $wpdb->get_var( "SELECT COUNT(DISTINCT $group) FROM {$table} {$query['where']}" );
|
||||
|
||||
foreach ( $rows as $row ) {
|
||||
$row->count = intval( $row->count, 10 );
|
||||
$row->id = isset( $row->{ $group } ) ? $row->{ $group } : '';
|
||||
}
|
||||
|
||||
/** @var list<object> $rows */
|
||||
return array(
|
||||
'items' => $rows,
|
||||
'total' => intval( $total_items, 10 ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a set of filters to a SQL query.
|
||||
*
|
||||
* @param array<string, mixed> $params Filters.
|
||||
* @phpstan-return LogQuery
|
||||
* @return array
|
||||
*/
|
||||
public static function get_query( array $params ) {
|
||||
$query = [
|
||||
'orderby' => 'id',
|
||||
'direction' => 'DESC',
|
||||
'limit' => self::DEFAULT_PER_PAGE,
|
||||
'offset' => 0,
|
||||
'where' => '',
|
||||
];
|
||||
|
||||
if ( isset( $params['orderby'] ) && in_array( $params['orderby'], array( 'ip', 'url' ), true ) ) {
|
||||
$query['orderby'] = $params['orderby'];
|
||||
}
|
||||
|
||||
if ( isset( $params['direction'] ) && in_array( strtoupper( $params['direction'] ), array( 'ASC', 'DESC' ), true ) ) {
|
||||
$query['direction'] = strtoupper( $params['direction'] );
|
||||
}
|
||||
|
||||
if ( isset( $params['per_page'] ) ) {
|
||||
$limit = intval( $params['per_page'], 10 );
|
||||
if ( $limit >= 5 && $limit <= self::MAX_PER_PAGE ) {
|
||||
$query['limit'] = $limit;
|
||||
}
|
||||
}
|
||||
|
||||
if ( isset( $params['page'] ) ) {
|
||||
$offset = intval( $params['page'], 10 );
|
||||
|
||||
if ( $offset >= 0 ) {
|
||||
$query['offset'] = $offset * $query['limit'];
|
||||
}
|
||||
}
|
||||
|
||||
if ( isset( $params['filterBy'] ) && is_array( $params['filterBy'] ) ) {
|
||||
$where = static::get_query_filter( $params['filterBy'] );
|
||||
|
||||
if ( count( $where ) > 0 ) {
|
||||
$query['where'] = 'WHERE ' . implode( ' AND ', $where );
|
||||
}
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get query filters as a SQL `WHERE` statement. SQL will be sanitized
|
||||
*
|
||||
* @phpstan-param LogFilterParams $filter Array of filter params.
|
||||
* @phpstan-return list<string>
|
||||
* @return array
|
||||
*/
|
||||
protected static function get_query_filter( array $filter ) {
|
||||
global $wpdb;
|
||||
|
||||
$where = [];
|
||||
|
||||
if ( isset( $filter['ip'] ) ) {
|
||||
// phpcs:ignore
|
||||
$ip = @inet_pton( trim( $filter['ip'] ) );
|
||||
|
||||
if ( $ip !== false ) {
|
||||
// Full IP match
|
||||
// phpcs:ignore
|
||||
$ip = @inet_ntop( $ip ); // Convert back to string
|
||||
$where[] = $wpdb->prepare( 'ip = %s', $ip );
|
||||
} else {
|
||||
// Partial IP match
|
||||
$where[] = $wpdb->prepare( 'ip LIKE %s', '%' . $wpdb->esc_like( trim( $filter['ip'] ) ) . '%' );
|
||||
}
|
||||
}
|
||||
|
||||
if ( isset( $filter['domain'] ) ) {
|
||||
$where[] = $wpdb->prepare( 'domain LIKE %s', '%' . $wpdb->esc_like( trim( $filter['domain'] ) ) . '%' );
|
||||
}
|
||||
|
||||
if ( isset( $filter['url-exact'] ) ) {
|
||||
$where[] = $wpdb->prepare( 'url = %s', $filter['url-exact'] );
|
||||
} elseif ( isset( $filter['url'] ) ) {
|
||||
$where[] = $wpdb->prepare( 'url LIKE %s', '%' . $wpdb->esc_like( trim( $filter['url'] ) ) . '%' );
|
||||
}
|
||||
|
||||
if ( isset( $filter['referrer'] ) ) {
|
||||
$where[] = $wpdb->prepare( 'referrer LIKE %s', '%' . $wpdb->esc_like( trim( $filter['referrer'] ) ) . '%' );
|
||||
}
|
||||
|
||||
if ( isset( $filter['agent'] ) ) {
|
||||
$agent = trim( $filter['agent'] );
|
||||
|
||||
if ( empty( $agent ) ) {
|
||||
$where[] = $wpdb->prepare( 'agent = %s', $agent );
|
||||
} else {
|
||||
$where[] = $wpdb->prepare( 'agent LIKE %s', '%' . $wpdb->esc_like( $agent ) . '%' );
|
||||
}
|
||||
}
|
||||
|
||||
if ( isset( $filter['http'] ) ) {
|
||||
$where[] = $wpdb->prepare( 'http_code = %d', $filter['http'] );
|
||||
}
|
||||
|
||||
if ( isset( $filter['method'] ) && in_array( strtoupper( $filter['method'] ), static::$supported_methods, true ) ) {
|
||||
$where[] = $wpdb->prepare( 'request_method = %s', strtoupper( $filter['method'] ) );
|
||||
}
|
||||
|
||||
return $where;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a new log entry
|
||||
*
|
||||
* @param string $domain Requested Domain.
|
||||
* @param string $url Requested URL.
|
||||
* @param string $ip Client IP. This is assumed to be a valid IP and won't be checked.
|
||||
* @param array<string, mixed> $details Extra log details.
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected static function sanitize_create( $domain, $url, $ip, array $details = [] ) {
|
||||
$url = urldecode( $url );
|
||||
$insert = [
|
||||
'url' => substr( sanitize_text_field( $url ), 0, self::MAX_URL_LENGTH ),
|
||||
'domain' => substr( sanitize_text_field( $domain ), 0, self::MAX_DOMAIN_LENGTH ),
|
||||
'ip' => substr( sanitize_text_field( $ip ), 0, self::MAX_IP_LENGTH ),
|
||||
'created' => current_time( 'mysql' ),
|
||||
];
|
||||
|
||||
// Unfortunatley these names dont match up
|
||||
$allowed = [
|
||||
'agent' => 'agent',
|
||||
'referrer' => 'referrer',
|
||||
'request_method' => 'request_method',
|
||||
'http_code' => 'http_code',
|
||||
'request_data' => 'request_data',
|
||||
];
|
||||
|
||||
foreach ( $allowed as $name => $replace ) {
|
||||
if ( ! empty( $details[ $name ] ) ) {
|
||||
$insert[ $replace ] = $details[ $name ];
|
||||
}
|
||||
}
|
||||
|
||||
if ( isset( $insert['agent'] ) ) {
|
||||
$insert['agent'] = substr( sanitize_text_field( $insert['agent'] ), 0, self::MAX_AGENT_LENGTH );
|
||||
}
|
||||
|
||||
if ( isset( $insert['referrer'] ) ) {
|
||||
$insert['referrer'] = substr( sanitize_text_field( $insert['referrer'] ), 0, self::MAX_REFERRER_LENGTH );
|
||||
}
|
||||
|
||||
if ( isset( $insert['request_data'] ) ) {
|
||||
$insert['request_data'] = wp_json_encode( $insert['request_data'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_NUMERIC_CHECK );
|
||||
}
|
||||
|
||||
if ( isset( $insert['http_code'] ) ) {
|
||||
$insert['http_code'] = intval( $insert['http_code'], 10 );
|
||||
}
|
||||
|
||||
if ( isset( $insert['request_method'] ) ) {
|
||||
$insert['request_method'] = strtoupper( sanitize_text_field( $insert['request_method'] ) );
|
||||
|
||||
if ( ! in_array( $insert['request_method'], static::$supported_methods, true ) ) {
|
||||
$insert['request_method'] = '';
|
||||
}
|
||||
}
|
||||
|
||||
return $insert;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CSV filename for this log object
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_csv_filename() {
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CSV headers for this log object
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function get_csv_header() {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CSV row for this log object
|
||||
*
|
||||
* @param object $row Log row.
|
||||
* @return array<int, string|int>
|
||||
*/
|
||||
public static function get_csv_row( $row ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the log entry to CSV
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function export_to_csv() {
|
||||
$filename = static::get_csv_filename() . '-' . date_i18n( get_option( 'date_format' ) ) . '.csv';
|
||||
|
||||
header( 'Content-Type: text/csv' );
|
||||
header( 'Cache-Control: no-cache, must-revalidate' );
|
||||
header( 'Expires: Mon, 26 Jul 1997 05:00:00 GMT' );
|
||||
header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
|
||||
|
||||
// phpcs:ignore
|
||||
$stdout = fopen( 'php://output', 'w' );
|
||||
if ( $stdout === false ) {
|
||||
return;
|
||||
}
|
||||
|
||||
fputcsv( $stdout, static::get_csv_header() );
|
||||
|
||||
global $wpdb;
|
||||
|
||||
$table = static::get_table_name( $wpdb );
|
||||
// phpcs:ignore
|
||||
$total_items = $wpdb->get_var( "SELECT COUNT(*) FROM $table" );
|
||||
$exported = 0;
|
||||
|
||||
$limit = 100;
|
||||
|
||||
while ( $exported < $total_items ) {
|
||||
// phpcs:ignore
|
||||
$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $table LIMIT %d,%d", $exported, $limit ) );
|
||||
$exported += count( $rows );
|
||||
|
||||
foreach ( $rows as $row ) {
|
||||
$csv = static::get_csv_row( $row );
|
||||
fputcsv( $stdout, $csv );
|
||||
}
|
||||
|
||||
if ( count( $rows ) < $limit ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
|
||||
require_once dirname( __DIR__ ) . '/matches/from-notfrom.php';
|
||||
require_once dirname( __DIR__ ) . '/matches/from-url.php';
|
||||
|
||||
/**
|
||||
* Matches a URL and some other condition
|
||||
*
|
||||
* @phpstan-template TSaveDetails of array<string, mixed>
|
||||
* @phpstan-template-covariant TSaveResult of (array<string, mixed>|string)
|
||||
*
|
||||
* @phpstan-type RedMatchUrlData array{
|
||||
* url: string
|
||||
* }
|
||||
* @phpstan-type RedMatchAgentData array{
|
||||
* agent: string,
|
||||
* regex: bool,
|
||||
* url_from: string,
|
||||
* url_notfrom: string
|
||||
* }
|
||||
* @phpstan-type RedMatchReferrerData array{
|
||||
* referrer: string,
|
||||
* regex: bool,
|
||||
* url_from: string,
|
||||
* url_notfrom: string
|
||||
* }
|
||||
* @phpstan-type RedMatchHeaderData array{
|
||||
* name: string,
|
||||
* value: string,
|
||||
* regex: bool,
|
||||
* url_from: string,
|
||||
* url_notfrom: string
|
||||
* }
|
||||
* @phpstan-type RedMatchCookieData array{
|
||||
* name: string,
|
||||
* value: string,
|
||||
* regex: bool,
|
||||
* url_from: string,
|
||||
* url_notfrom: string
|
||||
* }
|
||||
* @phpstan-type RedMatchCustomData array{
|
||||
* filter: string,
|
||||
* url_from: string,
|
||||
* url_notfrom: string
|
||||
* }
|
||||
* @phpstan-type RedMatchRoleData array{
|
||||
* role: string,
|
||||
* url_from: string,
|
||||
* url_notfrom: string
|
||||
* }
|
||||
* @phpstan-type RedMatchServerData array{
|
||||
* server: string,
|
||||
* url_from: string,
|
||||
* url_notfrom: string
|
||||
* }
|
||||
* @phpstan-type RedMatchIpData array{
|
||||
* ip: string[],
|
||||
* url_from: string,
|
||||
* url_notfrom: string
|
||||
* }
|
||||
* @phpstan-type RedMatchPageData array{
|
||||
* page: string,
|
||||
* url: string
|
||||
* }
|
||||
* @phpstan-type RedMatchLanguageData array{
|
||||
* language: string,
|
||||
* url_from: string,
|
||||
* url_notfrom: string
|
||||
* }
|
||||
* @phpstan-type RedMatchLoginData array{
|
||||
* logged_in: string,
|
||||
* logged_out: string
|
||||
* }
|
||||
* @phpstan-type RedMatchData RedMatchUrlData|RedMatchAgentData|RedMatchReferrerData|RedMatchHeaderData|RedMatchCookieData|RedMatchCustomData|RedMatchRoleData|RedMatchServerData|RedMatchIpData|RedMatchPageData|RedMatchLanguageData|RedMatchLoginData
|
||||
*/
|
||||
abstract class Red_Match {
|
||||
/**
|
||||
* Match type
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $type = '';
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param RedMatchData|string $values Initial values.
|
||||
*/
|
||||
public function __construct( $values = '' ) {
|
||||
if ( $values !== '' ) {
|
||||
$this->load( $values );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get match type
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_type() {
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the match
|
||||
*
|
||||
* @phpstan-param TSaveDetails $details Details to save.
|
||||
* @param array<string, mixed> $details Details to save.
|
||||
* @param boolean $no_target_url The URL when no target.
|
||||
* @phpstan-return TSaveResult|null
|
||||
* @return array<string, mixed>|string|null
|
||||
*/
|
||||
abstract public function save( array $details, $no_target_url = false );
|
||||
|
||||
/**
|
||||
* Get the match name
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
abstract public function name();
|
||||
|
||||
/**
|
||||
* Match the URL against the specific matcher conditions
|
||||
*
|
||||
* @param string $url Requested URL.
|
||||
* @return boolean
|
||||
*/
|
||||
abstract public function is_match( $url );
|
||||
|
||||
/**
|
||||
* Get the target URL for this match. Some matches may have a matched/unmatched target.
|
||||
*
|
||||
* @param string $original_url The client URL (not decoded).
|
||||
* @param string $matched_url The URL in the redirect.
|
||||
* @param Red_Source_Flags $flag Source flags.
|
||||
* @param boolean $is_matched Was the match successful.
|
||||
* @return string|false
|
||||
*/
|
||||
abstract public function get_target_url( $original_url, $matched_url, Red_Source_Flags $flag, $is_matched );
|
||||
|
||||
/**
|
||||
* Get the match data
|
||||
*
|
||||
* @return RedMatchData|null
|
||||
*/
|
||||
abstract public function get_data();
|
||||
|
||||
/**
|
||||
* Load the match data into this instance.
|
||||
*
|
||||
* @param RedMatchData|string $values Match values, as read from the database (plain text or serialized PHP).
|
||||
* @return void
|
||||
*/
|
||||
abstract public function load( $values );
|
||||
|
||||
/**
|
||||
* Sanitize a match URL
|
||||
*
|
||||
* @param string $url URL.
|
||||
* @return string
|
||||
*/
|
||||
public function sanitize_url( $url ) {
|
||||
// No new lines
|
||||
$url = (string) preg_replace( "/[\r\n\t].*?$/s", '', $url );
|
||||
|
||||
// Clean control codes
|
||||
$url = (string) preg_replace( '/[^\PC\s]/u', '', $url );
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a regular expression to the target URL, replacing any values.
|
||||
*
|
||||
* @param string $source_url Redirect source URL.
|
||||
* @param string $target_url Target URL.
|
||||
* @param string $requested_url The URL being requested (decoded).
|
||||
* @param Red_Source_Flags $flags Source URL flags.
|
||||
* @return string
|
||||
*/
|
||||
protected function get_target_regex_url( $source_url, $target_url, $requested_url, Red_Source_Flags $flags ) {
|
||||
$regex = new Red_Regex( $source_url, $flags->is_ignore_case() );
|
||||
|
||||
return $regex->replace( $target_url, $requested_url );
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Red_Match object, given a type
|
||||
*
|
||||
* @param string $name Match type.
|
||||
* @param RedMatchData|string $data Match data.
|
||||
* @return Red_Match<array<string, mixed>, (array<string, mixed>|string)>|null
|
||||
*/
|
||||
public static function create( $name, $data = '' ) {
|
||||
$avail = self::available();
|
||||
if ( isset( $avail[ strtolower( $name ) ] ) ) {
|
||||
$classname = $name . '_match';
|
||||
|
||||
/** @var class-string<Red_Match<array<string, mixed>, (array<string, mixed>|string)>> $classname */
|
||||
if ( ! class_exists( strtolower( $classname ) ) ) {
|
||||
include __DIR__ . '/../matches/' . $avail[ strtolower( $name ) ];
|
||||
}
|
||||
|
||||
/**
|
||||
* @var Red_Match<array<string, mixed>, (array<string, mixed>|string)>
|
||||
*/
|
||||
$class = new $classname( $data );
|
||||
$class->type = $name;
|
||||
return $class;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all Red_Match objects
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function all() {
|
||||
$data = [];
|
||||
|
||||
$avail = self::available();
|
||||
foreach ( array_keys( $avail ) as $name ) {
|
||||
/** @var Red_Match<array<string, mixed>, (array<string, mixed>|string)>|null $obj */
|
||||
$obj = self::create( $name );
|
||||
if ( $obj === null ) {
|
||||
continue;
|
||||
}
|
||||
$data[ $name ] = $obj->name();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available matches
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function available() {
|
||||
return [
|
||||
'url' => 'url.php',
|
||||
'referrer' => 'referrer.php',
|
||||
'agent' => 'user-agent.php',
|
||||
'login' => 'login.php',
|
||||
'header' => 'http-header.php',
|
||||
'custom' => 'custom-filter.php',
|
||||
'cookie' => 'cookie.php',
|
||||
'role' => 'user-role.php',
|
||||
'server' => 'server.php',
|
||||
'ip' => 'ip.php',
|
||||
'page' => 'page.php',
|
||||
'language' => 'language.php',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
require_once dirname( __DIR__ ) . '/modules/wordpress.php';
|
||||
require_once dirname( __DIR__ ) . '/modules/apache.php';
|
||||
require_once dirname( __DIR__ ) . '/modules/nginx.php';
|
||||
|
||||
/**
|
||||
* Base class for redirect module.
|
||||
*
|
||||
* @phpstan-type WordPressModuleOptions array{}
|
||||
* @phpstan-type ApacheModuleOptions array{
|
||||
* location: string
|
||||
* }
|
||||
* @phpstan-type NginxModuleOptions array{
|
||||
* location: string
|
||||
* }
|
||||
* @phpstan-type RedModuleOptions WordPressModuleOptions|ApacheModuleOptions|NginxModuleOptions
|
||||
*/
|
||||
abstract class Red_Module {
|
||||
/**
|
||||
* Constructor. Loads options
|
||||
*
|
||||
* @param RedModuleOptions $options Any module options.
|
||||
*/
|
||||
public function __construct( array $options = [] ) {
|
||||
if ( ! empty( $options ) ) {
|
||||
$this->load( $options );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a module based on the supplied ID, and loads it with appropriate options.
|
||||
*
|
||||
* @param integer $id Module ID.
|
||||
* @return Red_Module|false
|
||||
*/
|
||||
public static function get( $id ) {
|
||||
$id = intval( $id, 10 );
|
||||
$options = Red_Options::get();
|
||||
|
||||
if ( $id === Apache_Module::MODULE_ID ) {
|
||||
return new Apache_Module( isset( $options['modules'][ Apache_Module::MODULE_ID ] ) ? $options['modules'][ Apache_Module::MODULE_ID ] : array() );
|
||||
}
|
||||
|
||||
if ( $id === WordPress_Module::MODULE_ID ) {
|
||||
return new WordPress_Module( isset( $options['modules'][ WordPress_Module::MODULE_ID ] ) ? $options['modules'][ WordPress_Module::MODULE_ID ] : array() );
|
||||
}
|
||||
|
||||
if ( $id === Nginx_Module::MODULE_ID ) {
|
||||
return new Nginx_Module( isset( $options['modules'][ Nginx_Module::MODULE_ID ] ) ? $options['modules'][ Nginx_Module::MODULE_ID ] : array() );
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that an ID is valid.
|
||||
*
|
||||
* @param integer $id Module ID.
|
||||
* @return boolean
|
||||
*/
|
||||
public static function is_valid_id( $id ) {
|
||||
if ( $id === Apache_Module::MODULE_ID || $id === WordPress_Module::MODULE_ID || $id === Nginx_Module::MODULE_ID ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a module ID given the module name
|
||||
*
|
||||
* @param string $name Module name.
|
||||
* @return integer|false
|
||||
*/
|
||||
public static function get_id_for_name( $name ) {
|
||||
$names = array(
|
||||
'wordpress' => WordPress_Module::MODULE_ID,
|
||||
'apache' => Apache_Module::MODULE_ID,
|
||||
'nginx' => Nginx_Module::MODULE_ID,
|
||||
);
|
||||
|
||||
if ( isset( $names[ $name ] ) ) {
|
||||
return $names[ $name ];
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush the module that a group belongs to
|
||||
*
|
||||
* @param integer $group_id Module group ID.
|
||||
* @return void
|
||||
*/
|
||||
public static function flush( $group_id ) {
|
||||
$group = Red_Group::get( $group_id );
|
||||
|
||||
if ( is_object( $group ) ) {
|
||||
$module = self::get( $group->get_module_id() );
|
||||
|
||||
if ( $module !== false ) {
|
||||
$module->flush_module();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush the module
|
||||
*
|
||||
* @param integer $module_id Module ID.
|
||||
* @return void
|
||||
*/
|
||||
public static function flush_by_module( $module_id ) {
|
||||
$module = self::get( $module_id );
|
||||
|
||||
if ( $module !== false ) {
|
||||
$module->flush_module();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the module ID
|
||||
*
|
||||
* @return integer
|
||||
*/
|
||||
abstract public function get_id();
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
abstract public function get_name();
|
||||
|
||||
/**
|
||||
* Update
|
||||
*
|
||||
* @param RedModuleOptions $data Data.
|
||||
* @return array<string, string>|false
|
||||
*/
|
||||
public function update( array $data ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load
|
||||
*
|
||||
* @param RedModuleOptions $options Options.
|
||||
* @return void
|
||||
*/
|
||||
protected function load( array $options ) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function flush_module() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
class Red_Monitor {
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
private $monitor_group_id = 0;
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private $updated_posts = array();
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private $monitor_types = array();
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $associated = '';
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function __construct( array $options ) {
|
||||
$this->monitor_types = apply_filters( 'redirection_monitor_types', isset( $options['monitor_types'] ) ? $options['monitor_types'] : array() );
|
||||
|
||||
if ( count( $this->monitor_types ) > 0 && $options['monitor_post'] > 0 ) {
|
||||
$this->monitor_group_id = intval( $options['monitor_post'], 10 );
|
||||
$this->associated = isset( $options['associated_redirect'] ) ? $options['associated_redirect'] : '';
|
||||
|
||||
// Only monitor if permalinks enabled
|
||||
if ( get_option( 'permalink_structure' ) !== false ) {
|
||||
add_action( 'pre_post_update', array( $this, 'pre_post_update' ), 10, 2 );
|
||||
add_action( 'post_updated', array( $this, 'post_updated' ), 11, 3 );
|
||||
add_action( 'redirection_remove_existing', array( $this, 'remove_existing_redirect' ) );
|
||||
add_filter( 'redirection_permalink_changed', array( $this, 'has_permalink_changed' ), 10, 3 );
|
||||
|
||||
if ( in_array( 'trash', $this->monitor_types, true ) ) {
|
||||
add_action( 'wp_trash_post', array( $this, 'post_trashed' ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $url
|
||||
* @return void
|
||||
*/
|
||||
public function remove_existing_redirect( string $url ): void {
|
||||
Red_Item::disable_where_matches( $url );
|
||||
}
|
||||
|
||||
/**
|
||||
* @param WP_Post|null $post
|
||||
* @param WP_Post|null $post_before
|
||||
* @return bool
|
||||
*/
|
||||
public function can_monitor_post( ?WP_Post $post, ?WP_Post $post_before ): bool {
|
||||
// Defensive check: ensure we have valid post objects
|
||||
if ( $post === null || $post_before === null ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check this is for the expected post
|
||||
// @phpstan-ignore isset.property
|
||||
if ( ! isset( $post->ID ) || ! isset( $this->updated_posts[ $post->ID ] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't do anything if we're not published
|
||||
if ( $post->post_status !== 'publish' || $post_before->post_status !== 'publish' ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$type = get_post_type( $post->ID );
|
||||
if ( ! in_array( $type, $this->monitor_types, true ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a post has been updated - check if the slug has changed
|
||||
*
|
||||
* @param int $post_id
|
||||
* @param WP_Post|null $post
|
||||
* @param WP_Post|null $post_before
|
||||
* @return void
|
||||
*/
|
||||
public function post_updated( int $post_id, ?WP_Post $post, ?WP_Post $post_before ): void {
|
||||
// WordPress may pass null during trash/delete operations - handle gracefully
|
||||
if ( $post === null || $post_before === null ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( isset( $this->updated_posts[ $post_id ] ) && $this->can_monitor_post( $post, $post_before ) ) {
|
||||
$this->check_for_modified_slug( $post_id, $this->updated_posts[ $post_id ] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember the previous post permalink
|
||||
*
|
||||
* @param int $post_id
|
||||
* @param array<string, mixed> $data
|
||||
* @return void
|
||||
*/
|
||||
public function pre_post_update( int $post_id, array $data ): void {
|
||||
$permalink = get_permalink( $post_id );
|
||||
if ( $permalink !== false ) {
|
||||
$this->updated_posts[ $post_id ] = $permalink;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $post_id
|
||||
* @return void
|
||||
*/
|
||||
public function post_trashed( int $post_id ): void {
|
||||
// Only create redirects for post types that are being monitored
|
||||
$post_type = get_post_type( $post_id );
|
||||
if ( $post_type === false || ! in_array( $post_type, $this->monitor_types, true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$permalink = get_permalink( $post_id );
|
||||
if ( $permalink === false ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = array(
|
||||
'url' => wp_parse_url( $permalink, PHP_URL_PATH ),
|
||||
'action_data' => array( 'url' => '/' ),
|
||||
'match_type' => 'url',
|
||||
'action_type' => 'url',
|
||||
'action_code' => 301,
|
||||
'group_id' => $this->monitor_group_id,
|
||||
'status' => 'disabled',
|
||||
);
|
||||
|
||||
/**
|
||||
* Filter the redirect data before creating a redirect for a trashed post.
|
||||
*
|
||||
* @param array $data The redirect data to be created.
|
||||
* @param int $post_id The ID of the trashed post.
|
||||
*/
|
||||
$data = apply_filters( 'redirection_monitor_trashed_data', $data, $post_id );
|
||||
|
||||
// Create a new redirect for this post, but only if not draft
|
||||
if ( $data['url'] !== null && $data['url'] !== false && $data['url'] !== '/' ) {
|
||||
$new_item = Red_Item::create( $data );
|
||||
|
||||
if ( ! is_wp_error( $new_item ) ) {
|
||||
do_action( 'redirection_monitor_created', $new_item, $data['url'], $post_id );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Changed if permalinks are different and the before wasn't the site url (we don't want to redirect the site URL)
|
||||
*
|
||||
* @param bool $result
|
||||
* @param string|false $before
|
||||
* @param string|false $after
|
||||
* @return bool
|
||||
*/
|
||||
public function has_permalink_changed( $result, $before, $after ) {
|
||||
// Check it's not redirecting from the root
|
||||
if ( $this->get_site_path() === $before || $before === '/' ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Are the URLs the same?
|
||||
if ( $before === $after ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
private function get_site_path(): string {
|
||||
$path = wp_parse_url( get_site_url(), PHP_URL_PATH );
|
||||
|
||||
if ( is_string( $path ) ) {
|
||||
return rtrim( $path, '/' ) . '/';
|
||||
}
|
||||
|
||||
return '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $post_id
|
||||
* @param string $before
|
||||
* @return bool
|
||||
*/
|
||||
public function check_for_modified_slug( int $post_id, string $before ): bool {
|
||||
$permalink = get_permalink( $post_id );
|
||||
if ( $permalink === false ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$after = wp_parse_url( $permalink, PHP_URL_PATH );
|
||||
$before = wp_parse_url( esc_url( $before ), PHP_URL_PATH );
|
||||
|
||||
if ( is_string( $before ) && is_string( $after ) && apply_filters( 'redirection_permalink_changed', false, $before, $after ) ) {
|
||||
do_action( 'redirection_remove_existing', $after, $post_id );
|
||||
|
||||
$data = array(
|
||||
'url' => $before,
|
||||
'action_data' => array( 'url' => $after ),
|
||||
'match_type' => 'url',
|
||||
'action_type' => 'url',
|
||||
'action_code' => 301,
|
||||
'group_id' => $this->monitor_group_id,
|
||||
);
|
||||
|
||||
// Create a new redirect for this post
|
||||
$new_item = Red_Item::create( $data );
|
||||
|
||||
if ( ! is_wp_error( $new_item ) ) {
|
||||
do_action( 'redirection_monitor_created', $new_item, $before, $post_id );
|
||||
|
||||
if ( ! empty( $this->associated ) ) {
|
||||
// Create an associated redirect for this post
|
||||
$data['url'] = trailingslashit( $data['url'] ) . ltrim( $this->associated, '/' );
|
||||
$data['action_data'] = array( 'url' => trailingslashit( $data['action_data']['url'] ) . ltrim( $this->associated, '/' ) );
|
||||
Red_Item::create( $data );
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Redirection options manager - pure static utility class.
|
||||
*
|
||||
* Usage: Red_Options::get() returns a fully typed array.
|
||||
*
|
||||
* @phpstan-type RedirectionOptions array{
|
||||
* support: bool,
|
||||
* token: string,
|
||||
* monitor_post: int,
|
||||
* monitor_types: array<string>,
|
||||
* associated_redirect: string,
|
||||
* auto_target: string,
|
||||
* expire_redirect: int,
|
||||
* expire_404: int,
|
||||
* log_external: bool,
|
||||
* log_header: bool,
|
||||
* track_hits: bool,
|
||||
* modules: array<int, mixed>,
|
||||
* redirect_cache: int,
|
||||
* ip_logging: int,
|
||||
* ip_headers: array<string>,
|
||||
* ip_proxy: array<string>,
|
||||
* last_group_id: int,
|
||||
* rest_api: int,
|
||||
* https: bool,
|
||||
* headers: array<mixed>,
|
||||
* database: string,
|
||||
* relocate: string,
|
||||
* preferred_domain: string,
|
||||
* aliases: array<string>,
|
||||
* permalinks: array<mixed>,
|
||||
* cache_key: int,
|
||||
* plugin_update: string,
|
||||
* update_notice: int,
|
||||
* flag_query: 'ignore'|'exact'|'pass'|'exactorder',
|
||||
* flag_case: bool,
|
||||
* flag_trailing: bool,
|
||||
* flag_regex: bool,
|
||||
* database_stage?: array{stage?: string|false, stages?: array<mixed>, status?: string|false},
|
||||
* location?: string
|
||||
* }
|
||||
*/
|
||||
class Red_Options {
|
||||
/**
|
||||
* Options DB key. Previously defined by REDIRECTION_OPTION.
|
||||
*/
|
||||
public const OPTION_KEY = 'redirection_options';
|
||||
|
||||
/**
|
||||
* REST API location constants. Previously REDIRECTION_API_JSON*, now centralized here.
|
||||
*/
|
||||
public const API_JSON = 0;
|
||||
public const API_JSON_INDEX = 1;
|
||||
public const API_JSON_RELATIVE = 3;
|
||||
|
||||
/**
|
||||
* In-memory cache for build_options result.
|
||||
*
|
||||
* @var RedirectionOptions|null
|
||||
*/
|
||||
private static $options_cache = null;
|
||||
|
||||
/**
|
||||
* Prevent instantiation - this is a static utility class.
|
||||
*/
|
||||
private function __construct() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load current options and return as a typed array.
|
||||
*
|
||||
* @phpstan-return RedirectionOptions
|
||||
* @return array
|
||||
*/
|
||||
public static function get(): array {
|
||||
return self::build_options();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the options cache.
|
||||
* @return void
|
||||
*/
|
||||
public static function reset() {
|
||||
self::$options_cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Redirection options (array form).
|
||||
* Mirrors legacy red_get_options() behavior.
|
||||
*
|
||||
* @phpstan-return RedirectionOptions
|
||||
* @return array
|
||||
*/
|
||||
private static function build_options(): array {
|
||||
// Return cached result if available
|
||||
if ( self::$options_cache !== null ) {
|
||||
return self::$options_cache;
|
||||
}
|
||||
|
||||
$options = get_option( self::OPTION_KEY );
|
||||
$fresh_install = false;
|
||||
|
||||
if ( $options === false ) {
|
||||
$fresh_install = true;
|
||||
}
|
||||
|
||||
if ( ! is_array( $options ) ) {
|
||||
$options = [];
|
||||
}
|
||||
|
||||
if ( red_is_disabled() ) {
|
||||
$options['https'] = false;
|
||||
}
|
||||
|
||||
$defaults = self::get_default_options();
|
||||
|
||||
foreach ( $defaults as $key => $value ) {
|
||||
if ( ! isset( $options[ $key ] ) ) {
|
||||
$options[ $key ] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if ( $fresh_install ) {
|
||||
// Default flags for new installs - ignore case and trailing slashes
|
||||
$options['flag_case'] = true;
|
||||
$options['flag_trailing'] = true;
|
||||
}
|
||||
|
||||
// Back-compat. If monitor_post is set without types then it's from an older Redirection
|
||||
if ( $options['monitor_post'] > 0 && count( $options['monitor_types'] ) === 0 ) {
|
||||
$options['monitor_types'] = [ 'post' ];
|
||||
}
|
||||
|
||||
// Remove old options not in get_default_options()
|
||||
foreach ( array_keys( $options ) as $key ) {
|
||||
if ( ! isset( $defaults[ $key ] ) && $key !== 'database_stage' ) {
|
||||
unset( $options[ $key ] );
|
||||
}
|
||||
}
|
||||
|
||||
// Back-compat fix
|
||||
if ( $options['rest_api'] === false || ! in_array( $options['rest_api'], [ self::API_JSON, self::API_JSON_INDEX, self::API_JSON_RELATIVE ], true ) ) {
|
||||
$options['rest_api'] = self::API_JSON;
|
||||
}
|
||||
|
||||
if ( isset( $options['modules'] ) && isset( $options['modules']['2'] ) && isset( $options['modules']['2']['location'] ) ) {
|
||||
$options['location'] = $options['modules']['2']['location'];
|
||||
}
|
||||
|
||||
/** @var RedirectionOptions $options */
|
||||
// Cache the result before returning
|
||||
self::$options_cache = $options;
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default options. Contains all valid options.
|
||||
*
|
||||
* @phpstan-return RedirectionOptions
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function get_default_options() {
|
||||
$flags = new Red_Source_Flags();
|
||||
$defaults = [
|
||||
'support' => false,
|
||||
'token' => md5( uniqid() ),
|
||||
'monitor_post' => 0,
|
||||
'monitor_types' => [],
|
||||
'associated_redirect' => '',
|
||||
'auto_target' => '',
|
||||
'expire_redirect' => 7,
|
||||
'expire_404' => 7,
|
||||
'log_external' => false,
|
||||
'log_header' => false,
|
||||
'track_hits' => true,
|
||||
'modules' => [],
|
||||
'redirect_cache' => 1,
|
||||
'ip_logging' => 0,
|
||||
'ip_headers' => [],
|
||||
'ip_proxy' => [],
|
||||
'last_group_id' => 0,
|
||||
'rest_api' => self::API_JSON,
|
||||
'https' => false,
|
||||
'headers' => [],
|
||||
'database' => '',
|
||||
'relocate' => '',
|
||||
'preferred_domain' => '',
|
||||
'aliases' => [],
|
||||
'permalinks' => [],
|
||||
'cache_key' => 0,
|
||||
'plugin_update' => 'prompt',
|
||||
'update_notice' => 0,
|
||||
];
|
||||
|
||||
$defaults = array_merge( $defaults, $flags->get_json() );
|
||||
|
||||
return apply_filters( 'red_default_options', $defaults );
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist option values using the same sanitization/merge rules as legacy red_set_options.
|
||||
* This is a static method that saves options and returns the updated array.
|
||||
*
|
||||
* @param array<string, mixed> $settings Partial settings to apply.
|
||||
* @phpstan-return RedirectionOptions
|
||||
* @return array
|
||||
*/
|
||||
public static function save( array $settings ): array {
|
||||
// Clear the cache before applying settings
|
||||
self::$options_cache = null;
|
||||
|
||||
$options = self::apply_settings( $settings );
|
||||
update_option( self::OPTION_KEY, apply_filters( 'redirection_save_options', $options ) );
|
||||
|
||||
// Clear the cache after saving to ensure fresh data on next get()
|
||||
self::$options_cache = null;
|
||||
|
||||
/** @var RedirectionOptions $options */
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply settings and return the updated options array without saving.
|
||||
*
|
||||
* @param array<string, mixed> $settings Partial settings to apply.
|
||||
* @phpstan-return RedirectionOptions
|
||||
* @return array
|
||||
*/
|
||||
private static function apply_settings( array $settings ): array {
|
||||
$options = self::build_options();
|
||||
$monitor_types = [];
|
||||
|
||||
if ( isset( $settings['database'] ) ) {
|
||||
$options['database'] = sanitize_text_field( $settings['database'] );
|
||||
}
|
||||
|
||||
if ( array_key_exists( 'database_stage', $settings ) ) {
|
||||
if ( $settings['database_stage'] === false ) {
|
||||
unset( $options['database_stage'] );
|
||||
} else {
|
||||
$options['database_stage'] = $settings['database_stage'];
|
||||
}
|
||||
}
|
||||
|
||||
if ( isset( $settings['ip_proxy'] ) && is_array( $settings['ip_proxy'] ) ) {
|
||||
$options['ip_proxy'] = array_map(
|
||||
function ( $ip ) {
|
||||
$ip = new Redirection_IP( $ip );
|
||||
return $ip->get();
|
||||
},
|
||||
$settings['ip_proxy']
|
||||
);
|
||||
|
||||
$options['ip_proxy'] = array_values( array_filter( $options['ip_proxy'] ) );
|
||||
}
|
||||
|
||||
if ( isset( $settings['ip_headers'] ) && is_array( $settings['ip_headers'] ) ) {
|
||||
$available = Redirection_Request::get_ip_headers();
|
||||
$options['ip_headers'] = array_filter(
|
||||
$settings['ip_headers'],
|
||||
function ( $header ) use ( $available ) {
|
||||
return in_array( $header, $available, true );
|
||||
}
|
||||
);
|
||||
$options['ip_headers'] = array_values( $options['ip_headers'] );
|
||||
}
|
||||
|
||||
if ( isset( $settings['rest_api'] ) && in_array( intval( $settings['rest_api'], 10 ), array( 0, 1, 2, 3, 4 ), true ) ) {
|
||||
$options['rest_api'] = intval( $settings['rest_api'], 10 );
|
||||
}
|
||||
|
||||
if ( isset( $settings['monitor_types'] ) && is_array( $settings['monitor_types'] ) ) {
|
||||
$allowed = red_get_post_types( false );
|
||||
|
||||
foreach ( $settings['monitor_types'] as $type ) {
|
||||
if ( in_array( $type, $allowed, true ) ) {
|
||||
$monitor_types[] = $type;
|
||||
}
|
||||
}
|
||||
|
||||
$options['monitor_types'] = $monitor_types;
|
||||
}
|
||||
|
||||
if ( isset( $settings['associated_redirect'] ) && is_string( $settings['associated_redirect'] ) ) {
|
||||
$options['associated_redirect'] = '';
|
||||
|
||||
if ( strlen( $settings['associated_redirect'] ) > 0 ) {
|
||||
$sanitizer = new Red_Item_Sanitize();
|
||||
$options['associated_redirect'] = trim( $sanitizer->sanitize_url( $settings['associated_redirect'] ) );
|
||||
}
|
||||
}
|
||||
|
||||
if ( isset( $settings['monitor_types'] ) && count( $monitor_types ) === 0 ) {
|
||||
$options['monitor_post'] = 0;
|
||||
$options['associated_redirect'] = '';
|
||||
} elseif ( isset( $settings['monitor_post'] ) ) {
|
||||
$options['monitor_post'] = max( 0, intval( $settings['monitor_post'], 10 ) );
|
||||
|
||||
if ( Red_Group::get( $options['monitor_post'] ) === false && $options['monitor_post'] !== 0 ) {
|
||||
$groups = Red_Group::get_all();
|
||||
|
||||
if ( count( $groups ) > 0 ) {
|
||||
$options['monitor_post'] = $groups[0]['id'];
|
||||
} else {
|
||||
$options['monitor_post'] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( isset( $settings['auto_target'] ) && is_string( $settings['auto_target'] ) ) {
|
||||
$options['auto_target'] = sanitize_text_field( $settings['auto_target'] );
|
||||
}
|
||||
|
||||
if ( isset( $settings['last_group_id'] ) ) {
|
||||
$options['last_group_id'] = max( 0, intval( $settings['last_group_id'], 10 ) );
|
||||
|
||||
if ( Red_Group::get( $options['last_group_id'] ) === false ) {
|
||||
$groups = Red_Group::get_all();
|
||||
$options['last_group_id'] = count( $groups ) > 0 ? $groups[0]['id'] : 0;
|
||||
}
|
||||
}
|
||||
|
||||
if ( isset( $settings['token'] ) && is_string( $settings['token'] ) ) {
|
||||
$options['token'] = sanitize_text_field( $settings['token'] );
|
||||
}
|
||||
|
||||
if ( isset( $settings['token'] ) && trim( $options['token'] ) === '' ) {
|
||||
$options['token'] = md5( uniqid() );
|
||||
}
|
||||
|
||||
// Boolean settings
|
||||
foreach ( [ 'support', 'https', 'log_external', 'log_header', 'track_hits' ] as $name ) {
|
||||
if ( isset( $settings[ $name ] ) ) {
|
||||
$options[ $name ] = $settings[ $name ] ? true : false;
|
||||
}
|
||||
}
|
||||
|
||||
if ( isset( $settings['expire_redirect'] ) ) {
|
||||
$options['expire_redirect'] = max( -1, min( intval( $settings['expire_redirect'], 10 ), 60 ) );
|
||||
}
|
||||
|
||||
if ( isset( $settings['expire_404'] ) ) {
|
||||
$options['expire_404'] = max( -1, min( intval( $settings['expire_404'], 10 ), 60 ) );
|
||||
}
|
||||
|
||||
if ( isset( $settings['ip_logging'] ) ) {
|
||||
$options['ip_logging'] = max( 0, min( 2, intval( $settings['ip_logging'], 10 ) ) );
|
||||
}
|
||||
|
||||
if ( isset( $settings['redirect_cache'] ) ) {
|
||||
$options['redirect_cache'] = intval( $settings['redirect_cache'], 10 );
|
||||
|
||||
if ( ! in_array( $options['redirect_cache'], array( -1, 0, 1, 24, 24 * 7 ), true ) ) {
|
||||
$options['redirect_cache'] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if ( isset( $settings['location'] ) && ( ! isset( $options['location'] ) || $options['location'] !== $settings['location'] ) ) {
|
||||
$module = Red_Module::get( 2 );
|
||||
|
||||
if ( $module !== false ) {
|
||||
$options['modules'][2] = $module->update( $settings );
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! empty( $options['monitor_post'] ) && count( $options['monitor_types'] ) === 0 ) {
|
||||
// If we have a monitor_post set, but no types, then blank everything
|
||||
$options['monitor_post'] = 0;
|
||||
$options['associated_redirect'] = '';
|
||||
}
|
||||
|
||||
if ( isset( $settings['plugin_update'] ) && in_array( $settings['plugin_update'], [ 'prompt', 'admin' ], true ) ) {
|
||||
$options['plugin_update'] = sanitize_text_field( $settings['plugin_update'] );
|
||||
}
|
||||
|
||||
$flags = new Red_Source_Flags();
|
||||
$flags_present = [];
|
||||
|
||||
foreach ( array_keys( $flags->get_json() ) as $flag ) {
|
||||
if ( isset( $settings[ $flag ] ) ) {
|
||||
$flags_present[ $flag ] = $settings[ $flag ];
|
||||
}
|
||||
}
|
||||
|
||||
if ( count( $flags_present ) > 0 ) {
|
||||
$flags->set_flags( $flags_present );
|
||||
$options = array_merge( $options, $flags->get_json() );
|
||||
}
|
||||
|
||||
if ( isset( $settings['headers'] ) ) {
|
||||
$headers = new Red_Http_Headers( $settings['headers'] );
|
||||
$options['headers'] = $headers->get_json();
|
||||
}
|
||||
|
||||
if ( isset( $settings['aliases'] ) && is_array( $settings['aliases'] ) ) {
|
||||
$options['aliases'] = array_map( 'sanitize_text_field', $settings['aliases'] );
|
||||
$options['aliases'] = array_values( array_filter( array_map( 'red_parse_domain_only', $settings['aliases'] ) ) );
|
||||
$options['aliases'] = array_slice( $options['aliases'], 0, 20 ); // Max 20
|
||||
}
|
||||
|
||||
if ( isset( $settings['permalinks'] ) && is_array( $settings['permalinks'] ) ) {
|
||||
$options['permalinks'] = array_map(
|
||||
function ( $permalink ) {
|
||||
return sanitize_option( 'permalink_structure', $permalink );
|
||||
},
|
||||
$settings['permalinks']
|
||||
);
|
||||
$options['permalinks'] = array_values( array_filter( array_map( 'trim', $options['permalinks'] ) ) );
|
||||
$options['permalinks'] = array_slice( $options['permalinks'], 0, 10 ); // Max 10
|
||||
}
|
||||
|
||||
if ( isset( $settings['preferred_domain'] ) && in_array( $settings['preferred_domain'], [ '', 'www', 'nowww' ], true ) ) {
|
||||
$options['preferred_domain'] = sanitize_text_field( $settings['preferred_domain'] );
|
||||
}
|
||||
|
||||
if ( isset( $settings['relocate'] ) && is_string( $settings['relocate'] ) ) {
|
||||
$options['relocate'] = red_parse_domain_path( sanitize_text_field( $settings['relocate'] ) );
|
||||
|
||||
if ( strlen( $options['relocate'] ) > 0 ) {
|
||||
$options['preferred_domain'] = '';
|
||||
$options['aliases'] = [];
|
||||
$options['https'] = false;
|
||||
}
|
||||
}
|
||||
|
||||
if ( isset( $settings['cache_key'] ) ) {
|
||||
$key = intval( $settings['cache_key'], 10 );
|
||||
|
||||
if ( $settings['cache_key'] === true ) {
|
||||
$key = time();
|
||||
} elseif ( $settings['cache_key'] === false ) {
|
||||
$key = 0;
|
||||
}
|
||||
|
||||
$options['cache_key'] = $key;
|
||||
}
|
||||
|
||||
if ( isset( $settings['update_notice'] ) ) {
|
||||
$major_version = explode( '-', REDIRECTION_VERSION )[0]; // Remove any beta suffix
|
||||
$major_version = implode( '.', array_slice( explode( '.', $major_version ), 0, 2 ) );
|
||||
$options['update_notice'] = $major_version;
|
||||
}
|
||||
|
||||
/** @var RedirectionOptions $options */
|
||||
return $options;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Provides permalink migration facilities
|
||||
*/
|
||||
class Red_Permalinks {
|
||||
/**
|
||||
* List of migrated permalink structures
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private $permalinks = [];
|
||||
|
||||
/**
|
||||
* Current permalink structure
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
private $current_permalink = null;
|
||||
|
||||
/**
|
||||
* Query variables for permalink matching
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
private $query_vars = [];
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param string[] $permalinks List of migrated permalinks.
|
||||
*/
|
||||
public function __construct( $permalinks ) {
|
||||
$this->permalinks = $permalinks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match and migrate any permalinks
|
||||
*
|
||||
* @param WP_Query $query Query.
|
||||
* @return void
|
||||
*/
|
||||
public function migrate( WP_Query $query ) {
|
||||
global $wp, $wp_query;
|
||||
|
||||
if ( count( $this->permalinks ) === 0 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! $this->needs_migrating() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->intercept_permalinks();
|
||||
|
||||
$query_copy = clone $query;
|
||||
|
||||
foreach ( $this->permalinks as $old ) {
|
||||
// Set the current permalink
|
||||
$this->current_permalink = $old;
|
||||
|
||||
// Run the WP query again
|
||||
$wp->init();
|
||||
$wp->parse_request();
|
||||
|
||||
// Anything matched?
|
||||
if ( $wp->matched_rule ) {
|
||||
// Perform the post query
|
||||
$wp->query_posts();
|
||||
|
||||
// A single post?
|
||||
if ( is_single() && count( $query->posts ) > 0 && $query->posts[0] instanceof WP_Post ) {
|
||||
// Restore permalinks
|
||||
$this->release_permalinks();
|
||||
|
||||
// Get real URL from the post ID
|
||||
$url = get_permalink( $query->posts[0]->ID );
|
||||
if ( $url !== false ) {
|
||||
wp_safe_redirect( $url, 301, 'redirection' );
|
||||
die();
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the query back to the original
|
||||
// phpcs:ignore
|
||||
$wp_query = $query_copy;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$this->release_permalinks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the current request needs migrating. This is based on `WP::handle_404` in class-wp.php
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
private function needs_migrating() {
|
||||
global $wp_query;
|
||||
|
||||
// It's a 404 - shortcut to yes
|
||||
if ( is_404() ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Not admin pages
|
||||
if ( is_admin() || is_robots() || is_favicon() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO: query_vars doesnt appear to be used
|
||||
if ( $wp_query->posts && ! $wp_query->is_posts_page && empty( $this->query_vars['page'] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( ! is_paged() ) {
|
||||
$author = get_query_var( 'author' );
|
||||
|
||||
// Don't 404 for authors without posts as long as they matched an author on this site.
|
||||
if (
|
||||
( is_author() && is_numeric( $author ) && $author > 0 && is_user_member_of_blog( (int) $author ) )
|
||||
// Don't 404 for these queries if they matched an object.
|
||||
|| ( ( is_tag() || is_category() || is_tax() || is_post_type_archive() ) && get_queried_object() !== null )
|
||||
// Don't 404 for these queries either.
|
||||
|| is_home() || is_search() || is_feed()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If we've got this far then it's a 404
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook the permalink options and return the migrated one
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function intercept_permalinks() {
|
||||
add_filter( 'pre_option_rewrite_rules', [ $this, 'get_old_rewrite_rules' ] );
|
||||
add_filter( 'pre_option_permalink_structure', [ $this, 'get_old_permalink' ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the hooked option
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function release_permalinks() {
|
||||
remove_filter( 'pre_option_rewrite_rules', [ $this, 'get_old_rewrite_rules' ] );
|
||||
remove_filter( 'pre_option_permalink_structure', [ $this, 'get_old_permalink' ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns rewrite rules for the current migrated permalink
|
||||
*
|
||||
* @param array<string, string> $rules Current rules.
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function get_old_rewrite_rules( $rules ) {
|
||||
global $wp_rewrite;
|
||||
|
||||
if ( $this->current_permalink !== null ) {
|
||||
$wp_rewrite->init();
|
||||
$wp_rewrite->matches = 'matches';
|
||||
return $wp_rewrite->rewrite_rules();
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current migrated permalink structure
|
||||
*
|
||||
* @param string $result Current value.
|
||||
* @return string
|
||||
*/
|
||||
public function get_old_permalink( $result ) {
|
||||
if ( $this->current_permalink !== null ) {
|
||||
return $this->current_permalink;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Redirect caching.
|
||||
*
|
||||
* This is based on server requests and not database requests.
|
||||
*
|
||||
* The client requests a URL. We use the requested URL, the `cache_key` setting, and the plugin version, to look for a cache entry.
|
||||
*
|
||||
* The `cache_key` is updated each time *any* redirect is updated. This is because a URL can be affected by other redirects, such as regular expressions
|
||||
* and redirects with dynamic conditions (i.e. cookie, login status etc).
|
||||
*
|
||||
* We include the plugin version as data can change between plugin versions, and it is safest to use new cache entries.
|
||||
*
|
||||
* If we have a cache hit then the data is used to perform the redirect.php
|
||||
*
|
||||
* If we do not have a cache hit then we request the URL from the database and perform redirect matches.
|
||||
*
|
||||
* After matching has been performed we then try and update the cache:
|
||||
* - if no match was found, cache an empty result
|
||||
* - if a match was found and no dynamic redirects were encountered, then cache that redirect only
|
||||
* - if a match was found and dynamic redirects were involved then cache all redirects
|
||||
*
|
||||
* We have a maximum number of redirects that can be cached to avoid saturating the cache.
|
||||
*
|
||||
* @phpstan-type RedirectSqlRow array{
|
||||
* id: int,
|
||||
* url: string,
|
||||
* match_url: string,
|
||||
* match_data: mixed,
|
||||
* action_code: int,
|
||||
* action_type: string,
|
||||
* action_data: string|null,
|
||||
* match_type: string,
|
||||
* title: string,
|
||||
* hits: int,
|
||||
* regex: bool,
|
||||
* group_id: int,
|
||||
* position: int,
|
||||
* last_access: string,
|
||||
* status: string,
|
||||
* enabled: bool
|
||||
* }
|
||||
*/
|
||||
class Redirect_Cache {
|
||||
const EMPTY_VALUE = 'empty';
|
||||
const CACHE_MAX = 10;
|
||||
|
||||
/**
|
||||
* Singleton
|
||||
*
|
||||
* @var Redirect_Cache|null
|
||||
*/
|
||||
private static $instance = null;
|
||||
|
||||
/**
|
||||
* Array of URLs that have been cached
|
||||
*
|
||||
* @var array<string,bool>
|
||||
*/
|
||||
private $cached = [];
|
||||
|
||||
/**
|
||||
* Cache key. Changed to current time whenever a redirect is updated.
|
||||
*
|
||||
* @var integer
|
||||
*/
|
||||
private $key = 0;
|
||||
|
||||
/**
|
||||
* Initialiser
|
||||
*
|
||||
* @return Redirect_Cache
|
||||
*/
|
||||
public static function init() {
|
||||
if ( is_null( self::$instance ) ) {
|
||||
self::$instance = new Redirect_Cache();
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function reset() {
|
||||
$settings = Red_Options::get();
|
||||
$this->key = $settings['cache_key'];
|
||||
$this->cached = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the cache enabled?
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
public function can_cache() {
|
||||
return $this->key > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current cache key
|
||||
*
|
||||
* @param string $url URL we are looking at.
|
||||
* @return string
|
||||
*/
|
||||
private function get_key( $url ) {
|
||||
return apply_filters( 'redirection_cache_key', md5( $url ) . '-' . (string) $this->key . '-' . REDIRECTION_VERSION );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cache entry for a URL
|
||||
*
|
||||
* @param string $url Requested URL.
|
||||
* @return Red_Item[]|bool
|
||||
*/
|
||||
public function get( $url ) {
|
||||
if ( ! $this->can_cache() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$cache_key = $this->get_key( $url );
|
||||
|
||||
// Look in cache
|
||||
$false = false;
|
||||
$result = wp_cache_get( $cache_key, 'redirection', false, $false );
|
||||
|
||||
// If a result was found then remember we are using the cache so we don't need to re-save it later
|
||||
if ( $result !== false ) {
|
||||
$this->cached[ $url ] = true;
|
||||
}
|
||||
|
||||
// Empty value is a special case. Storing [] in the cache doesn't work, so we store the special EMPTY_VALUE to represent []
|
||||
if ( $result === self::EMPTY_VALUE ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the cache for a URL
|
||||
*
|
||||
* @param string $url URL to cache.
|
||||
* @param Red_Item|false $matched The matched redirect.
|
||||
* @param Red_Item[] $redirects All of the redirects the match the URL.
|
||||
* @return boolean
|
||||
*/
|
||||
public function set( $url, $matched, $redirects ) {
|
||||
if ( ! $this->can_cache() || isset( $this->cached[ $url ] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$cache_key = $this->get_key( $url );
|
||||
|
||||
// Default store the match redirect
|
||||
$rows = [];
|
||||
if ( $matched !== false ) {
|
||||
$rows[] = $matched;
|
||||
}
|
||||
|
||||
// Are any of the redirects before, and including, the match a dynamic redirect?
|
||||
$dynamic = $this->get_dynamic_matched( $redirects, $matched );
|
||||
if ( count( $dynamic ) > 0 ) {
|
||||
// Store all dynamic redirects
|
||||
$rows = $dynamic;
|
||||
}
|
||||
|
||||
// Have we exceeded our limit?
|
||||
if ( count( $rows ) > self::CACHE_MAX ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$converted = $this->convert_to_rows( $rows );
|
||||
$value = count( $converted ) === 0 ? self::EMPTY_VALUE : $converted;
|
||||
|
||||
wp_cache_set( $cache_key, $value, 'redirection' );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Red_Item to a format suitable for storing in the cache
|
||||
* Returns the JSON representation of redirects (without 'status' field).
|
||||
*
|
||||
* @param Red_Item[] $rows Redirects.
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function convert_to_rows( array $rows ) {
|
||||
$converted = [];
|
||||
|
||||
foreach ( $rows as $row ) {
|
||||
$converted[] = $row->to_sql();
|
||||
}
|
||||
|
||||
return $converted;
|
||||
}
|
||||
|
||||
/**
|
||||
* If there are dynamic redirects before the matched redirect then return all dynamic redirects (including the matched one), otherwise return nothing.
|
||||
*
|
||||
* If the matched redirect is a static redirect then we include it in the list, but don't include any redirects after.
|
||||
*
|
||||
* @param Red_Item[] $redirects Array of redirects.
|
||||
* @param Red_Item|false $matched The matched item.
|
||||
* @return Red_Item[]
|
||||
*/
|
||||
private function get_dynamic_matched( array $redirects, $matched ) {
|
||||
$dynamic = [];
|
||||
|
||||
foreach ( $redirects as $redirect ) {
|
||||
if ( $redirect->is_dynamic() ) {
|
||||
$dynamic[] = $redirect;
|
||||
}
|
||||
|
||||
// Is this the matched redirect?
|
||||
if ( $matched === $redirect ) {
|
||||
// Yes. Do we have any dynamic redirects so far?
|
||||
if ( count( $dynamic ) === 0 ) {
|
||||
// No. Just return an empty array
|
||||
return [];
|
||||
}
|
||||
|
||||
if ( ! $matched->is_dynamic() ) {
|
||||
// We need to include the non-dynamic redirect in the list
|
||||
return array_merge( $dynamic, [ $matched ] );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $dynamic;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Filter the redirects
|
||||
*/
|
||||
class Red_Item_Filters {
|
||||
/**
|
||||
* List of filters
|
||||
*
|
||||
* @phpstan-var list<string>
|
||||
* @var array
|
||||
*/
|
||||
private $filters = [];
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param array<string, string> $filter_params Filters.
|
||||
*/
|
||||
public function __construct( $filter_params ) {
|
||||
global $wpdb;
|
||||
|
||||
foreach ( $filter_params as $filter_by => $filter ) {
|
||||
$filter = trim( sanitize_text_field( $filter ) );
|
||||
$filter_by = sanitize_text_field( $filter_by );
|
||||
|
||||
if ( $filter_by === 'status' ) {
|
||||
if ( $filter === 'enabled' ) {
|
||||
$this->filters[] = "status='enabled'";
|
||||
} else {
|
||||
$this->filters[] = "status='disabled'";
|
||||
}
|
||||
} elseif ( $filter_by === 'url-match' ) {
|
||||
if ( $filter === 'regular' ) {
|
||||
$this->filters[] = 'regex=1';
|
||||
} else {
|
||||
$this->filters[] = 'regex=0';
|
||||
}
|
||||
} elseif ( $filter_by === 'id' ) {
|
||||
$this->filters[] = $wpdb->prepare( 'id=%d', intval( $filter, 10 ) );
|
||||
} elseif ( $filter_by === 'match' && in_array( $filter, array_keys( Red_Match::available() ), true ) ) {
|
||||
$this->filters[] = $wpdb->prepare( 'match_type=%s', $filter );
|
||||
} elseif ( $filter_by === 'action' && in_array( $filter, array_keys( Red_Action::available() ), true ) ) {
|
||||
$this->filters[] = $wpdb->prepare( 'action_type=%s', $filter );
|
||||
} elseif ( $filter_by === 'http' ) {
|
||||
$sanitizer = new Red_Item_Sanitize();
|
||||
$filter = intval( $filter, 10 );
|
||||
|
||||
if ( $sanitizer->is_valid_error_code( $filter ) || $sanitizer->is_valid_redirect_code( $filter ) ) {
|
||||
$this->filters[] = $wpdb->prepare( 'action_code=%d', $filter );
|
||||
}
|
||||
} elseif ( $filter_by === 'access' ) {
|
||||
if ( $filter === 'year' ) {
|
||||
$this->filters[] = 'last_access < DATE_SUB(NOW(),INTERVAL 1 YEAR)';
|
||||
} elseif ( $filter === 'month' ) {
|
||||
$this->filters[] = 'last_access < DATE_SUB(NOW(),INTERVAL 1 MONTH)';
|
||||
} else {
|
||||
$this->filters[] = "( last_access < '1970-01-01 00:00:01' )";
|
||||
}
|
||||
} elseif ( $filter_by === 'url' ) {
|
||||
$this->filters[] = $wpdb->prepare( 'url LIKE %s', '%' . $wpdb->esc_like( $filter ) . '%' );
|
||||
} elseif ( $filter_by === 'target' ) {
|
||||
$this->filters[] = $wpdb->prepare( 'action_data LIKE %s', '%' . $wpdb->esc_like( $filter ) . '%' );
|
||||
} elseif ( $filter_by === 'title' ) {
|
||||
$this->filters[] = $wpdb->prepare( 'title LIKE %s', '%' . $wpdb->esc_like( $filter ) . '%' );
|
||||
} elseif ( $filter_by === 'group' ) {
|
||||
$this->filters[] = $wpdb->prepare( 'group_id=%d', intval( $filter, 10 ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the filters as sanitized SQL.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_as_sql() {
|
||||
if ( count( $this->filters ) > 0 ) {
|
||||
return 'WHERE ' . implode( ' AND ', $this->filters );
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Options for a redirect source URL
|
||||
*
|
||||
* @phpstan-type SourceOptionsJson array{
|
||||
* log_exclude?: bool
|
||||
* }
|
||||
*/
|
||||
class Red_Source_Options {
|
||||
/**
|
||||
* Exclude this from logging.
|
||||
*
|
||||
* @var boolean
|
||||
*/
|
||||
private $log_exclude = false;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param SourceOptionsJson|null $options Options.
|
||||
*/
|
||||
public function __construct( $options = null ) {
|
||||
if ( $options !== null ) {
|
||||
$this->set_options( $options );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set options
|
||||
*
|
||||
* @param SourceOptionsJson $options Options.
|
||||
* @return void
|
||||
*/
|
||||
public function set_options( $options ) {
|
||||
if ( isset( $options['log_exclude'] ) && $options['log_exclude'] === true ) {
|
||||
$this->log_exclude = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this source be logged?
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
public function can_log() {
|
||||
$options = Red_Options::get();
|
||||
|
||||
if ( $options['expire_redirect'] !== -1 ) {
|
||||
return ! $this->log_exclude;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get options as JSON
|
||||
*
|
||||
* @return SourceOptionsJson
|
||||
*/
|
||||
public function get_json() {
|
||||
return array_filter(
|
||||
[
|
||||
'log_exclude' => $this->log_exclude,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @phpstan-import-type RedirectCreateDetails from Red_Item
|
||||
*/
|
||||
|
||||
class Red_Item_Sanitize {
|
||||
/**
|
||||
* Trim strings recursively in an array
|
||||
*
|
||||
* @param array<string, mixed> $array_to_clean
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function clean_array( $array_to_clean ) {
|
||||
foreach ( $array_to_clean as $name => $value ) {
|
||||
if ( is_array( $value ) ) {
|
||||
$array_to_clean[ $name ] = $this->clean_array( $value );
|
||||
} elseif ( is_string( $value ) ) {
|
||||
$value = trim( $value );
|
||||
$array_to_clean[ $name ] = $value;
|
||||
} else {
|
||||
$array_to_clean[ $name ] = $value;
|
||||
}
|
||||
};
|
||||
|
||||
return $array_to_clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert absolute URL into server match where appropriate
|
||||
*
|
||||
* @param string $url
|
||||
* @param array<string, mixed> $details
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function set_server( $url, array $details ) {
|
||||
$return = [];
|
||||
$domain = wp_parse_url( $url, PHP_URL_HOST );
|
||||
|
||||
// Auto-convert an absolute URL to relative + server match
|
||||
if ( is_string( $domain ) && $domain !== Redirection_Request::get_server_name() ) {
|
||||
$return['match_type'] = 'server';
|
||||
|
||||
if ( isset( $details['action_data']['url'] ) ) {
|
||||
$return['action_data'] = [
|
||||
'server' => $domain,
|
||||
'url_from' => $details['action_data']['url'],
|
||||
];
|
||||
} else {
|
||||
$return['action_data'] = [ 'server' => $domain ];
|
||||
}
|
||||
|
||||
$url = wp_parse_url( $url, PHP_URL_PATH );
|
||||
if ( $url === null || $url === false ) {
|
||||
$url = '/';
|
||||
}
|
||||
}
|
||||
|
||||
$return['url'] = $url;
|
||||
return $return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize and normalize redirect details
|
||||
*
|
||||
* @param array<string, mixed>|RedirectCreateDetails $details
|
||||
* @return array<string, mixed>|\WP_Error
|
||||
*/
|
||||
public function get( array $details ) {
|
||||
$data = [];
|
||||
$details = $this->clean_array( $details );
|
||||
|
||||
// Set regex
|
||||
$data['regex'] = isset( $details['regex'] ) && intval( $details['regex'], 10 ) === 1 ? 1 : 0;
|
||||
|
||||
// Auto-migrate the regex to the source flags
|
||||
$data['match_data'] = [ 'source' => [ 'flag_regex' => $data['regex'] === 1 ? true : false ] ];
|
||||
|
||||
$flags = new Red_Source_Flags();
|
||||
|
||||
// Set flags
|
||||
if ( isset( $details['match_data'] ) && isset( $details['match_data']['source'] ) ) {
|
||||
$defaults = Red_Options::get();
|
||||
|
||||
// Parse the source flags
|
||||
$flags = new Red_Source_Flags( $details['match_data']['source'] );
|
||||
|
||||
// Remove defaults
|
||||
$data['match_data']['source'] = $flags->get_json_without_defaults( $defaults );
|
||||
$data['regex'] = $flags->is_regex() ? 1 : 0;
|
||||
}
|
||||
|
||||
// If match_data is empty then don't save anything
|
||||
// @phpstan-ignore isset.offset
|
||||
if ( isset( $data['match_data']['source'] ) && count( $data['match_data']['source'] ) === 0 ) {
|
||||
$data['match_data']['source'] = [];
|
||||
}
|
||||
|
||||
if ( isset( $details['match_data']['options'] ) && is_array( $details['match_data']['options'] ) ) {
|
||||
$source = new Red_Source_Options( $details['match_data']['options'] );
|
||||
$data['match_data']['options'] = $source->get_json();
|
||||
}
|
||||
|
||||
$data['match_data'] = array_filter( $data['match_data'] );
|
||||
|
||||
if ( empty( $data['match_data'] ) ) {
|
||||
$data['match_data'] = null;
|
||||
}
|
||||
|
||||
// Parse URL
|
||||
$url = empty( $details['url'] ) ? $this->auto_generate() : $details['url'];
|
||||
if ( strpos( $url, 'http:' ) !== false || strpos( $url, 'https:' ) !== false ) {
|
||||
$details = array_merge( $details, $this->set_server( $url, $details ) );
|
||||
}
|
||||
|
||||
$data['match_type'] = isset( $details['match_type'] ) ? sanitize_text_field( $details['match_type'] ) : 'url';
|
||||
$data['url'] = $this->get_url( $url, $data['regex'] === 1 ? true : false );
|
||||
|
||||
if ( isset( $details['hits'] ) ) {
|
||||
$data['last_count'] = intval( $details['hits'], 10 );
|
||||
}
|
||||
|
||||
if ( isset( $details['last_access'] ) ) {
|
||||
$last_access = strtotime( sanitize_text_field( $details['last_access'] ) );
|
||||
if ( $last_access !== false ) {
|
||||
$data['last_access'] = gmdate( 'Y-m-d H:i:s', $last_access );
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! is_wp_error( $data['url'] ) ) {
|
||||
$matcher = new Red_Url_Match( $data['url'] );
|
||||
$data['match_url'] = $matcher->get_url();
|
||||
|
||||
// If 'exact order' then save the match URL with query params
|
||||
if ( $flags->is_query_exact_order() ) {
|
||||
$data['match_url'] = $matcher->get_url_with_params();
|
||||
}
|
||||
}
|
||||
|
||||
$data['title'] = ! empty( $details['title'] ) ? $details['title'] : null;
|
||||
$data['group_id'] = $this->get_group( isset( $details['group_id'] ) ? $details['group_id'] : 0 );
|
||||
$data['position'] = $this->get_position( $details );
|
||||
|
||||
// Set match_url to 'regex'
|
||||
if ( $data['regex'] === 1 ) {
|
||||
$data['match_url'] = 'regex';
|
||||
}
|
||||
|
||||
if ( is_string( $data['title'] ) ) {
|
||||
$data['title'] = trim( substr( sanitize_text_field( $data['title'] ), 0, 500 ) );
|
||||
$data['title'] = wp_kses( $data['title'], 'strip' );
|
||||
|
||||
if ( strlen( $data['title'] ) === 0 ) {
|
||||
$data['title'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
$matcher = Red_Match::create( isset( $details['match_type'] ) ? sanitize_text_field( $details['match_type'] ) : '' );
|
||||
if ( $matcher === null ) {
|
||||
return new WP_Error( 'redirect', 'Invalid redirect matcher' );
|
||||
}
|
||||
|
||||
$action_code = isset( $details['action_code'] ) ? intval( $details['action_code'], 10 ) : 0;
|
||||
$action = Red_Action::create( isset( $details['action_type'] ) ? sanitize_text_field( $details['action_type'] ) : '', $action_code );
|
||||
if ( $action === null ) {
|
||||
return new WP_Error( 'redirect', 'Invalid redirect action' );
|
||||
}
|
||||
|
||||
$data['action_type'] = sanitize_text_field( $details['action_type'] );
|
||||
$data['action_code'] = $this->get_code( $details['action_type'], $action_code );
|
||||
|
||||
if ( isset( $details['action_data'] ) && is_array( $details['action_data'] ) ) {
|
||||
$match_data = $matcher->save( $details['action_data'] ? $details['action_data'] : array(), ! $this->is_url_type( $data['action_type'] ) );
|
||||
$data['action_data'] = is_array( $match_data ) ? serialize( $match_data ) : $match_data;
|
||||
}
|
||||
|
||||
// Any errors?
|
||||
foreach ( $data as $value ) {
|
||||
if ( is_wp_error( $value ) ) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return apply_filters( 'redirection_validate_redirect', $data );
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $details
|
||||
* @return int
|
||||
*/
|
||||
protected function get_position( $details ) {
|
||||
if ( isset( $details['position'] ) ) {
|
||||
return max( 0, intval( $details['position'], 10 ) );
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $type
|
||||
* @return bool
|
||||
*/
|
||||
protected function is_url_type( $type ) {
|
||||
if ( $type === 'url' || $type === 'pass' ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $code
|
||||
* @return bool
|
||||
*/
|
||||
public function is_valid_redirect_code( $code ) {
|
||||
return in_array( $code, array( 301, 302, 303, 304, 307, 308 ), true );
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $code
|
||||
* @return bool
|
||||
*/
|
||||
public function is_valid_error_code( $code ) {
|
||||
return in_array( $code, array( 400, 401, 403, 404, 410, 418, 451, 500, 501, 502, 503, 504 ), true );
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $action_type
|
||||
* @param int $code
|
||||
* @return int
|
||||
*/
|
||||
protected function get_code( $action_type, $code ) {
|
||||
if ( $action_type === 'url' || $action_type === 'random' ) {
|
||||
if ( $this->is_valid_redirect_code( $code ) ) {
|
||||
return $code;
|
||||
}
|
||||
|
||||
return 301;
|
||||
}
|
||||
|
||||
if ( $action_type === 'error' ) {
|
||||
if ( $this->is_valid_error_code( $code ) ) {
|
||||
return $code;
|
||||
}
|
||||
|
||||
return 404;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int|string $group_id
|
||||
* @return int|\WP_Error
|
||||
*/
|
||||
protected function get_group( $group_id ) {
|
||||
$group_id = intval( $group_id, 10 );
|
||||
|
||||
if ( Red_Group::get( $group_id ) === false ) {
|
||||
return new WP_Error( 'redirect', 'Invalid group when creating redirect' );
|
||||
}
|
||||
|
||||
return $group_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $url
|
||||
* @param bool $regex
|
||||
* @return string|\WP_Error
|
||||
*/
|
||||
protected function get_url( $url, $regex ) {
|
||||
$url = self::sanitize_url( $url, $regex );
|
||||
|
||||
if ( $url === '' ) {
|
||||
return new WP_Error( 'redirect', 'Invalid source URL' );
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
protected function auto_generate() {
|
||||
$options = Red_Options::get();
|
||||
$url = '';
|
||||
|
||||
if ( strlen( $options['auto_target'] ) > 0 ) {
|
||||
$id = time();
|
||||
$url = str_replace( '$dec$', (string) $id, $options['auto_target'] );
|
||||
$url = str_replace( '$hex$', sprintf( '%x', $id ), $url );
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $url
|
||||
* @param bool $regex
|
||||
* @return string
|
||||
*/
|
||||
public function sanitize_url( $url, $regex = false ) {
|
||||
$url = wp_kses( $url, 'strip' );
|
||||
$url = str_replace( '&', '&', $url );
|
||||
|
||||
// Make sure that the old URL is relative
|
||||
$url = (string) preg_replace( '@^https?://(.*?)/@', '/', $url );
|
||||
$url = (string) preg_replace( '@^https?://(.*?)$@', '/', $url );
|
||||
|
||||
// No new lines
|
||||
$url = (string) preg_replace( "/[\r\n\t].*?$/s", '', $url );
|
||||
|
||||
// Clean control codes
|
||||
$url = (string) preg_replace( '/[^\PC\s]/u', '', $url );
|
||||
|
||||
// Ensure a slash at start
|
||||
if ( substr( $url, 0, 1 ) !== '/' && $regex === false ) {
|
||||
$url = '/' . $url;
|
||||
}
|
||||
|
||||
// Try and URL decode any i10n characters
|
||||
$decoded = $this->remove_bad_encoding( rawurldecode( $url ) );
|
||||
|
||||
// Was there any invalid characters?
|
||||
if ( $decoded === false ) {
|
||||
// Yes. Use the url as an undecoded URL, and check for invalid characters
|
||||
$decoded = $this->remove_bad_encoding( $url );
|
||||
|
||||
// Was there any invalid characters?
|
||||
if ( $decoded === false ) {
|
||||
// Yes, it's still a problem. Use the URL as-is and hope for the best
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
|
||||
if ( $regex ) {
|
||||
$decoded = str_replace( '?<!', '?<!', $decoded );
|
||||
}
|
||||
|
||||
// Return the URL
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any bad encoding, where possible
|
||||
*
|
||||
* @param string $text Text.
|
||||
* @return string|false
|
||||
*/
|
||||
/**
|
||||
* @param string $text
|
||||
* @return string|false
|
||||
*/
|
||||
private function remove_bad_encoding( $text ) {
|
||||
// Try and remove bad decoding
|
||||
if ( function_exists( 'iconv' ) ) {
|
||||
return @iconv( 'UTF-8', 'UTF-8//IGNORE', sanitize_textarea_field( $text ) );
|
||||
}
|
||||
|
||||
return sanitize_textarea_field( $text );
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Regular expression helper
|
||||
*/
|
||||
class Red_Regex {
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $pattern;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
private $case;
|
||||
|
||||
/**
|
||||
* @param string $pattern
|
||||
* @param bool $case_insensitive
|
||||
*/
|
||||
public function __construct( $pattern, $case_insensitive = false ) {
|
||||
$this->pattern = rawurldecode( $pattern );
|
||||
$this->case = $case_insensitive;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does $target match the regex pattern, applying case insensitivity if set.
|
||||
*
|
||||
* Note: if the pattern is invalid it will not match
|
||||
*
|
||||
* @param string $target Text to match the regex against.
|
||||
* @return boolean match
|
||||
*/
|
||||
public function is_match( $target ) {
|
||||
return @preg_match( $this->get_regex(), $target, $matches ) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $path
|
||||
* @return string
|
||||
*/
|
||||
private function encode_path( $path ) {
|
||||
return str_replace( ' ', '%20', $path );
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $path
|
||||
* @return string
|
||||
*/
|
||||
private function encode_query( $path ) {
|
||||
return str_replace( ' ', '+', $path );
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex replace the current pattern with $replace_pattern, applied to $target
|
||||
*
|
||||
* Note: if the pattern is invalid it will return $target
|
||||
*
|
||||
* @param string $replace_pattern The regex replace pattern.
|
||||
* @param string $target Text to match the regex against.
|
||||
* @return string Replaced text
|
||||
*/
|
||||
public function replace( $replace_pattern, $target ) {
|
||||
$regex = $this->get_regex();
|
||||
$result = @preg_replace( $regex, $replace_pattern, $target );
|
||||
|
||||
if ( is_null( $result ) ) {
|
||||
return $target;
|
||||
}
|
||||
|
||||
// Space encode the target
|
||||
$split = explode( '?', $result );
|
||||
if ( count( $split ) === 2 ) {
|
||||
$result = implode( '?', [ $this->encode_path( $split[0] ), $this->encode_query( $split[1] ) ] );
|
||||
} else {
|
||||
$result = $this->encode_path( $result );
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
private function get_regex() {
|
||||
$at_escaped = str_replace( '@', '\\@', $this->pattern );
|
||||
$case = '';
|
||||
|
||||
if ( $this->is_ignore_case() ) {
|
||||
$case = 'i';
|
||||
}
|
||||
|
||||
return '@' . $at_escaped . '@s' . $case;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function is_ignore_case() {
|
||||
return $this->case;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/ip.php';
|
||||
|
||||
class Redirection_Request {
|
||||
/**
|
||||
* URL friendly sanitize_text_fields which lets encoded characters through and doesn't trim
|
||||
*
|
||||
* @param string $value Value.
|
||||
* @return string
|
||||
*/
|
||||
public static function sanitize_url( $value ) {
|
||||
// Remove invalid UTF
|
||||
$url = wp_check_invalid_utf8( $value, true );
|
||||
|
||||
// No new lines
|
||||
$url = (string) preg_replace( "/[\r\n\t].*?$/s", '', $url );
|
||||
|
||||
// Clean control codes
|
||||
$url = (string) preg_replace( '/[^\PC\s]/u', '', $url );
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get HTTP headers
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function get_request_headers() {
|
||||
$ignore = apply_filters(
|
||||
'redirection_request_headers_ignore',
|
||||
[
|
||||
'cookie',
|
||||
'host',
|
||||
]
|
||||
);
|
||||
$headers = [];
|
||||
|
||||
foreach ( $_SERVER as $name => $value ) {
|
||||
$value = sanitize_text_field( $value );
|
||||
$name = sanitize_text_field( $name );
|
||||
|
||||
if ( substr( $name, 0, 5 ) === 'HTTP_' ) {
|
||||
$name = strtolower( substr( $name, 5 ) );
|
||||
$name = str_replace( '_', ' ', $name );
|
||||
$name = ucwords( $name );
|
||||
$name = str_replace( ' ', '-', $name );
|
||||
|
||||
if ( ! in_array( strtolower( $name ), $ignore, true ) ) {
|
||||
$headers[ $name ] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return apply_filters( 'redirection_request_headers', $headers );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get request method
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_request_method() {
|
||||
$method = '';
|
||||
|
||||
if ( isset( $_SERVER['REQUEST_METHOD'] ) && is_string( $_SERVER['REQUEST_METHOD'] ) ) {
|
||||
$method = sanitize_text_field( $_SERVER['REQUEST_METHOD'] );
|
||||
}
|
||||
|
||||
return apply_filters( 'redirection_request_method', $method );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server name (from $_SERVER['SERVER_NAME]), or use the request name ($_SERVER['HTTP_HOST']) if not present
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_server_name() {
|
||||
$host = self::get_request_server_name();
|
||||
|
||||
if ( isset( $_SERVER['SERVER_NAME'] ) && is_string( $_SERVER['SERVER_NAME'] ) ) {
|
||||
$host = sanitize_text_field( $_SERVER['SERVER_NAME'] );
|
||||
}
|
||||
|
||||
return apply_filters( 'redirection_request_server', $host );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the request server name (from $_SERVER['HTTP_HOST])
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_request_server_name() {
|
||||
$host = '';
|
||||
|
||||
if ( isset( $_SERVER['HTTP_HOST'] ) && is_string( $_SERVER['HTTP_HOST'] ) ) {
|
||||
$host = sanitize_text_field( $_SERVER['HTTP_HOST'] );
|
||||
}
|
||||
|
||||
$parts = explode( ':', $host );
|
||||
|
||||
return apply_filters( 'redirection_request_server_host', $parts[0] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server name + protocol
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_server() {
|
||||
return self::get_protocol() . '://' . self::get_server_name();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get protocol
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_protocol() {
|
||||
return is_ssl() ? 'https' : 'http';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get request protocol
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_request_url() {
|
||||
$url = '';
|
||||
|
||||
if ( isset( $_SERVER['REQUEST_URI'] ) && is_string( $_SERVER['REQUEST_URI'] ) ) {
|
||||
$url = self::sanitize_url( $_SERVER['REQUEST_URI'] );
|
||||
}
|
||||
|
||||
return apply_filters( 'redirection_request_url', stripslashes( $url ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user agent
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_user_agent() {
|
||||
$agent = '';
|
||||
|
||||
if ( isset( $_SERVER['HTTP_USER_AGENT'] ) && is_string( $_SERVER['HTTP_USER_AGENT'] ) ) {
|
||||
$agent = sanitize_text_field( $_SERVER['HTTP_USER_AGENT'] );
|
||||
}
|
||||
|
||||
return apply_filters( 'redirection_request_agent', $agent );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get referrer
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_referrer() {
|
||||
$referrer = '';
|
||||
|
||||
if ( isset( $_SERVER['HTTP_REFERER'] ) && is_string( $_SERVER['HTTP_REFERER'] ) ) {
|
||||
$referrer = self::sanitize_url( $_SERVER['HTTP_REFERER'] );
|
||||
}
|
||||
|
||||
return apply_filters( 'redirection_request_referrer', $referrer );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get standard IP header names
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function get_ip_headers() {
|
||||
return [
|
||||
'HTTP_CF_CONNECTING_IP',
|
||||
'HTTP_CLIENT_IP',
|
||||
'HTTP_X_FORWARDED_FOR',
|
||||
'HTTP_X_FORWARDED',
|
||||
'HTTP_X_CLUSTER_CLIENT_IP',
|
||||
'HTTP_FORWARDED_FOR',
|
||||
'HTTP_FORWARDED',
|
||||
'HTTP_VIA',
|
||||
'REMOTE_ADDR',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get browser IP
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_ip() {
|
||||
$options = Red_Options::get();
|
||||
$ip = new Redirection_IP();
|
||||
|
||||
// This is set by the server, but may not be the actual IP
|
||||
if ( isset( $_SERVER['REMOTE_ADDR'] ) ) {
|
||||
$ip = new Redirection_IP( $_SERVER['REMOTE_ADDR'] );
|
||||
}
|
||||
|
||||
if ( in_array( $ip->get(), $options['ip_proxy'], true ) || empty( $options['ip_proxy'] ) ) {
|
||||
foreach ( $options['ip_headers'] as $header ) {
|
||||
if ( isset( $_SERVER[ $header ] ) ) {
|
||||
$ip = new Redirection_IP( $_SERVER[ $header ] );
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return apply_filters( 'redirection_request_ip', $ip->get() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a cookie
|
||||
*
|
||||
* @param string $cookie Name.
|
||||
* @return string|false
|
||||
*/
|
||||
public static function get_cookie( $cookie ) {
|
||||
if ( isset( $_COOKIE[ $cookie ] ) && is_string( $_COOKIE[ $cookie ] ) ) {
|
||||
return apply_filters( 'redirection_request_cookie', sanitize_text_field( $_COOKIE[ $cookie ] ), $cookie );
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a HTTP header
|
||||
*
|
||||
* @param string $name Header name.
|
||||
* @return string|false
|
||||
*/
|
||||
public static function get_header( $name ) {
|
||||
$name = 'HTTP_' . strtoupper( $name );
|
||||
$name = str_replace( '-', '_', $name );
|
||||
|
||||
if ( isset( $_SERVER[ $name ] ) && is_string( $_SERVER[ $name ] ) ) {
|
||||
return apply_filters( 'redirection_request_header', sanitize_text_field( $_SERVER[ $name ] ), $name );
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get browser accept language
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function get_accept_language() {
|
||||
if ( isset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) && is_string( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) {
|
||||
$languages = (string) preg_replace( '/;.*$/', '', sanitize_text_field( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) );
|
||||
$languages = str_replace( ' ', '', $languages );
|
||||
|
||||
return apply_filters( 'redirection_request_accept_language', explode( ',', $languages ) );
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* URL encoding handler for source and target URLs
|
||||
*
|
||||
* @phpstan-type EncodingMap array<string, string>
|
||||
*/
|
||||
class Red_Url_Encode {
|
||||
/**
|
||||
* URL
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $url;
|
||||
|
||||
/**
|
||||
* Is regex?
|
||||
*
|
||||
* @var boolean
|
||||
*/
|
||||
private $is_regex;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param string $url URL.
|
||||
* @param boolean $is_regex Is Regex.
|
||||
*/
|
||||
public function __construct( $url, $is_regex = false ) {
|
||||
// Remove any newlines
|
||||
$url = (string) preg_replace( "/[\r\n\t].*?$/s", '', $url );
|
||||
|
||||
// Remove invalid characters
|
||||
$url = (string) preg_replace( '/[^\PC\s]/u', '', $url );
|
||||
|
||||
// Make sure spaces are quoted
|
||||
$url = str_replace( ' ', '%20', $url );
|
||||
$url = str_replace( '%24', '$', $url );
|
||||
|
||||
$this->url = $url;
|
||||
$this->is_regex = $is_regex;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL encode some things, but other things can be passed through
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_as_target() {
|
||||
$allowed = [
|
||||
'%2F' => '/',
|
||||
'%3F' => '?',
|
||||
'%3A' => ':',
|
||||
'%3D' => '=',
|
||||
'%26' => '&',
|
||||
'%25' => '%',
|
||||
'+' => '%20',
|
||||
'%24' => '$',
|
||||
'%23' => '#',
|
||||
];
|
||||
|
||||
$url = rawurlencode( $this->url );
|
||||
$url = $this->replace_encoding( $url, $allowed );
|
||||
|
||||
return $this->encode_regex( $url );
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a URL
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_as_source() {
|
||||
$allowed = [
|
||||
'%2F' => '/',
|
||||
'%3F' => '?',
|
||||
'+' => '%20',
|
||||
'.' => '\\.',
|
||||
];
|
||||
|
||||
$url = $this->replace_encoding( rawurlencode( $this->url ), $allowed );
|
||||
return $this->encode_regex( $url );
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Replace encoded characters in a URL
|
||||
*
|
||||
* @param string $str Source string.
|
||||
* @param EncodingMap $allowed Map of encoded characters to their replacements.
|
||||
* @return string
|
||||
*/
|
||||
private function replace_encoding( $str, $allowed ) {
|
||||
foreach ( $allowed as $before => $after ) {
|
||||
$str = str_replace( $before, $after, $str );
|
||||
}
|
||||
|
||||
return $str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a regex URL
|
||||
*
|
||||
* @param string $url URL.
|
||||
* @return string
|
||||
*/
|
||||
private function encode_regex( $url ) {
|
||||
if ( $this->is_regex ) {
|
||||
// No leading slash
|
||||
$url = ltrim( $url, '/' );
|
||||
|
||||
// If pattern has a ^ at the start then ensure we don't have a slash immediatley after
|
||||
$url = (string) preg_replace( '@^\^/@', '^', $url );
|
||||
|
||||
$url = $this->replace_encoding(
|
||||
$url,
|
||||
[
|
||||
'%2A' => '*',
|
||||
'%3F' => '?',
|
||||
'%28' => '(',
|
||||
'%29' => ')',
|
||||
'%5B' => '[',
|
||||
'%5C' => ']',
|
||||
'%24' => '$',
|
||||
'%2B' => '+',
|
||||
'%7C' => '|',
|
||||
'\\.' => '.',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Represent URL source flags.
|
||||
*
|
||||
* @phpstan-type FlagName 'flag_query'|'flag_case'|'flag_trailing'|'flag_regex'
|
||||
* @phpstan-type QueryType 'ignore'|'exact'|'pass'|'exactorder'
|
||||
* @phpstan-type FlagsJson array{
|
||||
* flag_query: QueryType,
|
||||
* flag_case: bool,
|
||||
* flag_trailing: bool,
|
||||
* flag_regex: bool
|
||||
* }
|
||||
* @phpstan-import-type RedirectionOptions from Red_Options
|
||||
*/
|
||||
class Red_Source_Flags {
|
||||
const QUERY_IGNORE = 'ignore';
|
||||
const QUERY_EXACT = 'exact';
|
||||
const QUERY_PASS = 'pass';
|
||||
const QUERY_EXACT_ORDER = 'exactorder';
|
||||
|
||||
const FLAG_QUERY = 'flag_query';
|
||||
const FLAG_CASE = 'flag_case';
|
||||
const FLAG_TRAILING = 'flag_trailing';
|
||||
const FLAG_REGEX = 'flag_regex';
|
||||
|
||||
/**
|
||||
* Case insensitive matching
|
||||
*
|
||||
* @var boolean
|
||||
*/
|
||||
private $flag_case = false;
|
||||
|
||||
/**
|
||||
* Ignored trailing slashes
|
||||
*
|
||||
* @var boolean
|
||||
*/
|
||||
private $flag_trailing = false;
|
||||
|
||||
/**
|
||||
* Regular expression
|
||||
*
|
||||
* @var boolean
|
||||
*/
|
||||
private $flag_regex = false;
|
||||
|
||||
/**
|
||||
* Query parameter matching
|
||||
*
|
||||
* @var self::QUERY_EXACT|self::QUERY_IGNORE|self::QUERY_PASS|self::QUERY_EXACT_ORDER
|
||||
*/
|
||||
private $flag_query = self::QUERY_EXACT;
|
||||
|
||||
/**
|
||||
* Values that have been set (tracks which flag keys were provided)
|
||||
*
|
||||
* @var array<FlagName>
|
||||
*/
|
||||
private $values_set = [];
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param array<string, mixed>|null $json JSON object.
|
||||
*/
|
||||
public function __construct( $json = null ) {
|
||||
if ( $json !== null ) {
|
||||
$this->set_flags( $json );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of valid query types as an array
|
||||
*
|
||||
* @return array<QueryType>
|
||||
*/
|
||||
private function get_allowed_query() {
|
||||
return [
|
||||
self::QUERY_IGNORE,
|
||||
self::QUERY_EXACT,
|
||||
self::QUERY_PASS,
|
||||
self::QUERY_EXACT_ORDER,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse flag data.
|
||||
*
|
||||
* @param array<string, mixed> $json Flag data.
|
||||
* @return void
|
||||
*/
|
||||
public function set_flags( array $json ) {
|
||||
if ( isset( $json[ self::FLAG_QUERY ] ) && in_array( $json[ self::FLAG_QUERY ], $this->get_allowed_query(), true ) ) {
|
||||
$this->flag_query = $json[ self::FLAG_QUERY ];
|
||||
}
|
||||
|
||||
if ( isset( $json[ self::FLAG_CASE ] ) && is_bool( $json[ self::FLAG_CASE ] ) ) {
|
||||
$this->flag_case = $json[ self::FLAG_CASE ] ? true : false;
|
||||
}
|
||||
|
||||
if ( isset( $json[ self::FLAG_TRAILING ] ) && is_bool( $json[ self::FLAG_TRAILING ] ) ) {
|
||||
$this->flag_trailing = $json[ self::FLAG_TRAILING ] ? true : false;
|
||||
}
|
||||
|
||||
if ( isset( $json[ self::FLAG_REGEX ] ) && is_bool( $json[ self::FLAG_REGEX ] ) ) {
|
||||
$this->flag_regex = $json[ self::FLAG_REGEX ] ? true : false;
|
||||
|
||||
if ( $this->flag_regex ) {
|
||||
// Regex auto-disables other things
|
||||
$this->flag_query = self::QUERY_EXACT;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep track of what values have been set, so we know what to override with defaults later
|
||||
/** @var array<FlagName> $intersected */
|
||||
$intersected = array_values( array_intersect( array_keys( $json ), array_keys( $this->get_json() ) ) );
|
||||
$this->values_set = $intersected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return `true` if ignore trailing slash, `false` otherwise
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
public function is_ignore_trailing() {
|
||||
return $this->flag_trailing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return `true` if ignore case, `false` otherwise
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
public function is_ignore_case() {
|
||||
return $this->flag_case;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return `true` if ignore trailing slash, `false` otherwise
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
public function is_regex() {
|
||||
return $this->flag_regex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return `true` if exact query match, `false` otherwise
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
public function is_query_exact() {
|
||||
return $this->flag_query === self::QUERY_EXACT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return `true` if exact query match in set order, `false` otherwise
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
public function is_query_exact_order() {
|
||||
return $this->flag_query === self::QUERY_EXACT_ORDER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return `true` if ignore query params, `false` otherwise
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
public function is_query_ignore() {
|
||||
return $this->flag_query === self::QUERY_IGNORE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return `true` if ignore and pass query params, `false` otherwise
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
public function is_query_pass() {
|
||||
return $this->flag_query === self::QUERY_PASS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the flags as a JSON object
|
||||
*
|
||||
* @return FlagsJson
|
||||
*/
|
||||
public function get_json() {
|
||||
return [
|
||||
self::FLAG_QUERY => $this->flag_query,
|
||||
self::FLAG_CASE => $this->is_ignore_case(),
|
||||
self::FLAG_TRAILING => $this->is_ignore_trailing(),
|
||||
self::FLAG_REGEX => $this->is_regex(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return flag data, with defaults removed from the data.
|
||||
*
|
||||
* @param RedirectionOptions $defaults Defaults to remove.
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function get_json_without_defaults( $defaults ) {
|
||||
$json = $this->get_json();
|
||||
|
||||
// @phpstan-ignore greater.alwaysTrue
|
||||
if ( count( $defaults ) > 0 ) {
|
||||
foreach ( $json as $key => $value ) {
|
||||
// @phpstan-ignore isset.offset
|
||||
if ( isset( $defaults[ $key ] ) && $value === $defaults[ $key ] ) {
|
||||
unset( $json[ $key ] );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return flag data, with defaults filling in any gaps not set.
|
||||
*
|
||||
* @return FlagsJson
|
||||
*/
|
||||
public function get_json_with_defaults() {
|
||||
$settings = Red_Options::get();
|
||||
$json = $this->get_json();
|
||||
$defaults = [
|
||||
self::FLAG_QUERY => $settings[ self::FLAG_QUERY ],
|
||||
self::FLAG_CASE => $settings[ self::FLAG_CASE ],
|
||||
self::FLAG_TRAILING => $settings[ self::FLAG_TRAILING ],
|
||||
self::FLAG_REGEX => $settings[ self::FLAG_REGEX ],
|
||||
];
|
||||
|
||||
foreach ( $this->values_set as $key ) {
|
||||
// @phpstan-ignore isset.offset
|
||||
if ( ! isset( $json[ $key ] ) ) {
|
||||
$json[ $key ] = $defaults[ $key ];
|
||||
}
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Get a URL suitable for matching in the database
|
||||
*/
|
||||
class Red_Url_Match {
|
||||
/**
|
||||
* URL
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $url;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param string $url The URL to match.
|
||||
*/
|
||||
public function __construct( $url ) {
|
||||
$this->url = $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the plain 'matched' URL:
|
||||
*
|
||||
* - Lowercase
|
||||
* - No trailing slashes
|
||||
*
|
||||
* @return string URL
|
||||
*/
|
||||
public function get_url() {
|
||||
// Remove query params, and decode any encoded characters
|
||||
$url = new Red_Url_Path( $this->url );
|
||||
$path = $url->get_without_trailing_slash();
|
||||
|
||||
// URL encode
|
||||
$decode = [
|
||||
'/',
|
||||
':',
|
||||
'[',
|
||||
']',
|
||||
'@',
|
||||
'~',
|
||||
',',
|
||||
'(',
|
||||
')',
|
||||
';',
|
||||
];
|
||||
|
||||
// URL encode everything - this converts any i10n to the proper encoding
|
||||
$path = rawurlencode( $path );
|
||||
|
||||
// We also converted things we dont want encoding, such as a /. Change these back
|
||||
foreach ( $decode as $char ) {
|
||||
$path = str_replace( rawurlencode( $char ), $char, $path );
|
||||
}
|
||||
|
||||
// Lowercase everything
|
||||
$path = Red_Url_Path::to_lower( $path );
|
||||
|
||||
return $path === '' ? '/' : $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL with parameters re-ordered into alphabetical order
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_url_with_params() {
|
||||
$query = new Red_Url_Query( $this->url, new Red_Source_Flags( [ Red_Source_Flags::FLAG_CASE => true ] ) );
|
||||
|
||||
return $query->get_url_with_query( $this->get_url() );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* The path part of a URL
|
||||
*/
|
||||
class Red_Url_Path {
|
||||
/**
|
||||
* URL path
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $path;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param string $path URL.
|
||||
*/
|
||||
public function __construct( $path ) {
|
||||
$this->path = $this->get_path_component( $path );
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the supplied `url` a match for this object?
|
||||
*
|
||||
* @param string $url URL to match against.
|
||||
* @param Red_Source_Flags $flags Source flags to use in match.
|
||||
* @return boolean
|
||||
*/
|
||||
public function is_match( $url, Red_Source_Flags $flags ) {
|
||||
$target = new Red_Url_Path( $url );
|
||||
|
||||
$target_path = $target->get();
|
||||
$source_path = $this->get();
|
||||
|
||||
if ( $flags->is_ignore_trailing() ) {
|
||||
// Ignore trailing slashes
|
||||
$source_path = $this->get_without_trailing_slash();
|
||||
$target_path = $target->get_without_trailing_slash();
|
||||
}
|
||||
|
||||
if ( $flags->is_ignore_case() ) {
|
||||
// Case insensitive match
|
||||
$source_path = self::to_lower( $source_path );
|
||||
$target_path = self::to_lower( $target_path );
|
||||
}
|
||||
|
||||
return $target_path === $source_path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a URL to lowercase
|
||||
*
|
||||
* @param string $url URL.
|
||||
* @return string
|
||||
*/
|
||||
public static function to_lower( $url ) {
|
||||
if ( function_exists( 'mb_strtolower' ) ) {
|
||||
return mb_strtolower( $url, 'UTF-8' );
|
||||
}
|
||||
|
||||
return strtolower( $url );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path value
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get() {
|
||||
return $this->path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path value without trailing slash, or `/` if home
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_without_trailing_slash() {
|
||||
// Return / or // as-is
|
||||
if ( $this->path === '/' ) {
|
||||
return $this->path;
|
||||
}
|
||||
|
||||
// Anything else remove the last /
|
||||
return (string) preg_replace( '@/$@', '', $this->get() );
|
||||
}
|
||||
|
||||
/**
|
||||
* `parse_url` doesn't handle 'incorrect' URLs, such as those with double slashes
|
||||
* These are often used in redirects, so we fall back to our own parsing
|
||||
*
|
||||
* @param string $url URL.
|
||||
* @return string
|
||||
*/
|
||||
private function get_path_component( $url ) {
|
||||
$path = $url;
|
||||
|
||||
if ( preg_match( '@^https?://@', $url, $matches ) > 0 ) {
|
||||
$parts = explode( '://', $url );
|
||||
|
||||
if ( count( $parts ) > 1 ) {
|
||||
$rest = explode( '/', $parts[1] );
|
||||
$path = '/' . implode( '/', array_slice( $rest, 1 ) );
|
||||
}
|
||||
}
|
||||
|
||||
return urldecode( $this->get_query_before( $path ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path component up to the query string
|
||||
*
|
||||
* @param string $url URL.
|
||||
* @return string
|
||||
*/
|
||||
private function get_query_before( $url ) {
|
||||
$qpos = strpos( $url, '?' );
|
||||
$qrpos = strpos( $url, '\\?' );
|
||||
|
||||
// Have we found an escaped query and it occurs before a normal query?
|
||||
if ( $qrpos !== false && $qrpos < $qpos ) {
|
||||
// Yes, the path is everything up to the escaped query
|
||||
return substr( $url, 0, $qrpos );
|
||||
}
|
||||
|
||||
// No query - return everything as path
|
||||
if ( $qpos === false ) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
// Query found - return everything up to it
|
||||
return substr( $url, 0, $qpos );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Query parameter matching
|
||||
*
|
||||
* @phpstan-type QueryParams array<string, string|null|array<string, mixed>>
|
||||
* @phpstan-type ParsedParam array{
|
||||
* name: string,
|
||||
* value: string|null,
|
||||
* parse_str: string
|
||||
* }
|
||||
*/
|
||||
class Red_Url_Query {
|
||||
/**
|
||||
* @var integer
|
||||
*/
|
||||
const RECURSION_LIMIT = 10;
|
||||
|
||||
/**
|
||||
* Original query parameters (used when passing)
|
||||
*
|
||||
* @var QueryParams
|
||||
*/
|
||||
private $original_query = [];
|
||||
|
||||
/**
|
||||
* Match query parameters (used only for matching, and may be lowercased)
|
||||
*
|
||||
* @var QueryParams
|
||||
*/
|
||||
private $match_query = [];
|
||||
|
||||
/**
|
||||
* Is this an exact match?
|
||||
*
|
||||
* @var false|string
|
||||
*/
|
||||
private $match_exact = false;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param string $url URL.
|
||||
* @param Red_Source_Flags $flags URL flags.
|
||||
*/
|
||||
public function __construct( $url, $flags ) {
|
||||
$this->original_query = $this->get_url_query( $url );
|
||||
$this->match_query = $this->original_query;
|
||||
|
||||
if ( $flags->is_ignore_case() ) {
|
||||
$this->match_query = $this->get_url_query( Red_Url_Path::to_lower( $url ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this object match the URL?
|
||||
*
|
||||
* @param string $url URL to match.
|
||||
* @param Red_Source_Flags $flags Source flags.
|
||||
* @return boolean
|
||||
*/
|
||||
public function is_match( $url, Red_Source_Flags $flags ) {
|
||||
if ( $flags->is_ignore_case() ) {
|
||||
$url = Red_Url_Path::to_lower( $url );
|
||||
}
|
||||
|
||||
// If we can't parse the query params then match the params exactly
|
||||
if ( $this->match_exact !== false ) {
|
||||
return $this->is_string_match( $this->get_query_after( $url ), $this->match_exact, $flags->is_ignore_case() );
|
||||
}
|
||||
|
||||
$target = $this->get_url_query( $url );
|
||||
|
||||
// All params in the source have to exist in the request, but in any order
|
||||
$matched = $this->get_query_same( $this->match_query, $target, $flags->is_ignore_case() );
|
||||
|
||||
if ( count( $matched ) !== count( $this->match_query ) ) {
|
||||
// Source params arent matched exactly
|
||||
return false;
|
||||
};
|
||||
|
||||
// Get list of whatever is left over
|
||||
$query_diff = $this->get_query_diff( $this->match_query, $target );
|
||||
$query_diff = array_merge( $query_diff, $this->get_query_diff( $target, $this->match_query ) );
|
||||
|
||||
if ( $flags->is_query_ignore() || $flags->is_query_pass() ) {
|
||||
return true; // This ignores all other query params
|
||||
}
|
||||
|
||||
// In an exact match there shouldn't be any more params
|
||||
return count( $query_diff ) === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the two strings match, false otherwise. Pays attention to case sensitivity
|
||||
*
|
||||
* @param string $first First string.
|
||||
* @param string $second Second string.
|
||||
* @param boolean $case Case sensitivity.
|
||||
* @return boolean
|
||||
*/
|
||||
private function is_string_match( $first, $second, $case ) {
|
||||
if ( $case ) {
|
||||
return Red_Url_Path::to_lower( $first ) === Red_Url_Path::to_lower( $second );
|
||||
}
|
||||
|
||||
return $first === $second;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass query params from one URL to another URL, ignoring any params that already exist on the target.
|
||||
*
|
||||
* @param string $target_url The target URL to add params to.
|
||||
* @param string $requested_url The source URL to pass params from.
|
||||
* @param Red_Source_Flags $flags Any URL flags.
|
||||
* @return string URL, modified or not.
|
||||
*/
|
||||
public static function add_to_target( $target_url, $requested_url, Red_Source_Flags $flags ) {
|
||||
if ( $flags->is_query_pass() && $target_url !== '' ) {
|
||||
$source_query = new Red_Url_Query( $target_url, $flags );
|
||||
$request_query = new Red_Url_Query( $requested_url, $flags );
|
||||
|
||||
// Now add any remaining params
|
||||
$query_diff = $source_query->get_query_diff( $source_query->original_query, $request_query->original_query );
|
||||
$request_diff = $request_query->get_query_diff( $request_query->original_query, $source_query->original_query );
|
||||
|
||||
foreach ( $request_diff as $key => $value ) {
|
||||
$query_diff[ $key ] = $value;
|
||||
}
|
||||
|
||||
// Remove any params from $source that are present in $request - we dont allow
|
||||
// predefined params to be overridden
|
||||
foreach ( array_keys( $query_diff ) as $key ) {
|
||||
if ( isset( $source_query->original_query[ $key ] ) ) {
|
||||
unset( $query_diff[ $key ] );
|
||||
}
|
||||
}
|
||||
|
||||
return self::build_url( $target_url, $query_diff );
|
||||
}
|
||||
|
||||
return $target_url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a URL from a base and query parameters
|
||||
*
|
||||
* @param string $url Base URL.
|
||||
* @param QueryParams $query_array Query parameters.
|
||||
* @return string
|
||||
*/
|
||||
public static function build_url( $url, $query_array ) {
|
||||
$query = http_build_query(
|
||||
array_map(
|
||||
function ( $value ) {
|
||||
if ( $value === null ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $value;
|
||||
},
|
||||
$query_array
|
||||
)
|
||||
);
|
||||
|
||||
$query = (string) preg_replace( '@%5B\d*%5D@', '[]', $query ); // Make these look like []
|
||||
|
||||
foreach ( $query_array as $key => $value ) {
|
||||
if ( $value === null ) {
|
||||
$search = str_replace( '%20', '+', rawurlencode( $key ) . '=' );
|
||||
$replace = str_replace( '%20', '+', rawurlencode( $key ) );
|
||||
|
||||
$query = str_replace( $search, $replace, $query );
|
||||
}
|
||||
}
|
||||
|
||||
$query = str_replace( '%252B', '+', $query );
|
||||
|
||||
if ( $query !== '' ) {
|
||||
// Get any fragment
|
||||
$target_fragment = wp_parse_url( $url, PHP_URL_FRAGMENT );
|
||||
|
||||
// If we have a fragment we need to ensure it comes after the query parameters, not before
|
||||
if ( is_string( $target_fragment ) ) {
|
||||
// Remove fragment
|
||||
$url = str_replace( '#' . $target_fragment, '', $url );
|
||||
|
||||
// Add to the end of the query
|
||||
$query .= '#' . $target_fragment;
|
||||
}
|
||||
|
||||
return $url . ( strpos( $url, '?' ) === false ? '?' : '&' ) . $query;
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a URL with the given base and query parameters from this Url_Query
|
||||
*
|
||||
* @param string $url Base URL.
|
||||
* @return string
|
||||
*/
|
||||
public function get_url_with_query( $url ) {
|
||||
return self::build_url( $url, $this->original_query );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the query parameters
|
||||
*
|
||||
* @return QueryParams
|
||||
*/
|
||||
public function get() {
|
||||
return $this->original_query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if query parameters match exactly
|
||||
*
|
||||
* @param string $url URL.
|
||||
* @param QueryParams $params Query parameters.
|
||||
* @return bool
|
||||
*/
|
||||
private function is_exact_match( $url, $params ) {
|
||||
// No parsed query params but we have query params on the URL - some parsing error with wp_parse_str
|
||||
if ( count( $params ) === 0 && $this->has_query_params( $url ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get query parameters from a URL
|
||||
*
|
||||
* @param string $url URL.
|
||||
* @return QueryParams
|
||||
*/
|
||||
private function get_url_query( $url ) {
|
||||
$params = [];
|
||||
$query = $this->get_query_after( $url );
|
||||
$internal = $this->parse_str( $query );
|
||||
|
||||
wp_parse_str( $query ? $query : '', $params );
|
||||
|
||||
// For exactness and due to the way parse_str works we go through and check any query param without a value
|
||||
foreach ( $params as $key => $value ) {
|
||||
if ( is_string( $value ) && strlen( $value ) === 0 && strpos( $url, $key . '=' ) === false ) {
|
||||
$params[ (string) $key ] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// A work-around until we replace parse_str with internal function
|
||||
foreach ( $internal as $pos => $internal_param ) {
|
||||
if ( $internal_param['parse_str'] !== $internal_param['name'] ) {
|
||||
foreach ( $params as $key => $value ) {
|
||||
if ( $key === $internal_param['parse_str'] ) {
|
||||
unset( $params[ $key ] );
|
||||
unset( $internal[ $pos ] );
|
||||
$params[ $internal_param['name'] ] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @var QueryParams $params */
|
||||
if ( $this->is_exact_match( $url, $params ) ) {
|
||||
$this->match_exact = $query;
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* A replacement for parse_str, which behaves oddly in some situations (spaces and no param value)
|
||||
*
|
||||
* TODO: use this in preference to parse_str
|
||||
*
|
||||
* @param string $query Query.
|
||||
* @return array<ParsedParam>
|
||||
*/
|
||||
private function parse_str( $query ) {
|
||||
$params = [];
|
||||
|
||||
if ( strlen( $query ) === 0 ) {
|
||||
return $params;
|
||||
}
|
||||
|
||||
$parts = explode( '&', $query ? $query : '' );
|
||||
|
||||
foreach ( $parts as $part ) {
|
||||
$param = explode( '=', $part );
|
||||
$parse_str = [];
|
||||
|
||||
wp_parse_str( $part, $parse_str );
|
||||
|
||||
$params[] = [
|
||||
'name' => str_replace( [ '[', ']', '%5B', '%5D' ], '', str_replace( '+', ' ', $param[0] ) ),
|
||||
'value' => isset( $param[1] ) ? str_replace( '+', ' ', $param[1] ) : null,
|
||||
'parse_str' => implode( '', array_keys( $parse_str ) ),
|
||||
];
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the URL contain query parameters?
|
||||
*
|
||||
* @param string $url URL.
|
||||
* @return boolean
|
||||
*/
|
||||
public function has_query_params( $url ) {
|
||||
$qpos = strpos( $url, '?' );
|
||||
|
||||
if ( $qpos === false ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parameters after the ?
|
||||
*
|
||||
* @param string $url URL.
|
||||
* @return string
|
||||
*/
|
||||
public function get_query_after( $url ) {
|
||||
$qpos = strpos( $url, '?' );
|
||||
$qrpos = strpos( $url, '\\?' );
|
||||
|
||||
// No ? anywhere - no query
|
||||
if ( $qpos === false ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Found an escaped ? and it comes before the non-escaped ?
|
||||
if ( $qrpos !== false && $qrpos < $qpos ) {
|
||||
return substr( $url, $qrpos + 2 );
|
||||
}
|
||||
|
||||
// Standard query param
|
||||
return substr( $url, $qpos + 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a lowercase key mapping for case-insensitive matching
|
||||
*
|
||||
* @param QueryParams $query Query parameters.
|
||||
* @return array<string, string> Map of lowercase keys to original keys.
|
||||
*/
|
||||
private function get_query_case( array $query ) {
|
||||
$keys = [];
|
||||
foreach ( array_keys( $query ) as $key ) {
|
||||
$keys[ Red_Url_Path::to_lower( $key ) ] = $key;
|
||||
}
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get query parameters that are the same in both query arrays
|
||||
*
|
||||
* @param QueryParams $source_query Source query params.
|
||||
* @param QueryParams $target_query Target query params.
|
||||
* @param bool $is_ignore_case Ignore case.
|
||||
* @param int $depth Current recursion depth.
|
||||
* @return QueryParams
|
||||
*/
|
||||
public function get_query_same( array $source_query, array $target_query, $is_ignore_case, $depth = 0 ) {
|
||||
if ( $depth > self::RECURSION_LIMIT ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$source_keys = $this->get_query_case( $source_query );
|
||||
$target_keys = $this->get_query_case( $target_query );
|
||||
|
||||
$same = [];
|
||||
foreach ( $source_keys as $key => $original_key ) {
|
||||
// Does the key exist in the target
|
||||
if ( isset( $target_keys[ $key ] ) ) {
|
||||
// Key exists. Now match the value
|
||||
$source_value = $source_query[ $original_key ];
|
||||
$target_value = $target_query[ $target_keys[ $key ] ];
|
||||
$add = false;
|
||||
|
||||
if ( is_array( $source_value ) && is_array( $target_value ) ) {
|
||||
$add = $this->get_query_same( $source_value, $target_value, $is_ignore_case, $depth + 1 );
|
||||
|
||||
if ( count( $add ) !== count( $source_value ) ) {
|
||||
$add = false;
|
||||
}
|
||||
} elseif ( is_string( $source_value ) && is_string( $target_value ) ) {
|
||||
$add = $this->is_string_match( $source_value, $target_value, $is_ignore_case ) ? $source_value : false;
|
||||
} elseif ( $source_value === null && $target_value === null ) {
|
||||
$add = null;
|
||||
}
|
||||
|
||||
if ( ! empty( $add ) || is_numeric( $add ) || $add === '' || $add === null ) {
|
||||
$same[ $original_key ] = $add;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $same;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the difference in query parameters
|
||||
*
|
||||
* @param QueryParams $source_query Source query params.
|
||||
* @param QueryParams $target_query Target query params.
|
||||
* @param int $depth Current recursion depth.
|
||||
* @return QueryParams
|
||||
*/
|
||||
public function get_query_diff( array $source_query, array $target_query, $depth = 0 ) {
|
||||
if ( $depth > self::RECURSION_LIMIT ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$diff = [];
|
||||
foreach ( $source_query as $key => $value ) {
|
||||
if ( array_key_exists( $key, $target_query ) && is_array( $value ) && is_array( $target_query[ $key ] ) ) {
|
||||
/** @var QueryParams $child_source */
|
||||
$child_source = $source_query[ $key ];
|
||||
/** @var QueryParams $child_target */
|
||||
$child_target = $target_query[ $key ];
|
||||
$add = $this->get_query_diff( $child_source, $child_target, $depth + 1 );
|
||||
|
||||
if ( ! empty( $add ) ) {
|
||||
$diff[ $key ] = $add;
|
||||
}
|
||||
} elseif ( ! array_key_exists( $key, $target_query ) || ! $this->is_value( $value ) || ! $this->is_value( $target_query[ $key ] ) || $target_query[ $key ] !== $source_query[ $key ] ) {
|
||||
$diff[ $key ] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $diff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value is a simple query value (string or null)
|
||||
*
|
||||
* @param mixed $value Value to check.
|
||||
* @return bool
|
||||
*/
|
||||
private function is_value( $value ) {
|
||||
return is_string( $value ) || $value === null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Decode request URLs
|
||||
*/
|
||||
class Red_Url_Request {
|
||||
/**
|
||||
* Original URL
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $original_url;
|
||||
|
||||
/**
|
||||
* Decoded URL
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $decoded_url;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param string $url URL.
|
||||
*/
|
||||
public function __construct( $url ) {
|
||||
$this->original_url = apply_filters( 'redirection_url_source', $url );
|
||||
$this->decoded_url = rawurldecode( $this->original_url );
|
||||
|
||||
// Replace the decoded query params with the original ones
|
||||
$this->original_url = $this->replace_query_params( $this->original_url, $this->decoded_url );
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the decoded path part, but keep the original query params. This ensures any redirects keep the encoding.
|
||||
*
|
||||
* @param string $original_url Original unencoded URL.
|
||||
* @param string $decoded_url Decoded URL.
|
||||
* @return string
|
||||
*/
|
||||
private function replace_query_params( $original_url, $decoded_url ) {
|
||||
$decoded = explode( '?', $decoded_url );
|
||||
|
||||
if ( count( $decoded ) > 1 ) {
|
||||
$original = explode( '?', $original_url );
|
||||
|
||||
if ( count( $original ) > 1 ) {
|
||||
return $decoded[0] . '?' . $original[1];
|
||||
}
|
||||
}
|
||||
|
||||
return $decoded_url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original URL
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_original_url() {
|
||||
return $this->original_url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the decoded URL
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_decoded_url() {
|
||||
return $this->decoded_url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this a valid URL?
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
public function is_valid() {
|
||||
return strlen( $this->get_decoded_url() ) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Protect certain URLs from being redirected. Note we don't need to protect wp-admin, as this code doesn't run there
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
public function is_protected_url() {
|
||||
$rest = wp_parse_url( red_get_rest_api() );
|
||||
|
||||
if ( ! is_array( $rest ) || ! isset( $rest['path'] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$rest_api = $rest['path'] . ( isset( $rest['query'] ) ? '?' . $rest['query'] : '' );
|
||||
|
||||
if ( substr( $this->get_decoded_url(), 0, strlen( $rest_api ) ) === $rest_api ) {
|
||||
// Never redirect the REST API
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Transform URL shortcodes
|
||||
*
|
||||
* @phpstan-type ShortcodeAttrs array<string, string>|string
|
||||
*/
|
||||
class Red_Url_Transform {
|
||||
/**
|
||||
* Replace special tags in the target URL.
|
||||
*
|
||||
* From the distant Redirection past. Undecided whether to keep
|
||||
*
|
||||
* @param string $url Target URL.
|
||||
* @return string
|
||||
*/
|
||||
public function transform( $url ) {
|
||||
// Deprecated number post ID
|
||||
if ( is_numeric( $url ) ) {
|
||||
$permalink = get_permalink( intval( $url, 10 ) );
|
||||
|
||||
if ( $permalink !== false ) {
|
||||
return $permalink;
|
||||
}
|
||||
}
|
||||
|
||||
global $shortcode_tags;
|
||||
|
||||
$shortcode_copy = array_merge( [], $shortcode_tags );
|
||||
|
||||
remove_all_shortcodes();
|
||||
|
||||
$shortcodes = apply_filters(
|
||||
'redirection_shortcodes',
|
||||
[
|
||||
'userid',
|
||||
'userlogin',
|
||||
'unixtime', // Also replaces $dec$
|
||||
|
||||
// These require content
|
||||
'md5',
|
||||
'upper',
|
||||
'lower',
|
||||
'dashes',
|
||||
'underscores',
|
||||
]
|
||||
);
|
||||
|
||||
foreach ( $shortcodes as $code ) {
|
||||
add_shortcode( $code, [ $this, 'do_shortcode' ] );
|
||||
}
|
||||
|
||||
// Support deprecated tags
|
||||
$url = $this->transform_deprecated( $url );
|
||||
$url = do_shortcode( $url );
|
||||
|
||||
// Restore shortcodes
|
||||
// phpcs:ignore
|
||||
$shortcode_tags = array_merge( [], $shortcode_copy );
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Peform a shortcode
|
||||
*
|
||||
* @param ShortcodeAttrs $attrs Shortcode attributes.
|
||||
* @param string|null $content Shortcode content.
|
||||
* @param string $tag Shortcode tag.
|
||||
* @return string
|
||||
*/
|
||||
public function do_shortcode( $attrs, $content, $tag ) {
|
||||
$content = $content ?? '';
|
||||
$user = wp_get_current_user();
|
||||
|
||||
switch ( $tag ) {
|
||||
case 'userid':
|
||||
return (string) $user->ID;
|
||||
|
||||
case 'userlogin':
|
||||
return $user->ID > 0 ? $user->user_login : '';
|
||||
|
||||
case 'unixtime':
|
||||
return (string) time();
|
||||
|
||||
case 'md5':
|
||||
return md5( $content );
|
||||
|
||||
case 'upper':
|
||||
return strtoupper( $content );
|
||||
|
||||
case 'lower':
|
||||
return strtolower( $content );
|
||||
|
||||
case 'dashes':
|
||||
return str_replace( [ '_', ' ' ], '-', $content );
|
||||
|
||||
case 'underscores':
|
||||
return str_replace( [ '-', ' ' ], '_', $content );
|
||||
}
|
||||
|
||||
return apply_filters( 'redirection_url_transform', '', $tag, $attrs, $content );
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert deprecated inline tags to shortcodes.
|
||||
*
|
||||
* @param string $url URL.
|
||||
* @return string
|
||||
*/
|
||||
private function transform_deprecated( $url ) {
|
||||
$url = str_replace( '%userid%', '[userid]', $url );
|
||||
$url = str_replace( '%userlogin%', '[userlogin]', $url );
|
||||
$url = str_replace( '%userurl%', '[userurl]', $url );
|
||||
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/url-query.php';
|
||||
require_once __DIR__ . '/url-path.php';
|
||||
require_once __DIR__ . '/url-match.php';
|
||||
require_once __DIR__ . '/url-flags.php';
|
||||
require_once __DIR__ . '/url-request.php';
|
||||
require_once __DIR__ . '/url-transform.php';
|
||||
require_once __DIR__ . '/url-encode.php';
|
||||
|
||||
class Red_Url {
|
||||
/**
|
||||
* URL
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $url;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param string $url URL.
|
||||
*/
|
||||
public function __construct( $url = '' ) {
|
||||
$this->url = $url;
|
||||
$this->url = str_replace( ' ', '%20', $this->url ); // deprecated
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw URL
|
||||
*
|
||||
* @return string URL
|
||||
*/
|
||||
public function get_url() {
|
||||
return $this->url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a target URL against the current URL, using any match flags
|
||||
*
|
||||
* @param string $requested_url Target URL.
|
||||
* @param Red_Source_Flags $flags Match flags.
|
||||
* @return boolean
|
||||
*/
|
||||
public function is_match( $requested_url, Red_Source_Flags $flags ) {
|
||||
if ( $flags->is_regex() ) {
|
||||
$regex = new Red_Regex( $this->url, $flags->is_ignore_case() );
|
||||
|
||||
return $regex->is_match( $requested_url );
|
||||
}
|
||||
|
||||
$path = new Red_Url_Path( $this->url );
|
||||
$query = new Red_Url_Query( $this->url, $flags );
|
||||
|
||||
return $path->is_match( $requested_url, $flags ) && $query->is_match( $requested_url, $flags );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user