This commit is contained in:
2026-07-02 15:54:39 -06:00
commit 9883323161
17470 changed files with 4470592 additions and 0 deletions
@@ -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( '&amp;', '&', $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( '?&lt;!', '?<!', $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 );
}
}