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,61 @@
<?php
/**
* Return an error to the client, and trigger the WordPress error page
*/
class Error_Action extends Red_Action {
/**
* Set WordPress to show the error page
*
* @return void
*/
public function run() {
wp_reset_query();
// Set the query to be a 404
set_query_var( 'is_404', true );
// Return the 404 page
add_filter( 'template_include', [ $this, 'template_include' ] );
// Clear any posts if this is actually a valid URL
add_filter( 'pre_handle_404', [ $this, 'pre_handle_404' ] );
// Ensure the appropriate http code is returned
add_action( 'wp', [ $this, 'wp' ] );
}
/**
* Output selected HTTP code, as well as redirection header
*
* @return void
*/
public function wp() {
status_header( $this->code );
nocache_headers();
global $wp_version;
if ( version_compare( $wp_version, '5.1', '<' ) ) {
header( 'X-Redirect-Agent: redirection' );
} else {
header( 'X-Redirect-By: redirection' );
}
}
public function pre_handle_404() {
global $wp_query;
// Page comments plugin interferes with this
$wp_query->posts = [];
return false;
}
public function template_include() {
return get_404_template();
}
public function name() {
return __( 'Error (404)', 'redirection' );
}
}
@@ -0,0 +1,19 @@
<?php
/**
* The 'do nothing' action. This really does nothing, and is used to short-circuit Redirection so that it doesn't trigger other redirects.
*/
class Nothing_Action extends Red_Action {
/**
* Issue an action when nothing happens. This stops further processing.
*
* @return void
*/
public function run() {
do_action( 'redirection_do_nothing', $this->get_target() );
}
public function name() {
return __( 'Do nothing (ignore)', 'redirection' );
}
}
@@ -0,0 +1,77 @@
<?php
require_once __DIR__ . '/url.php';
/**
* A 'pass through' action. Matches a rewrite rather than a redirect, and uses PHP to fetch data from a remote URL.
*/
class Pass_Action extends Url_Action {
/**
* Process an external passthrough - a URL that lives external to this server.
*
* @param string $url Target URL.
* @return void
*/
public function process_external( $url ) {
// This is entirely at the user's risk. The $url is set by the user
// phpcs:ignore
echo wp_remote_fopen( $url );
}
/**
* Process an internal passthrough - a URL that lives on the same server. Here we change the request URI and continue without making a remote request.
*
* @param string $target Target URL.
* @return void
*/
public function process_internal( $target ) {
// Another URL on the server
$pos = strpos( $target, '?' );
$_SERVER['REQUEST_URI'] = $target;
$_SERVER['PATH_INFO'] = $target;
if ( $pos ) {
$_SERVER['QUERY_STRING'] = substr( $target, $pos + 1 );
$_SERVER['PATH_INFO'] = $target;
// Take the query params in the target and make them the params for this request
parse_str( $_SERVER['QUERY_STRING'], $_GET );
}
}
/**
* Is a URL external?
*
* @param string $target URL to test.
* @return boolean
*/
public function is_external( $target ) {
return substr( $target, 0, 7 ) === 'http://' || substr( $target, 0, 8 ) === 'https://';
}
/**
* Pass the data from the target
*
* @return void
*/
public function run() {
// External target
$target = $this->get_target();
if ( $target === null ) {
return;
}
if ( $this->is_external( $target ) ) {
// Pass on to an external request, echo the results, and then stop
$this->process_external( $target );
exit();
}
// Change the request and carry on
$this->process_internal( $target );
}
public function name() {
return __( 'Pass-through', 'redirection' );
}
}
@@ -0,0 +1,50 @@
<?php
require_once __DIR__ . '/url.php';
/**
* URL action - redirect to a URL
*/
class Random_Action extends Url_Action {
/**
* Get a random URL
*
* @return string|null
*/
private function get_random_url() {
// Pick a random WordPress page
global $wpdb;
$id = $wpdb->get_var( "SELECT ID FROM {$wpdb->prefix}posts WHERE post_status='publish' AND post_password='' AND post_type='post' ORDER BY RAND() LIMIT 0,1" );
if ( $id ) {
$url = get_permalink( $id );
if ( $url ) {
return $url;
}
}
return null;
}
/**
* Run this action. May not return from this function.
*
* @return void
*/
public function run() {
$target = $this->get_random_url();
if ( $target ) {
$this->redirect_to( $target );
}
}
public function needs_target() {
return false;
}
public function name() {
return __( 'Redirect to random post', 'redirection' );
}
}
@@ -0,0 +1,54 @@
<?php
/**
* URL action - redirect to a URL
*/
class Url_Action extends Red_Action {
/**
* Redirect to a URL
*
* @param string $target Target URL.
* @return void
*/
protected function redirect_to( $target ) {
// This is a known redirect, possibly extenal
// phpcs:ignore
$redirect = wp_redirect( $target, $this->get_code(), 'redirection' );
if ( $redirect ) {
global $wp_version;
if ( version_compare( $wp_version, '5.1', '<' ) ) {
header( 'X-Redirect-Agent: redirection' );
}
die();
}
}
/**
* Run this action. May not return from this function.
*
* @return void
*/
public function run() {
$target = $this->get_target();
if ( $target !== null ) {
$this->redirect_to( $target );
}
}
/**
* Does this action need a target?
*
* @return boolean
*/
public function needs_target() {
return true;
}
public function name() {
return __( 'Redirect to URL', 'redirection' );
}
}
@@ -0,0 +1,225 @@
<?php
/**
* @api {get} /redirection/v1/404 Get 404 logs
* @apiName GetLogs
* @apiDescription Get a paged list of 404 logs after applying a set of filters and result ordering.
* @apiGroup 404
*
* @apiUse 404QueryParams
*
* @apiUse 404List
* @apiUse 401Error
* @apiUse 404Error
*/
/**
* @api {post} /redirection/v1/bulk/404/:type Bulk action
* @apiName BulkAction
* @apiDescription Delete 404 logs by ID
* @apiGroup 404
*
* @apiParam (URL) {String="delete"} :type Type of bulk action that is applied to every log ID.
*
* @apiParam (Query Parameter) {String[]} [items] Array of group IDs to perform the action on
* @apiParam (Query Parameter) {Boolean=false} [global] Perform action globally using the filter parameters
* @apiUse 404QueryParams
*
* @apiUse 404List
* @apiUse 401Error
* @apiUse 404Error
* @apiUse 400MissingError
*/
/**
* @apiDefine 404QueryParams 404 log query parameters
*
* @apiParam (Query Parameter) {String} [filterBy[ip]] Filter the results by the supplied IP
* @apiParam (Query Parameter) {String} [filterBy[url]] Filter the results by the supplied URL
* @apiParam (Query Parameter) {String} [filterBy[url-]exact] Filter the results by the exact URL (not a substring match, as per `url`)
* @apiParam (Query Parameter) {String} [filterBy[referrer]] Filter the results by the supplied referrer
* @apiParam (Query Parameter) {String} [filterBy[agent]] Filter the results by the supplied user agent
* @apiParam (Query Parameter) {String} [filterBy[target]] Filter the results by the supplied redirect target
* @apiParam (Query Parameter) {String} [filterBy[domain]] Filter the results by the supplied domain name
* @apiParam (Query Parameter) {String="head","get","post"} [filterBy[method]] Filter the results by the supplied HTTP request method
* @apiParam (Query Parameter) {Integer} [filterBy[http]] Filter the results by the supplied redirect HTTP code
* @apiParam (Query Parameter) {string="ip","url"} [orderby] Order by IP or URL
* @apiParam (Query Parameter) {String="asc","desc"} [direction] Direction to order the results by (ascending or descending)
* @apiParam (Query Parameter) {Integer{1...200}} [per_page=25] Number of results per request
* @apiParam (Query Parameter) {Integer} [page=0] Current page of results
* @apiParam (Query Parameter) {String="ip","url"} [groupBy] Group by IP or URL
*/
/**
* @apiDefine 404List
*
* @apiSuccess {Object[]} items Array of 404 log objects
* @apiSuccess {Integer} items.id ID of 404 log entry
* @apiSuccess {String} items.created Date the 404 log entry was recorded
* @apiSuccess {Integer} items.created_time Unix time value for `created`
* @apiSuccess {Integer} items.url The requested URL that caused the 404 log entry
* @apiSuccess {String} items.agent User agent of the client initiating the request
* @apiSuccess {Integer} items.referrer Referrer of the client initiating the request
* @apiSuccess {Integer} total Number of items
*
* @apiSuccessExample {json} Success 200:
* HTTP/1.1 200 OK
* {
* "items": [
* {
* "id": 3,
* "created": "2019-01-01 12:12:00,
* "created_time": "12345678",
* "url": "/the-url",
* "agent": "FancyBrowser",
* "referrer": "http://site.com/previous/,
* }
* ],
* "total": 1
* }
*/
/**
* @phpstan-type Log404Response array{
* items: list<array<string, mixed>|object>,
* total: int
* }
*
* 404 API endpoint
*/
class Redirection_Api_404 extends Redirection_Api_Filter_Route {
/**
* 404 API endpoint constructor
*
* @param non-falsy-string $api_namespace Namespace.
*/
public function __construct( $api_namespace ) {
$orders = [ 'url', 'ip', 'total', 'count', '' ];
$filters = [ 'ip', 'url-exact', 'referrer', 'agent', 'url', 'domain', 'method', 'http' ];
// GET /404 - List 404 logs
register_rest_route(
$api_namespace,
'/404',
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'route_404' ],
'permission_callback' => [ $this, 'permission_callback_manage' ],
'args' => $this->get_filter_args( $orders, $filters ),
],
]
);
// POST /bulk/404/:bulk - Bulk delete 404 logs
register_rest_route(
$api_namespace,
'/bulk/404/(?P<bulk>delete)',
[
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ $this, 'route_bulk' ],
'permission_callback' => [ $this, 'permission_callback_delete' ],
'args' => array_merge(
$this->get_filter_args( $orders, $filters ),
[
'items' => [
'description' => 'Comma separated list of item IDs to perform action on',
'type' => 'array',
'items' => [
'description' => 'Item ID',
'type' => [ 'string', 'number' ],
],
],
]
),
],
]
);
}
/**
* Checks a manage capability
*
* @param WP_REST_Request<array<string, mixed>> $request Request.
* @return bool
*/
public function permission_callback_manage( WP_REST_Request $request ) {
return Redirection_Capabilities::has_access( Redirection_Capabilities::CAP_404_MANAGE );
}
/**
* Checks a delete capability
*
* @param WP_REST_Request<array<string, mixed>> $request Request.
* @return bool
*/
public function permission_callback_delete( WP_REST_Request $request ) {
return Redirection_Capabilities::has_access( Redirection_Capabilities::CAP_404_DELETE );
}
/**
* Get 404 log
*
* @param WP_REST_Request<array<string, mixed>> $request The request.
* @return Log404Response
*/
public function route_404( WP_REST_Request $request ) {
return $this->get_404( $request->get_params() );
}
/**
* Perform action on 404s
*
* @param WP_REST_Request<array<string, mixed>> $request The request.
* @return Log404Response
*/
public function route_bulk( WP_REST_Request $request ) {
$params = $request->get_params();
if ( isset( $params['items'] ) && is_array( $params['items'] ) && count( $params['items'] ) > 0 ) {
$items = $params['items'];
foreach ( $items as $item ) {
if ( is_numeric( $item ) ) {
Red_404_Log::delete( intval( $item, 10 ) );
} elseif ( isset( $params['groupBy'] ) ) {
$group_by = sanitize_text_field( $params['groupBy'] );
$delete_by = 'url-exact';
if ( in_array( $group_by, [ 'ip', 'agent' ], true ) ) {
$delete_by = $group_by;
}
Red_404_Log::delete_all( [ 'filterBy' => [ $delete_by => $item ] ] );
}
}
if ( isset( $params['groupBy'] ) && $params['groupBy'] === 'url-exact' ) {
unset( $params['groupBy'] );
}
} elseif ( isset( $params['global'] ) && $params['global'] !== false ) {
Red_404_Log::delete_all( $params );
}
return $this->get_404( $params );
}
/**
* Get 404 log
*
* @param array<string, mixed> $params The request parameters.
* @return Log404Response
*/
private function get_404( array $params ) {
if ( isset( $params['groupBy'] ) && in_array( $params['groupBy'], [ 'ip', 'url', 'agent', 'url-exact' ], true ) ) {
$group_by = sanitize_text_field( $params['groupBy'] );
if ( $group_by === 'url-exact' ) {
$group_by = 'url';
}
return Red_404_Log::get_grouped( $group_by, $params );
}
return Red_404_Log::get_filtered( $params );
}
}
@@ -0,0 +1,43 @@
<?php
class Redirection_Api {
/**
* @var Redirection_Api|null
*/
private static $instance = null;
/**
* @var array<int, Redirection_Api_Route>
* @phpstan-ignore property.onlyWritten
*/
private $routes = array();
/**
* @return Redirection_Api
*/
public static function init() {
if ( is_null( self::$instance ) ) {
self::$instance = new Redirection_Api();
}
return self::$instance;
}
/**
* @return void
*/
public function __construct() {
global $wpdb;
$wpdb->hide_errors();
$this->routes[] = new Redirection_Api_Redirect( REDIRECTION_API_NAMESPACE );
$this->routes[] = new Redirection_Api_Group( REDIRECTION_API_NAMESPACE );
$this->routes[] = new Redirection_Api_Log( REDIRECTION_API_NAMESPACE );
$this->routes[] = new Redirection_Api_404( REDIRECTION_API_NAMESPACE );
$this->routes[] = new Redirection_Api_Settings( REDIRECTION_API_NAMESPACE );
$this->routes[] = new Redirection_Api_Plugin( REDIRECTION_API_NAMESPACE );
$this->routes[] = new Redirection_Api_Import( REDIRECTION_API_NAMESPACE );
$this->routes[] = new Redirection_Api_Export( REDIRECTION_API_NAMESPACE );
}
}
@@ -0,0 +1,87 @@
<?php
/**
* @api {get} /redirection/v1/export/:module/:format Export redirects
* @apiName Export
* @apiDescription Export redirects for a module to Apache, CSV, Nginx, or JSON format
* @apiGroup Import/Export
*
* @apiParam (URL) {String="1","2","3","all"} :module The module to export, with 1 being WordPress, 2 is Apache, and 3 is Nginx
* @apiParam (URL) {String="csv","apache","nginx","json"} :format The format of the export
*
* @apiSuccess {String} data Exported data
* @apiSuccess {Integer} total Number of items exported
*
* @apiUse 401Error
* @apiUse 404Error
* @apiError redirect_export_invalid_module Invalid module
* @apiErrorExample {json} 404 Error Response:
* HTTP/1.1 400 Bad Request
* {
* "code": "redirect_export_invalid_module",
* "message": "Invalid module"
* }
*/
/**
* @phpstan-type ExportResponse array{
* data: string,
* total: int
* }
*/
class Redirection_Api_Export extends Redirection_Api_Route {
/**
* Export API endpoint constructor
*
* @param non-falsy-string $api_namespace Namespace.
*/
public function __construct( $api_namespace ) {
// GET /export/:module/:format - Export redirects to specified format
register_rest_route(
$api_namespace,
'/export/(?P<module>1|2|3|all)/(?P<format>csv|apache|nginx|json)',
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'route_export' ],
'permission_callback' => [ $this, 'permission_callback_manage' ],
],
]
);
}
/**
* Check if the user has permission to manage import/export
*
* @param WP_REST_Request<array<string, mixed>> $request
* @return bool
*/
public function permission_callback_manage( WP_REST_Request $request ) {
return Redirection_Capabilities::has_access( Redirection_Capabilities::CAP_IO_MANAGE );
}
/**
* Export redirects to a specified format
*
* @param WP_REST_Request<array<string, mixed>> $request
* @return ExportResponse|WP_Error
*/
public function route_export( WP_REST_Request $request ) {
$module = sanitize_text_field( $request['module'] );
$format = 'json';
if ( in_array( $request['format'], [ 'csv', 'apache', 'nginx', 'json' ], true ) ) {
$format = sanitize_text_field( $request['format'] );
}
$export = Red_FileIO::export( $module, $format );
if ( $export === false ) {
return $this->add_error_details( new WP_Error( 'redirect_export_invalid_module', 'Invalid module' ), __LINE__ );
}
return array(
'data' => $export['data'],
'total' => $export['total'],
);
}
}
@@ -0,0 +1,137 @@
<?php
class Redirection_Api_Filter_Route extends Redirection_Api_Route {
/**
* Validate filter param against allowed fields
*
* @param mixed $value Provided filter value.
* @param WP_REST_Request $request Request.
* @param string $param Param name.
*
* @phpstan-param mixed $value
* @phpstan-param WP_REST_Request<array<string, mixed>> $request
* @phpstan-param string $param
* @phpstan-return true|WP_Error
* @return true|WP_Error
*/
public function validate_filter( $value, $request, $param ) {
$attrs = $request->get_attributes();
/** @var array<string, mixed> $attrs */
$fields = [];
if ( isset( $attrs['args'] ) && is_array( $attrs['args'] ) ) {
$args = $attrs['args'];
if ( isset( $args['filterBy'] ) && is_array( $args['filterBy'] ) ) {
$filter_by = $args['filterBy'];
if ( isset( $filter_by['filter_fields'] ) && is_array( $filter_by['filter_fields'] ) ) {
/** @var list<string> $fields */
$fields = array_values( array_map( 'strval', $filter_by['filter_fields'] ) );
}
}
}
// An empty string means no filter was set (client sent filterBy= with nothing) — normalize to an empty array
if ( $value === '' ) {
$request->set_param( $param, [] );
return true;
}
if ( ! is_array( $value ) ) {
return new WP_Error( 'rest_invalid_param', 'Filter is not an array', array( 'status' => 400 ) );
}
/** @var array<string, mixed> $value */
if ( count( $fields ) > 0 ) {
foreach ( array_keys( $value ) as $key ) {
if ( ! in_array( $key, $fields, true ) ) {
return new WP_Error( 'rest_invalid_param', 'Filter type is not supported: ' . $key, array( 'status' => 400 ) );
}
}
}
return true;
}
/**
* Build shared filter args
*
* @param list<string> $order_fields Fields allowed for orderby.
* @param list<string> $filters Fields allowed to filter by.
* @return array<string, mixed>
*/
protected function get_filter_args( $order_fields, $filters = [] ) {
// Safety check: ensure Red_Item class is loaded (prevents fatal errors during incomplete plugin updates)
// Use a fallback value if the class doesn't exist
$max_per_page = 200; // Default fallback value
if ( class_exists( 'Red_Item' ) ) {
$max_per_page = Red_Item::MAX_PER_PAGE;
}
return [
'filterBy' => [
'description' => 'Field to filter by',
'validate_callback' => [ $this, 'validate_filter' ],
'filter_fields' => $filters,
],
'orderby' => [
'description' => 'Field to order results by',
'type' => 'string',
'enum' => $order_fields,
],
'direction' => [
'description' => 'Direction of ordered results',
'type' => 'string',
'default' => 'desc',
'enum' => [ 'asc', 'desc' ],
],
'per_page' => [
'description' => 'Number of results per page',
'type' => 'integer',
'default' => 25,
'minimum' => 5,
'maximum' => $max_per_page,
],
'page' => [
'description' => 'Page offset',
'type' => 'integer',
'minimum' => 0,
'default' => 0,
],
];
}
/**
* Register a bulk action endpoint for a resource
*
* @param non-falsy-string $namespace REST namespace.
* @param non-falsy-string $route REST route.
* @param list<string> $orders Allowed order fields.
* @param list<string> $filters Allowed filter fields.
* @param string $callback Handler method on this class.
* @param callable|array{0:self,1:string}|false $permissions Permission callback or false for default.
* @return void
*/
public function register_bulk( $namespace, $route, $orders, $filters, $callback, $permissions = false ) {
register_rest_route(
$namespace,
$route,
array_merge(
$this->get_route( WP_REST_Server::EDITABLE, $callback, $permissions ),
[
'args' => array_merge(
$this->get_filter_args( $orders, $filters ),
[
'items' => [
'description' => 'Comma separated list of item IDs to perform action on',
'type' => 'array',
'items' => [
'type' => 'string',
],
],
]
),
]
)
);
}
}
@@ -0,0 +1,371 @@
<?php
/**
* @api {get} /redirection/v1/group Get groups
* @apiName GetGroups
* @apiDescription Get a paged list of groups based after applying a set of filters and result ordering.
* @apiGroup Group
*
* @apiUse GroupQueryParams
*
* @apiUse GroupList
* @apiUse 401Error
* @apiUse 404Error
*/
/**
* @api {post} /redirection/v1/group Create group
* @apiName CreateGroup
* @apiDescription Create a new group, and return a paged list of groups.
* @apiGroup Group
*
* @apiUse GroupItem
* @apiUse GroupQueryParams
*
* @apiUse GroupList
* @apiUse 401Error
* @apiUse 404Error
* @apiError (Error 400) redirect_group_invalid Invalid group or parameters
* @apiErrorExample {json} 404 Error Response:
* HTTP/1.1 400 Bad Request
* {
* "code": "redirect_group_invalid",
* "message": "Invalid group or parameters"
* }
*/
/**
* @api {post} /redirection/v1/group/:id Update group
* @apiName UpdateGroup
* @apiDescription Update an existing group.
* @apiGroup Group
*
* @apiParam (URL) {Integer} :id Group ID to update
* @apiUse GroupList
*
* @apiSuccess {String} item The updated group
* @apiSuccess {Integer} item.id ID of group
* @apiSuccess {String} item.name Name of this group
* @apiSuccess {Boolean} item.enabled `true` if group (and redirects) are enabled, `false` otherwise
* @apiSuccess {Integer} item.redirects Number of redirects in this group
* @apiSuccess {String} item.moduleName Name of the module this group belongs to
* @apiSuccess {Integer} item.module_id ID of the module this group belongs to
*
* @apiUse 401Error
* @apiUse 404Error
*
* @apiError (Error 400) redirect_group_invalid Invalid group or parameters
* @apiErrorExample {json} 404 Error Response:
* HTTP/1.1 400 Bad Request
* {
* "code": "redirect_group_invalid",
* "message": "Invalid group or parameters"
* }
*/
/**
* @api {post} /redirection/v1/bulk/group/:type Bulk action
* @apiName BulkAction
* @apiDescription Enable, disable, and delete a set of groups. The endpoint will return the next page of results after.
* performing the action, based on the supplied query parameters. This information can be used to refresh a list displayed to the client.
* @apiGroup Group
*
* @apiParam (URL) {String="delete","enable","disable"} :type Type of bulk action that is applied to every group ID.
* Enabling or disabling a group will also enable or disable all redirects in that group
*
* @apiParam (Query Parameter) {String[]} [items] Array of group IDs to perform the action on
* @apiParam (Query Parameter) {Boolean=false} [global] Perform action globally using the filter parameters
* @apiUse GroupQueryParams
*
* @apiUse GroupList
* @apiUse 401Error
* @apiUse 404Error
* @apiUse 400MissingError
*/
/**
* @apiDefine GroupQueryParams
*
* @apiParam (Query Parameter) {String} [filterBy[name]] Filter the results by the supplied name
* @apiParam (Query Parameter) {String="enabled","disabled"} [filterBy[status]] Filter the results by the supplied status
* @apiParam (Query Parameter) {Integer="1","2","3"} [filterBy[module]] Filter the results by the supplied module ID
* @apiParam (Query Parameter) {String="name"} [orderby] Order in which results are returned
* @apiParam (Query Parameter) {String="asc","desc"} [direction=desc] Direction to order the results by (ascending or descending)
* @apiParam (Query Parameter) {Integer{1...200}} [per_page=25] Number of results per request
* @apiParam (Query Parameter) {Integer} [page=0] Current page of results
*/
/**
* @apiDefine GroupItem
*
* @apiParam (JSON Body) {String} name Name of the group
* @apiParam (JSON Body) {Integer="1","2","3"} moduleID Module ID of the group, with 1 being WordPress, 2 is Apache, and 3 is Nginx
*/
/**
* @apiDefine GroupList
*
* @apiSuccess {Object[]} items Array of group objects
* @apiSuccess {Integer} items.id ID of group
* @apiSuccess {String} items.name Name of this group
* @apiSuccess {Boolean} items.enabled `true` if group (and redirects) are enabled, `false` otherwise
* @apiSuccess {Integer} items.redirects Number of redirects in this group
* @apiSuccess {String} items.moduleName Name of the module this group belongs to
* @apiSuccess {Integer} items.module_id ID of the module this group belongs to
* @apiSuccess {Integer} total Number of items
*
* @apiSuccessExample {json} Success 200:
* HTTP/1.1 200 OK
* {
* "items": [
* {
* "id": 3,
* "enabled": true,
* "moduleName": "WordPress",
* "module_id": 1,
* "name": "Redirections",
* "redirects": 0,
* }
* ],
* "total": 1
* }
*/
/**
* Group API endpoint
*
* @phpstan-type GroupListResponse array{
* items: list<array<string, mixed>>,
* total: int
* }
*/
class Redirection_Api_Group extends Redirection_Api_Filter_Route {
/**
* 404 API endpoint constructor
*
* @param non-falsy-string $api_namespace Namespace.
*/
public function __construct( $api_namespace ) {
$orders = [ 'name', 'id', '' ];
$filters = [ 'status', 'module', 'name' ];
// GET /group - List groups
// POST /group - Create group
register_rest_route(
$api_namespace,
'/group',
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'route_list' ],
'permission_callback' => [ $this, 'permission_callback_manage' ],
'args' => $this->get_filter_args( $orders, $filters ),
],
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ $this, 'route_create' ],
'permission_callback' => [ $this, 'permission_callback_add' ],
'args' => array_merge(
$this->get_filter_args( $orders, $filters ),
$this->get_group_args()
),
],
]
);
// POST /group/:id - Update group
register_rest_route(
$api_namespace,
'/group/(?P<id>[\d]+)',
[
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ $this, 'route_update' ],
'permission_callback' => [ $this, 'permission_callback_add' ],
'args' => $this->get_group_args(),
],
]
);
// POST /bulk/group/:bulk - Bulk actions on groups
register_rest_route(
$api_namespace,
'/bulk/group/(?P<bulk>delete|enable|disable)',
[
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ $this, 'route_bulk' ],
'permission_callback' => [ $this, 'permission_callback_bulk' ],
'args' => array_merge(
$this->get_filter_args( $orders, $filters ),
[
'items' => [
'description' => 'Comma separated list of item IDs to perform action on',
'type' => 'array',
'items' => [
'description' => 'Item ID',
'type' => [ 'string', 'number' ],
],
],
]
),
],
]
);
}
/**
* Checks a manage capability
*
* Access to group data is required by the CAP_GROUP_MANAGE and CAP_REDIRECT_MANAGE caps
*
* @param WP_REST_Request<array<string, mixed>> $request Request.
* @return bool
*/
public function permission_callback_manage( WP_REST_Request $request ) {
return Redirection_Capabilities::has_access( Redirection_Capabilities::CAP_GROUP_MANAGE ) || Redirection_Capabilities::has_access( Redirection_Capabilities::CAP_REDIRECT_MANAGE );
}
/**
* Checks a bulk capability
*
* @param WP_REST_Request<array<string, mixed>> $request Request.
* @return bool
*/
public function permission_callback_bulk( WP_REST_Request $request ) {
if ( $request['bulk'] === 'delete' ) {
return Redirection_Capabilities::has_access( Redirection_Capabilities::CAP_GROUP_DELETE );
}
return $this->permission_callback_add( $request );
}
/**
* Checks a create capability
*
* @param WP_REST_Request<array<string, mixed>> $request Request.
* @return bool
*/
public function permission_callback_add( WP_REST_Request $request ) {
return Redirection_Capabilities::has_access( Redirection_Capabilities::CAP_GROUP_ADD );
}
/**
* @return array<string, array<string, mixed>>
*/
private function get_group_args() {
return array(
'moduleId' => array(
'description' => 'Module ID',
'type' => 'integer',
'minimum' => 0,
'maximum' => 3,
'required' => true,
),
'name' => array(
'description' => 'Group name',
'type' => 'string',
'required' => true,
),
'status' => [
'description' => 'Status of the group',
],
);
}
/**
* Get group list
*
* @param WP_REST_Request<array<string, mixed>> $request The request.
* @return GroupListResponse
*/
public function route_list( WP_REST_Request $request ) {
return Red_Group::get_filtered( $request->get_params() ); // @phpstan-ignore-line
}
/**
* Create a group
*
* @param WP_REST_Request<array<string, mixed>> $request The request.
* @return GroupListResponse|WP_Error
*/
public function route_create( WP_REST_Request $request ) {
$params = $request->get_params();
$name = '';
$module = 0;
if ( isset( $params['name'] ) ) {
$name = sanitize_text_field( $params['name'] );
}
if ( isset( $params['moduleId'] ) ) {
$module = intval( $params['moduleId'], 10 );
}
$group = Red_Group::create( $name, $module );
if ( $group !== false ) {
return Red_Group::get_filtered( $params ); // @phpstan-ignore-line
}
return $this->add_error_details( new WP_Error( 'redirect_group_invalid', 'Invalid group or parameters' ), __LINE__ );
}
/**
* Update a group
*
* @param WP_REST_Request<array<string, mixed>> $request The request.
* @return array{item: array<string, mixed>}|WP_Error
*/
public function route_update( WP_REST_Request $request ) {
$params = $request->get_params();
$group = Red_Group::get( intval( $request['id'], 10 ) );
if ( $group !== false ) {
$result = $group->update( $params );
if ( $result !== false ) {
return array( 'item' => $group->to_json() );
}
}
return $this->add_error_details( new WP_Error( 'redirect_group_invalid', 'Invalid group details' ), __LINE__ );
}
/**
* Perform action on groups
*
* @param WP_REST_Request<array<string, mixed>> $request The request.
* @return GroupListResponse|WP_Error
*/
public function route_bulk( WP_REST_Request $request ) {
$params = $request->get_params();
$action = $request['bulk'];
$items = [];
if ( isset( $params['items'] ) && is_array( $params['items'] ) ) {
// Array of integers, sanitized below
$items = $params['items'];
} elseif ( isset( $params['global'] ) && $params['global'] !== false ) {
// Groups have additional actions that fire and so we need to action them individually
$groups = Red_Group::get_all( $params );
$items = array_column( $groups, 'id' );
}
foreach ( $items as $item ) {
$group = Red_Group::get( intval( $item, 10 ) );
if ( is_object( $group ) ) {
if ( $action === 'delete' ) {
$group->delete();
} elseif ( $action === 'disable' ) {
$group->disable();
} elseif ( $action === 'enable' ) {
$group->enable();
}
}
}
return $this->route_list( $request );
}
}
@@ -0,0 +1,199 @@
<?php
/**
* @api {get} /redirection/v1/import/file/:group_id Import redirects
* @apiName Import
* @apiDescription Import redirects from CSV, JSON, or Apache .htaccess
* @apiGroup Import/Export
*
* @apiParam (URL) {Integer} :group_id The group ID to import into
* @apiParam (File) {File} file The multipart form upload containing the file to import
*
* @apiSuccess {Integer} imported Number of items imported
*
* @apiUse 401Error
* @apiUse 404Error
* @apiError (Error 400) redirect_import_invalid_group Invalid group
* @apiErrorExample {json} 404 Error Response:
* HTTP/1.1 400 Bad Request
* {
* "code": "redirect_import_invalid_group",
* "message": "Invalid group"
* }
* @apiError (Error 400) redirect_import_invalid_file Invalid file upload
* @apiErrorExample {json} 404 Error Response:
* HTTP/1.1 400 Bad Request
* {
* "code": "redirect_import_invalid_file",
* "message": "Invalid file upload"
* }
*/
/**
* @phpstan-type ImportPluginPayload array{
* plugin?: string|list<string>
* }
* @phpstan-type ImportFileParams array{
* file?: array{
* tmp_name: string,
* name: string,
* size: int,
* type: string,
* error: int
* }
* }
*/
class Redirection_Api_Import extends Redirection_Api_Route {
/**
* @param non-falsy-string $api_namespace REST namespace.
*/
public function __construct( $api_namespace ) {
// POST /import/file/:group_id - Import from file upload
register_rest_route(
$api_namespace,
'/import/file/(?P<group_id>\d+)',
[
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ $this, 'route_import_file' ],
'permission_callback' => [ $this, 'permission_callback_manage' ],
],
]
);
// GET/POST /import/plugin - List or import from plugins
register_rest_route(
$api_namespace,
'/import/plugin',
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'route_plugin_import_list' ],
'permission_callback' => [ $this, 'permission_callback_manage' ],
],
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ $this, 'route_plugin_import' ],
'permission_callback' => [ $this, 'permission_callback_manage' ],
],
]
);
}
/**
* Permission callback used for import routes.
*
* @param WP_REST_Request $_request Request (unused).
* @phpstan-param WP_REST_Request<array<string, mixed>> $_request
* @return bool
*/
public function permission_callback_manage( WP_REST_Request $_request ) {
return Redirection_Capabilities::has_access( Redirection_Capabilities::CAP_IO_MANAGE );
}
/**
* List available plugin importers.
*
* @param WP_REST_Request $request Request.
* @phpstan-param WP_REST_Request<array<string, mixed>> $request
* @phpstan-return array{importers: array<int, mixed>}
* @return array{importers: array}
*/
public function route_plugin_import_list( WP_REST_Request $request ) {
include_once dirname( __DIR__ ) . '/models/importer.php';
return array( 'importers' => Red_Plugin_Importer::get_plugins() );
}
/**
* Import redirects using selected plugin importers.
*
* @param WP_REST_Request $request Request.
* @phpstan-param WP_REST_Request<array<string, mixed>> $request
* @phpstan-return array{imported: int}|WP_Error
* @return array{imported: int}|WP_Error
*/
public function route_plugin_import( WP_REST_Request $request ) {
include_once dirname( __DIR__ ) . '/models/importer.php';
$params = $request->get_params();
/** @var ImportPluginPayload $params */
$groups = Red_Group::get_all();
/** @var array<array{id: int, name: string, redirects: int, module_id: int, moduleName: string, enabled: bool, default?: bool}> $groups */
$plugin_param = $params['plugin'] ?? $request->get_param( 'plugin' );
if ( is_array( $plugin_param ) ) {
$plugins = array_map( 'strval', $plugin_param );
} elseif ( $plugin_param === null ) {
$plugins = [];
} else {
$plugins = [ (string) $plugin_param ];
}
/** @var list<string> $plugins */
$plugins = array_map( 'sanitize_text_field', $plugins );
$total = 0;
if ( count( $groups ) === 0 ) {
return $this->add_error_details(
new WP_Error( 'redirect_import_invalid_group', 'No groups are available for import' ),
__LINE__
);
}
foreach ( $plugins as $plugin ) {
$total += Red_Plugin_Importer::import( $plugin, $groups[0]['id'] );
}
return [ 'imported' => $total ];
}
/**
* Import redirects from an uploaded file.
*
* @param WP_REST_Request $request Request.
* @phpstan-param WP_REST_Request<array<string, mixed>> $request
* @phpstan-return array{imported: int}|WP_Error
* @return array{imported: int}|WP_Error
*/
public function route_import_file( WP_REST_Request $request ) {
$file_params = $request->get_file_params();
/** @var ImportFileParams $file_params */
$group_id = intval( $request['group_id'], 10 );
if ( ! isset( $file_params['file'] ) || ! is_uploaded_file( $file_params['file']['tmp_name'] ) ) {
return $this->add_error_details( new WP_Error( 'redirect_import_invalid_file', 'Invalid file upload' ), __LINE__ );
}
$upload = $file_params['file'];
$parts = pathinfo( $upload['name'] );
$extension = isset( $parts['extension'] ) ? strtolower( $parts['extension'] ) : '';
// JSON imports don't need a group, but all other formats do
if ( $extension !== 'json' ) {
$group = Red_Group::get( $group_id );
if ( $group === false ) {
return $this->add_error_details( new WP_Error( 'redirect_import_invalid_group', 'Invalid group' ), __LINE__ );
}
}
$count = Red_FileIO::import( $group_id, $upload );
// Import failure returns 0, but 0 can also mean no valid redirects in file
// For JSON files, pre-validate to distinguish between invalid JSON and empty/no-redirects
if ( $count === 0 && $extension === 'json' ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file read
$content = file_get_contents( $upload['tmp_name'] );
if ( $content !== false ) {
json_decode( $content, true );
if ( json_last_error() !== JSON_ERROR_NONE ) {
return $this->add_error_details(
new WP_Error( 'redirect_import_invalid_json', 'Invalid JSON file: ' . json_last_error_msg() ),
__LINE__
);
}
}
}
return array(
'imported' => $count,
);
}
}
@@ -0,0 +1,234 @@
<?php
/**
* @api {get} /redirection/v1/log Get logs
* @apiName GetLogs
* @apiDescription Get a paged list of redirect logs after applying a set of filters and result ordering.
* @apiGroup Log
*
* @apiUse LogQueryParams
*
* @apiUse LogList
* @apiUse 401Error
* @apiUse 404Error
*/
/**
* @api {post} /redirection/v1/log Delete logs
* @apiName DeleteLogs
* @apiDescription Delete logs by filter. If no filter is supplied then all entries will be deleted. The endpoint will return the next page of results after.
* performing the action, based on the supplied query parameters. This information can be used to refresh a list displayed to the client.
* @apiGroup Log
*
* @apiParam (Query Parameter) {String} filterBy[ip] Filter the results by the supplied IP
* @apiParam (Query Parameter) {String} filterBy[url] Filter the results by the supplied URL
* @apiParam (Query Parameter) {String} filterBy[url-exact] Filter the results by the exact URL (not a substring match, as per `url`)
* @apiParam (Query Parameter) {String} filterBy[referrer] Filter the results by the supplied referrer
* @apiParam (Query Parameter) {String} filterBy[agent] Filter the results by the supplied user agent
* @apiParam (Query Parameter) {String} filterBy[target] Filter the results by the supplied redirect target
*
* @apiUse LogList
* @apiUse 401Error
* @apiUse 404Error
*/
/**
* @api {post} /redirection/v1/bulk/log/:type Bulk action
* @apiName BulkAction
* @apiDescription Delete logs by ID
* @apiGroup Log
*
* @apiParam (URL) {String="delete"} :type Type of bulk action that is applied to every log ID.
* @apiParam (Query Parameter) {String[]} [items] Array of group IDs to perform the action on
* @apiParam (Query Parameter) {Boolean=false} [global] Perform action globally using the filter parameters
* @apiUse LogQueryParams
*
* @apiUse LogList
* @apiUse 401Error
* @apiUse 404Error
* @apiUse 400MissingError
*/
/**
* @apiDefine LogQueryParams Log query parameters
*
* @apiParam (Query Parameter) {String} [filterBy[ip]] Filter the results by the supplied IP
* @apiParam (Query Parameter) {String} [filterBy[url]] Filter the results by the supplied URL
* @apiParam (Query Parameter) {String} [filterBy[url-]exact] Filter the results by the exact URL (not a substring match, as per `url`)
* @apiParam (Query Parameter) {String} [filterBy[referrer]] Filter the results by the supplied referrer
* @apiParam (Query Parameter) {String} [filterBy[agent]] Filter the results by the supplied user agent
* @apiParam (Query Parameter) {String} [filterBy[target]] Filter the results by the supplied redirect target
* @apiParam (Query Parameter) {String} [filterBy[domain]] Filter the results by the supplied domain name
* @apiParam (Query Parameter) {String} [filterBy[redirect_by]] Filter the results by the redirect agent
* @apiParam (Query Parameter) {String="head","get","post"} [filterBy[method]] Filter the results by the supplied HTTP request method
* @apiParam (Query Parameter) {String="ip","url"} [orderby] Order by IP or URL
* @apiParam (Query Parameter) {String="asc","desc"} [direction=desc] Direction to order the results by (ascending or descending)
* @apiParam (Query Parameter) {Integer{1...200}} [per_page=25] Number of results per request
* @apiParam (Query Parameter) {Integer} [page=0] Current page of results
* @apiParam (Query Parameter) {String="ip","url"} [groupBy] Group by IP or URL
*/
/**
* @apiDefine LogList
*
* @apiSuccess {Object[]} items Array of log objects
* @apiSuccess {Integer} items.id ID of log entry
* @apiSuccess {String} items.created Date the log entry was recorded
* @apiSuccess {Integer} items.created_time Unix time value for `created`
* @apiSuccess {Integer} items.url The requested URL that caused the log entry
* @apiSuccess {String} items.agent User agent of the client initiating the request
* @apiSuccess {Integer} items.referrer Referrer of the client initiating the request
* @apiSuccess {Integer} total Number of items
*
* @apiSuccessExample {json} Success 200:
* HTTP/1.1 200 OK
* {
* "items": [
* {
* "id": 3,
* "created": "2019-01-01 12:12:00,
* "created_time": "12345678",
* "url": "/the-url",
* "agent": "FancyBrowser",
* "referrer": "http://site.com/previous/,
* }
* ],
* "total": 1
* }
*/
/**
* @phpstan-type RedirectLogResponse array{
* items: list<array<string, mixed>|object>,
* total: int
* }
*
* Log API endpoint
*/
class Redirection_Api_Log extends Redirection_Api_Filter_Route {
/**
* Log API endpoint constructor
*
* @param non-falsy-string $api_namespace Namespace.
*/
public function __construct( $api_namespace ) {
$orders = [ 'url', 'ip', 'total', 'count', '' ];
$filters = [ 'ip', 'url-exact', 'referrer', 'agent', 'url', 'target', 'domain', 'method', 'http', 'redirect_by' ];
// GET /log - Get redirect logs
register_rest_route(
$api_namespace,
'/log',
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'route_log' ],
'permission_callback' => [ $this, 'permission_callback_manage' ],
'args' => $this->get_filter_args( $orders, $filters ),
],
]
);
// POST /bulk/log/:bulk - Bulk delete logs
register_rest_route(
$api_namespace,
'/bulk/log/(?P<bulk>delete)',
[
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ $this, 'route_bulk' ],
'permission_callback' => [ $this, 'permission_callback_delete' ],
'args' => array_merge(
$this->get_filter_args( $orders, $filters ),
[
'items' => [
'description' => 'Comma separated list of item IDs to perform action on',
'type' => 'array',
'items' => [
'description' => 'Item ID',
'type' => [ 'string', 'number' ],
],
],
]
),
],
]
);
}
/**
* Checks a manage capability
*
* @param WP_REST_Request<array<string, mixed>> $request Request.
* @return bool
*/
public function permission_callback_manage( WP_REST_Request $request ) {
return Redirection_Capabilities::has_access( Redirection_Capabilities::CAP_LOG_MANAGE );
}
/**
* Checks a delete capability
*
* @param WP_REST_Request<array<string, mixed>> $request Request.
* @return bool
*/
public function permission_callback_delete( WP_REST_Request $request ) {
return Redirection_Capabilities::has_access( Redirection_Capabilities::CAP_LOG_DELETE );
}
/**
* Get log list
*
* @param WP_REST_Request<array<string, mixed>> $request The request.
* @return RedirectLogResponse
*/
public function route_log( WP_REST_Request $request ) {
return $this->get_logs( $request->get_params() );
}
/**
* Perform bulk action on logs
*
* @param WP_REST_Request<array<string, mixed>> $request The request.
* @return RedirectLogResponse
*/
public function route_bulk( WP_REST_Request $request ) {
$params = $request->get_params();
if ( isset( $params['items'] ) && is_array( $params['items'] ) && count( $params['items'] ) > 0 ) {
$items = $params['items'];
foreach ( $items as $item ) {
if ( is_numeric( $item ) ) {
Red_Redirect_Log::delete( intval( $item, 10 ) );
} elseif ( isset( $params['groupBy'] ) ) {
$delete_by = 'url-exact';
if ( in_array( $params['groupBy'], [ 'ip', 'agent' ], true ) ) {
$delete_by = sanitize_text_field( $params['groupBy'] );
}
Red_Redirect_Log::delete_all( [ 'filterBy' => [ $delete_by => $item ] ] ); // @phpstan-ignore-line
}
}
} elseif ( isset( $params['global'] ) && $params['global'] !== false ) {
Red_Redirect_Log::delete_all( $params );
}
return $this->route_log( $request );
}
/**
* Get redirect logs
*
* @param array<string, mixed> $params The request parameters.
* @return RedirectLogResponse
*/
private function get_logs( array $params ) {
if ( isset( $params['groupBy'] ) && in_array( $params['groupBy'], [ 'ip', 'url', 'agent' ], true ) ) {
return Red_Redirect_Log::get_grouped( sanitize_text_field( $params['groupBy'] ), $params );
}
return Red_Redirect_Log::get_filtered( $params );
}
}
@@ -0,0 +1,346 @@
<?php
/**
* 'Plugin' functions for Redirection
*
* @phpstan-import-type DatabaseStatus from Red_Database_Status
* @phpstan-import-type FixerJson from Red_Fixer
*/
class Redirection_Api_Plugin extends Redirection_Api_Route {
/**
* Register REST routes for plugin actions
*
* @param non-falsy-string $api_namespace REST namespace.
* @return void
*/
public function __construct( $api_namespace ) {
// GET/POST /plugin - Get plugin status or run fixer
register_rest_route(
$api_namespace,
'/plugin',
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'route_status' ],
'permission_callback' => [ $this, 'permission_callback_manage' ],
],
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ $this, 'route_fixit' ],
'permission_callback' => [ $this, 'permission_callback_manage' ],
'args' => [
'name' => [
'description' => 'Name',
'type' => 'string',
],
'value' => [
'description' => 'Value',
'type' => 'string',
],
],
],
]
);
// POST /plugin/delete - Delete plugin data
register_rest_route(
$api_namespace,
'/plugin/delete',
[
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ $this, 'route_delete' ],
'permission_callback' => [ $this, 'permission_callback_manage' ],
],
]
);
// ALL /plugin/test - Test endpoint
register_rest_route(
$api_namespace,
'/plugin/test',
[
[
'methods' => WP_REST_Server::ALLMETHODS,
'callback' => [ $this, 'route_test' ],
'permission_callback' => [ $this, 'permission_callback_setup' ],
],
]
);
// POST /plugin/data - Database upgrade/status
register_rest_route(
$api_namespace,
'/plugin/data',
[
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ $this, 'route_database' ],
'permission_callback' => [ $this, 'permission_callback_setup' ],
'args' => [
'upgrade' => [
'description' => 'Upgrade parameter',
'type' => 'string',
'enum' => [
'stop',
'skip',
'retry',
],
],
],
],
]
);
// POST /plugin/finish - Finish setup wizard
register_rest_route(
$api_namespace,
'/plugin/finish',
[
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ $this, 'route_finish' ],
'permission_callback' => [ $this, 'permission_callback_setup' ],
],
]
);
// POST /plugin/fix - Fix database status
register_rest_route(
$api_namespace,
'/plugin/fix',
[
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ $this, 'route_fix_status' ],
'permission_callback' => [ $this, 'permission_callback_setup' ],
'args' => [
'reason' => [
'description' => 'Reason for fix',
'type' => 'string',
'required' => true,
],
'current' => [
'description' => 'Current version',
'type' => 'string',
'required' => true,
],
],
],
]
);
}
/**
* Permission callback for manage-level actions
*
* @param WP_REST_Request $request REST request.
* @phpstan-param WP_REST_Request<array<string, mixed>> $request
* @return bool
*/
public function permission_callback_manage( WP_REST_Request $request ) {
return Redirection_Capabilities::has_access( Redirection_Capabilities::CAP_SUPPORT_MANAGE );
}
/**
* Permission callback for setup and database upgrade actions
*
* @param WP_REST_Request $request REST request.
* @phpstan-param WP_REST_Request<array<string, mixed>> $request
* @return bool
*/
public function permission_callback_setup( WP_REST_Request $request ) {
return Redirection_Capabilities::has_access( Redirection_Capabilities::CAP_OPTION_MANAGE ) ||
$this->permission_callback_manage( $request );
}
/**
* Get fixer status/debug details
*
* @param WP_REST_Request $request REST request.
* @phpstan-param WP_REST_Request<array<string, mixed>> $request
* @return FixerJson
*/
public function route_status( WP_REST_Request $request ) {
include_once dirname( REDIRECTION_FILE ) . '/models/fixer.php';
$fixer = new Red_Fixer();
return $fixer->get_json();
}
/**
* Run fixer or save a specific debug setting
*
* @param WP_REST_Request $request REST request.
* @phpstan-param WP_REST_Request<array<string, mixed>> $request
* @return FixerJson|WP_Error
*/
public function route_fixit( WP_REST_Request $request ) {
include_once dirname( REDIRECTION_FILE ) . '/models/fixer.php';
$params = $request->get_params();
$fixer = new Red_Fixer();
if ( isset( $params['name'] ) && isset( $params['value'] ) ) {
global $wpdb;
$fixer->save_debug( sanitize_text_field( $params['name'] ), sanitize_text_field( $params['value'] ) );
$groups = intval( $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}redirection_groups" ), 10 );
if ( $groups === 0 ) {
$group = Red_Group::create( __( 'Redirections', 'redirection' ), 1 );
if ( $group === false ) {
return $this->add_error_details( new WP_Error( 'redirect_group_create_failed', 'Unable to create group' ), __LINE__ );
}
}
} else {
$result = $fixer->fix( $fixer->get_status() );
if ( is_wp_error( $result ) ) {
return $this->add_error_details( $result, __LINE__ );
}
}
return $fixer->get_json();
}
/**
* Delete plugin (single-site only) and redirect back to plugins page
*
* @return array{location: string}|WP_Error
*/
public function route_delete() {
if ( is_multisite() ) {
return new WP_Error( 'redirect_delete_multi', 'Multisite installations must delete the plugin from the network admin' );
}
Redirection_Admin::plugin_uninstall();
$current = get_option( 'active_plugins' );
$plugin_position = array_search( basename( dirname( REDIRECTION_FILE ) ) . '/' . basename( REDIRECTION_FILE ), $current, true );
if ( $plugin_position !== false ) {
array_splice( $current, (int) $plugin_position, 1 );
update_option( 'active_plugins', $current );
}
return array( 'location' => admin_url() . 'plugins.php' );
}
/**
* Simple plugin test endpoint
*
* @param WP_REST_Request $request REST request.
* @phpstan-param WP_REST_Request<array<string, mixed>> $request
* @return array{success: true}
*/
public function route_test( WP_REST_Request $request ) {
return array(
'success' => true,
);
}
/**
* Database status/upgrade orchestration
*
* @param WP_REST_Request $request REST request.
* @phpstan-param WP_REST_Request<array<string, mixed>> $request
* @return DatabaseStatus
*/
public function route_database( WP_REST_Request $request ) {
$params = $request->get_params();
$status = new Red_Database_Status();
/** @var string|false $upgrade */
$upgrade = false;
if ( isset( $params['upgrade'] ) && in_array( $params['upgrade'], [ 'stop', 'skip' ], true ) ) {
$upgrade = sanitize_text_field( $params['upgrade'] );
}
// Check upgrade
if ( ! $status->needs_updating() && ! $status->needs_installing() ) {
/* translators: version number */
$status->set_error( sprintf( __( 'Your database does not need updating to %s.', 'redirection' ), REDIRECTION_DB_VERSION ) );
return $status->get_json();
}
if ( $upgrade === 'stop' ) {
$status->stop_update();
} elseif ( $upgrade === 'skip' ) {
$status->set_next_stage();
}
$should_upgrade = $upgrade === false || $status->get_current_stage() !== false;
if ( $should_upgrade ) {
$database = new Red_Database();
$database->apply_upgrade( $status );
}
return $status->get_json();
}
/**
* Finish setup wizard
*
* @return array{success: true}
*/
public function route_finish() {
$status = new Red_Database_Status();
$status->finish();
return array( 'success' => true );
}
/**
* Fix database status after manual upgrade
*
* @param WP_REST_Request $request REST request.
* @phpstan-param WP_REST_Request<array<string, mixed>> $request
* @return array{success: true, database: DatabaseStatus}|WP_Error
*/
public function route_fix_status( WP_REST_Request $request ) {
global $wpdb;
$params = $request->get_params();
$reason = isset( $params['reason'] ) ? sanitize_text_field( $params['reason'] ) : '';
$current = isset( $params['current'] ) ? sanitize_text_field( $params['current'] ) : '';
// Validate required parameters
if ( $reason === '' ) {
return $this->add_error_details(
new WP_Error( 'redirection_invalid_reason', 'Missing or invalid reason parameter' ),
__LINE__
);
}
if ( $current === '' ) {
return $this->add_error_details(
new WP_Error( 'redirection_invalid_version', 'Missing or invalid current version parameter' ),
__LINE__
);
}
$status = new Red_Database_Status();
if ( $reason === 'database' ) {
$status->save_db_version( $current );
// After manual database install, ensure default groups are created
$latest = Red_Database::get_latest_database();
$latest->create_groups( $wpdb, true );
$status->finish();
} else {
return $this->add_error_details(
new WP_Error( 'redirection_unsupported_reason', "Unsupported reason: $reason" ),
__LINE__
);
}
return array(
'success' => true,
'database' => $status->get_json(),
);
}
}
@@ -0,0 +1,458 @@
<?php
/**
* @api {get} /redirection/v1/redirect Get redirects
* @apiName GetRedirects
* @apiDescription Get a paged list of redirects based after applying a set of filters and result ordering.
* @apiGroup Redirect
*
* @apiUse RedirectQueryParams
*
* @apiUse RedirectList
* @apiUse 401Error
* @apiUse 404Error
*/
/**
* @api {post} /redirection/v1/redirect Create redirect
* @apiName CreateRedirect
* @apiDescription Create a new redirect, and return a paged list of redirects.
* @apiGroup Redirect
*
* @apiUse RedirectItem
* @apiUse RedirectQueryParams
*
* @apiUse RedirectList
* @apiUse 401Error
* @apiUse 404Error
* @apiError (Error 400) redirect_create_failed Failed to create redirect
* @apiErrorExample {json} 404 Error Response:
* HTTP/1.1 400 Bad Request
* {
* "code": "redirect_create_failed",
* "message": "Failed to create redirect"
* }
*/
/**
* @api {post} /redirection/v1/redirect/:id Update redirect
* @apiName UpdateRedirect
* @apiDescription Update an existing redirect.
* @apiGroup Redirect
*
* @apiParam (URL) {Integer} :id Redirect ID to update
*
* @apiUse RedirectItem
*
* @apiUse RedirectList
* @apiUse 401Error
* @apiUse 404Error
* @apiError (Error 400) redirect_update_failed Failed to update redirect
* @apiErrorExample {json} 404 Error Response:
* HTTP/1.1 400 Bad Request
* {
* "code": "redirect_update_failed",
* "message": "Failed to update redirect"
* }
*/
/**
* @api {post} /redirection/v1/bulk/redirect/:type Bulk action
* @apiName BulkAction
* @apiDescription Enable, disable, and delete a set of redirects. The endpoint will return the next page of results after.
* performing the action, based on the supplied query parameters. This information can be used to refresh a list displayed to the client.
* @apiGroup Redirect
*
* @apiParam (URL) {String="delete","enable","disable","reset"} :type Type of bulk action that is applied to every item.
* @apiParam (Query Parameter) {String[]} [items] Array of redirect IDs to perform the action on
* @apiParam (Query Parameter) {Boolean=false} [global] Perform action globally using the filter parameters
* @apiUse RedirectQueryParams
*
* @apiUse RedirectList
* @apiUse 401Error
* @apiUse 404Error
* @apiUse 400MissingError
*/
/**
* @apiDefine RedirectItem Redirect
* All data associated with a redirect
*
* @apiParam {String="enabled","disabled"} status Status of the redirect
* @apiParam {Integer} position Redirect position, used to determine order multiple redirects occur
* @apiParam {Object} match_data Additional match parameters
* @apiParam {Object} match_data.source Match against the source
* @apiParam {Boolean} match_data.source.flag_regex `true` for regular expression, `false` otherwise
* @apiParam {String="ignore","exact","pass"} match_data.source.flag_query Which query parameter matching to use
* @apiParam {Boolean} match_data.source.flag_case] `true` for case insensitive matches, `false` otherwise
* @apiParam {Boolean} match_data.source.flag_trailing] `true` to ignore trailing slashes, `false` otherwise
* @apiParam {Object} match_data.options Options for the redirect match
* @apiParam {Boolean} match_data.options.log_exclude `true` to exclude this from any logs, `false` otherwise (default)
* @apiParam {Boolean} regex True for regular expression, `false` otherwise
* @apiParam {String} url The source URL
* @apiParam {String="url","referrer","agent","login","header","custom","cookie","role","server","ip","page","language"} match_type What URL matching to use
* @apiParam {String} [title] A descriptive title for the redirect, or empty for no title
* @apiParam {Integer} group_id The group this redirect belongs to
* @apiParam {String} action_type What to do when the URL is matched
* @apiParam {Integer} action_code The HTTP code to return
* @apiParam {Object} action_data Any data associated with the `action_type` and `match_type`. For example, the target URL
*/
/**
* @apiDefine RedirectList A list of redirects
* A list of redirects
*
* @apiSuccess {Object[]} items Array of redirect objects
* @apiSuccess {Integer} items.id ID of redirect
* @apiSuccess {String} items.url Source URL to match
* @apiSuccess {String} items.match_url Match URL
* @apiSuccess {Object} items.match_data Match against the source
* @apiSuccess {String} items.match_type What URL matching to use
* @apiSuccess {String} items.action_type What to do when the URL is matched
* @apiSuccess {Integer} items.action_code The HTTP code to return
* @apiSuccess {String} items.action_data Any data associated with the action_type. For example, the target URL
* @apiSuccess {String} items.title Optional A descriptive title for the redirect, or empty for no title
* @apiSuccess {String} items.hits Number of hits this redirect has received
* @apiSuccess {String} items.regex True for regular expression, false otherwise
* @apiSuccess {String} items.group_id The group this redirect belongs to
* @apiSuccess {String} items.position Redirect position, used to determine order multiple redirects occur
* @apiSuccess {String} items.last_access The date this redirect was last hit
* @apiSuccess {String} items.status Status of the redirect
* @apiSuccess {Integer} total Number of items
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "items": [
* {
* id: 3,
* url: "/source",
* match_url: "/source",
* match_data: "",
* action_code: "",
* action_type: "",
* action_data: "",
* match_type: "url",
* title: "Redirect title",
* hits: 5,
* regex: true,
* group_id: 15,
* position: 1,
* last_access: "2019-01-01 01:01:01"
* status: "enabled"
* }
* ],
* "total": 1
* }
*/
/**
* @apiDefine RedirectQueryParams
*
* @apiParam (Query Parameter) {String="enabled","disabled"} [filterBy[status]] Filter the results by the supplied status
* @apiParam (Query Parameter) {String} [filterBy[url]] Filter the results by the supplied URL
* @apiParam (Query Parameter) {String="regular","plain"} [filterBy[url-match]] Filter the results by `regular` expressions or non regular expressions
* @apiParam (Query Parameter) {String} [filterBy[match]] Filter the results by the supplied match type
* @apiParam (Query Parameter) {String} [filterBy[action]] Filter the results by the supplied action type
* @apiParam (Query Parameter) {Integer} [filterBy[http]] Filter the results by the supplied redirect HTTP code
* @apiParam (Query Parameter) {String="year","month","all"} [filterBy[access]] Filter the results by how long the redirect was last accessed
* @apiParam (Query Parameter) {String} [filterBy[target]] Filter the results by the supplied redirect target
* @apiParam (Query Parameter) {String} [filterBy[title]] Filter the results by the supplied redirect title
* @apiParam (Query Parameter) {Integer} [filterBy[group]] Filter the results by the supplied redirect group ID
* @apiParam (Query Parameter) {Integer} [filterBy[id]] Filter the results to the redirect ID
* @apiParam (Query Parameter) {Integer="1","2","3"} [filterBy[module]] Filter the results by the supplied module ID
* @apiParam (Query Parameter) {String="source","last_count","last_access","position","id"} [orderby=id] Order in which results are returned
* @apiParam (Query Parameter) {String="asc","desc"} [direction=desc] Direction to order the results by (ascending or descending)
* @apiParam (Query Parameter) {Integer{1...200}} [per_page=25] Number of results per request
* @apiParam (Query Parameter) {Integer} [page=0] Current page of results
*/
/**
* @phpstan-type RedirectListResponse array{
* items: list<array<string, mixed>>,
* total: int
* }
*
* Redirect API endpoint
*/
class Redirection_Api_Redirect extends Redirection_Api_Filter_Route {
/**
* Redirect API endpoint constructor
*
* @param non-falsy-string $api_namespace Namespace.
*/
public function __construct( $api_namespace ) {
$orders = [ 'source', 'last_count', 'last_access', 'position', 'id', '' ];
$filters = [ 'status', 'url-match', 'match', 'action', 'http', 'access', 'url', 'target', 'title', 'group', 'id' ];
// GET /redirect - List redirects
// POST /redirect - Create redirect
register_rest_route(
$api_namespace,
'/redirect',
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'route_list' ],
'permission_callback' => [ $this, 'permission_callback_manage' ],
'args' => $this->get_filter_args( $orders, $filters ),
],
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ $this, 'route_create' ],
'permission_callback' => [ $this, 'permission_callback_add' ],
'args' => $this->get_filter_args( $orders, $filters ),
],
]
);
// POST /redirect/:id - Update redirect
register_rest_route(
$api_namespace,
'/redirect/(?P<id>[\d]+)',
[
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ $this, 'route_update' ],
'permission_callback' => [ $this, 'permission_callback_add' ],
],
]
);
// GET /redirect/post - Search for posts
register_rest_route(
$api_namespace,
'/redirect/post',
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'route_match_post' ],
'permission_callback' => [ $this, 'permission_callback_manage' ],
'args' => [
'text' => [
'description' => 'Text to match',
'type' => 'string',
'required' => true,
],
],
],
]
);
// POST /bulk/redirect/:bulk - Bulk actions on redirects
register_rest_route(
$api_namespace,
'/bulk/redirect/(?P<bulk>delete|enable|disable|reset)',
[
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ $this, 'route_bulk' ],
'permission_callback' => [ $this, 'permission_callback_bulk' ],
'args' => array_merge(
$this->get_filter_args( $orders, $filters ),
[
'global' => [
'description' => 'Apply bulk action globally, as per filters',
'type' => 'boolean',
],
'items' => [
'description' => 'Array of IDs to perform action on',
'type' => 'array',
'items' => [
'description' => 'Item ID',
'type' => [ 'string', 'number' ],
],
],
]
),
],
]
);
}
/**
* Checks a manage capability
*
* @param WP_REST_Request<array<string, mixed>> $request Request.
* @return bool
*/
public function permission_callback_manage( WP_REST_Request $request ) {
return Redirection_Capabilities::has_access( Redirection_Capabilities::CAP_REDIRECT_MANAGE );
}
/**
* Checks a bulk capability
*
* @param WP_REST_Request<array<string, mixed>> $request Request.
* @return bool
*/
public function permission_callback_bulk( WP_REST_Request $request ) {
if ( $request['bulk'] === 'delete' ) {
return Redirection_Capabilities::has_access( Redirection_Capabilities::CAP_REDIRECT_DELETE );
}
return $this->permission_callback_add( $request );
}
/**
* Checks a create capability
*
* @param WP_REST_Request<array<string, mixed>> $request Request.
* @return bool
*/
public function permission_callback_add( WP_REST_Request $request ) {
return Redirection_Capabilities::has_access( Redirection_Capabilities::CAP_REDIRECT_ADD );
}
/**
* Get redirect list
*
* @param WP_REST_Request<array<string, mixed>> $request The request.
* @return RedirectListResponse
*/
public function route_list( WP_REST_Request $request ) {
return Red_Item::get_filtered( $request->get_params() );
}
/**
* Create new redirect(s)
*
* @param WP_REST_Request<array<string, mixed>> $request The request.
* @return RedirectListResponse|WP_Error
*/
public function route_create( WP_REST_Request $request ) {
$params = $request->get_params();
$urls = array();
if ( isset( $params['url'] ) ) {
$urls = array( $params['url'] );
if ( is_array( $params['url'] ) ) {
$urls = $params['url'];
}
// Remove duplicates
$unique = [];
foreach ( $urls as $url ) {
$unique[ $url ] = $url;
}
foreach ( $unique as $url ) {
$params['url'] = $url;
// Data is sanitized in the create function
$redirect = Red_Item::create( $params );
if ( is_wp_error( $redirect ) ) {
return $this->add_error_details( $redirect, __LINE__ );
}
}
}
return $this->route_list( $request );
}
/**
* Update redirect
*
* @param WP_REST_Request<array<string, mixed>> $request The request.
* @return array{item: array<string, mixed>}|WP_Error
*/
public function route_update( WP_REST_Request $request ) {
$params = $request->get_params();
$redirect = Red_Item::get_by_id( intval( $params['id'], 10 ) );
if ( $redirect !== false ) {
$result = $redirect->update( $params );
if ( is_wp_error( $result ) ) {
return $this->add_error_details( $result, __LINE__ );
}
return [ 'item' => $redirect->to_json() ];
}
return $this->add_error_details( new WP_Error( 'redirect_update_failed', 'Invalid redirect details' ), __LINE__ );
}
/**
* Perform bulk action on redirects
*
* @param WP_REST_Request<array<string, mixed>> $request The request.
* @return RedirectListResponse|WP_Error
*/
public function route_bulk( WP_REST_Request $request ) {
$params = $request->get_params();
$action = sanitize_text_field( $request['bulk'] );
if ( isset( $params['items'] ) && is_array( $params['items'] ) && count( $params['items'] ) > 0 ) {
$items = $params['items'];
foreach ( $items as $item ) {
$redirect = Red_Item::get_by_id( intval( $item, 10 ) );
if ( $redirect === false ) {
return $this->add_error_details( new WP_Error( 'redirect_bulk_failed', 'Invalid redirect' ), __LINE__ );
}
if ( $action === 'delete' ) {
$redirect->delete();
} elseif ( $action === 'disable' ) {
$redirect->disable();
} elseif ( $action === 'enable' ) {
$redirect->enable();
} elseif ( $action === 'reset' ) {
$redirect->reset();
}
}
} elseif ( isset( $params['global'] ) && $params['global'] !== false ) {
// Params are sanitized in the filter class
if ( $action === 'delete' ) {
Red_Item::delete_all( $params );
} elseif ( $action === 'reset' ) {
Red_Item::reset_all( $params );
} elseif ( $action === 'enable' || $action === 'disable' ) {
Red_Item::set_status_all( $action, $params );
}
}
return $this->route_list( $request );
}
/**
* Search for a post
*
* @param WP_REST_Request<array<string, mixed>> $request The request.
* @return list<array{title: string, value: string|false}>
*/
public function route_match_post( WP_REST_Request $request ) {
global $wpdb;
$params = $request->get_params();
$search = sanitize_text_field( $params['text'] );
$results = [];
$posts = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID,post_title,post_name FROM $wpdb->posts WHERE post_status='publish' AND (post_title LIKE %s OR post_name LIKE %s) " .
"AND post_type IN ('post','page')",
'%' . $wpdb->esc_like( $search ) . '%',
'%' . $wpdb->esc_like( $search ) . '%'
)
);
foreach ( (array) $posts as $post ) {
$title = $post->post_name;
if ( strpos( $post->post_title, $search ) !== false ) {
$title = $post->post_title;
}
$results[] = [
'title' => $title,
'value' => get_permalink( $post->ID ),
];
}
return $results;
}
}
@@ -0,0 +1,54 @@
<?php
class Redirection_Api_Route {
/**
* Add DB error details to a WP_Error
*
* @param WP_Error $error Error instance.
* @param string|int $line Error code/identifier.
* @param int $code HTTP status code.
* @return WP_Error
*/
protected function add_error_details( WP_Error $error, $line, $code = 400 ) {
global $wpdb;
$data = array(
'status' => $code,
'error_code' => $line,
);
if ( isset( $wpdb->last_error ) && $wpdb->last_error ) {
$data['wpdb'] = $wpdb->last_error;
}
$error->add_data( $data );
return $error;
}
/**
* Default permission callback for API routes
*
* @param WP_REST_Request $request REST request.
* @phpstan-param WP_REST_Request<array<string, mixed>> $request
* @return bool
*/
public function permission_callback( WP_REST_Request $request ) {
return Redirection_Capabilities::has_access( Redirection_Capabilities::CAP_PLUGIN );
}
/**
* Build a REST route config
*
* @param mixed $method Allowed methods (WP_REST_Server constants).
* @param string $callback Method name on this class.
* @param callable|array{0:self,1:string}|false $permissions Permission callback or false for default.
* @return array<string, mixed>
*/
public function get_route( $method, $callback, $permissions = false ) {
return [
'methods' => $method,
'callback' => [ $this, $callback ],
'permission_callback' => $permissions ? $permissions : [ $this, 'permission_callback' ],
];
}
}
@@ -0,0 +1,210 @@
<?php
/**
* @api {get} /redirection/v1/setting Get settings
* @apiName GetSettings
* @apiDescription Get all settings for Redirection. This includes user-configurable settings, as well as necessary WordPress settings.
* @apiGroup Settings
*
* @apiUse SettingItem
* @apiUse 401Error
* @apiUse 404Error
*/
/**
* @api {post} /redirection/v1/setting Update settings
* @apiName UpdateSettings
* @apiDescription Update Redirection settings. Note you can do partial updates, and only the values specified will be changed.
* @apiGroup Settings
*
* @apiParam {Object} settings An object containing all the settings to update
* @apiParamExample {json} settings:
* {
* "expire_redirect": 14,
* "https": false
* }
*
* @apiUse SettingItem
* @apiUse 401Error
* @apiUse 404Error
*/
/**
* @apiDefine SettingItem Settings
* Redirection settings
*
* @apiSuccess {Object[]} settings An object containing all settings
* @apiSuccess {String} settings.expire_redirect
* @apiSuccess {String} settings.token
* @apiSuccess {String} settings.monitor_post
* @apiSuccess {String[]} settings.monitor_types
* @apiSuccess {String} settings.associated_redirect
* @apiSuccess {String} settings.auto_target
* @apiSuccess {String} settings.expire_redirect
* @apiSuccess {String} settings.expire_404
* @apiSuccess {String} settings.modules
* @apiSuccess {String} settings.redirect_cache
* @apiSuccess {String} settings.ip_logging
* @apiSuccess {String} settings.last_group_id
* @apiSuccess {String} settings.rest_api
* @apiSuccess {String} settings.https
* @apiSuccess {String} settings.headers
* @apiSuccess {String} settings.database
* @apiSuccess {String} settings.relocate Relocate this site to the specified domain (and path)
* @apiSuccess {String="www","nowww",""} settings.preferred_domain Preferred canonical domain
* @apiSuccess {String[]} settings.aliases Array of domains that will be redirected to the current WordPress site
* @apiSuccess {Object[]} groups An array of groups
* @apiSuccess {String} groups.label Name of the group
* @apiSuccess {Integer} groups.value Group ID
* @apiSuccess {String} installed The path that WordPress is installed in
* @apiSuccess {Boolean} canDelete True if Redirection can be deleted, false otherwise (on multisite, for example)
* @apiSuccess {String[]} post_types Array of WordPress post types
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "settings": {
* "expire_redirect": 7,
* "https": true
* },
* "groups": [
* { label: 'My group', value: 5 }
* ],
* "installed": "/var/html/wordpress",
* "canDelete": true,
* "post_types": [
* "post",
* "page"
* ]
* }
*/
/**
* @phpstan-import-type RedirectionOptions from Red_Options
*
* @phpstan-type SettingsResponse array{
* settings: RedirectionOptions,
* groups: array<int, object>,
* installed: string,
* canDelete: bool,
* post_types: array<int|string>
* }
* @phpstan-type SettingsResponseWithWarning array{
* settings: RedirectionOptions,
* groups: array<int, object>,
* installed: string,
* canDelete: bool,
* post_types: array<int|string>,
* warning?: string
* }
*/
class Redirection_Api_Settings extends Redirection_Api_Route {
/**
* Settings API endpoint constructor
*
* @param non-falsy-string $api_namespace Namespace.
*/
public function __construct( $api_namespace ) {
// GET /setting - Get settings
// POST /setting - Update settings
register_rest_route(
$api_namespace,
'/setting',
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'route_settings' ],
'permission_callback' => [ $this, 'permission_callback_manage' ],
],
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ $this, 'route_save_settings' ],
'permission_callback' => [ $this, 'permission_callback_manage' ],
],
]
);
}
/**
* Get all settings for Redirection
*
* @param WP_REST_Request<array<string, mixed>> $request
* @return SettingsResponse
*/
public function route_settings( WP_REST_Request $request ) {
if ( ! function_exists( 'get_home_path' ) ) {
include_once ABSPATH . '/wp-admin/includes/file.php';
}
return [
'settings' => Red_Options::get(),
'groups' => $this->groups_to_json( Red_Group::get_for_select() ),
'installed' => get_home_path(),
'canDelete' => ! is_multisite(),
'post_types' => red_get_post_types(),
];
}
/**
* Check if the user has permission to manage settings
*
* @param WP_REST_Request<array<string, mixed>> $request
* @return bool
*/
public function permission_callback_manage( WP_REST_Request $request ) {
return Redirection_Capabilities::has_access( Redirection_Capabilities::CAP_OPTION_MANAGE ) || Redirection_Capabilities::has_access( Redirection_Capabilities::CAP_SITE_MANAGE );
}
/**
* Save settings for Redirection
*
* @param WP_REST_Request<array<string, mixed>> $request
* @return SettingsResponseWithWarning
*/
public function route_save_settings( WP_REST_Request $request ) {
$params = $request->get_params();
$result = true;
if ( isset( $params['location'] ) && strlen( $params['location'] ) > 0 ) {
$module = Red_Module::get( 2 );
if ( $module !== false && $module instanceof Apache_Module ) {
$result = $module->can_save( sanitize_text_field( $params['location'] ) );
}
}
red_set_options( $params );
$settings = $this->route_settings( $request );
if ( is_wp_error( $result ) ) {
$settings['warning'] = $result->get_error_message();
}
return $settings;
}
/**
* Convert groups array to JSON format
*
* @param array<string|int, mixed> $groups Groups array from Red_Group::get_for_select()
* @param int $depth Current recursion depth
* @return array<int, object>
*/
private function groups_to_json( $groups, $depth = 0 ) {
$items = array();
foreach ( $groups as $text => $value ) {
if ( is_array( $value ) && $depth === 0 ) {
$items[] = (object) array(
'label' => $text,
'value' => $this->groups_to_json( $value, 1 ),
);
} else {
$items[] = (object) array(
'label' => $value,
'value' => $text,
);
}
}
return $items;
}
}
@@ -0,0 +1,63 @@
<?php
require_once __DIR__ . '/api-route.php';
require_once __DIR__ . '/api-filter-route.php';
require_once __DIR__ . '/api-group.php';
require_once __DIR__ . '/api-redirect.php';
require_once __DIR__ . '/api-log.php';
require_once __DIR__ . '/api-404.php';
require_once __DIR__ . '/api-settings.php';
require_once __DIR__ . '/api-plugin.php';
require_once __DIR__ . '/api-import.php';
require_once __DIR__ . '/api-export.php';
require_once __DIR__ . '/api-core.php';
define( 'REDIRECTION_API_NAMESPACE', 'redirection/v1' );
/**
* @apiDefine 401Error
*
* @apiError (Error 401) rest_forbidden You are not authorized to access this API endpoint
* @apiErrorExample {json} 401 Error Response:
* HTTP/1.1 401 Bad Request
* {
* "code": "rest_forbidden",
* "message": "Sorry, you are not allowed to do that."
* }
*/
/**
* @apiDefine 404Error
*
* @apiError (Error 404) rest_no_route Endpoint not found
* @apiErrorExample {json} 404 Error Response:
* HTTP/1.1 404 Not Found
* {
* "code": "rest_no_route",
* "message": "No route was found matching the URL and request method"
* }
*/
/**
* @apiDefine 400Error
*
* @apiError rest_forbidden You are not authorized to access this API endpoint
* @apiErrorExample {json} 400 Error Response:
* HTTP/1.1 400 Bad Request
* {
* "error": "invalid",
* "message": "Invalid request"
* }
*/
/**
* @apiDefine 400MissingError
*
* @apiError (Error 400) rest_missing_callback_param Some required parameters are not present or not in the correct format
* @apiErrorExample {json} 400 Error Response:
* HTTP/1.1 400 Bad Request
* {
* "code": "rest_missing_callback_param",
* "message": "Missing parameter(s): PARAM"
* }
*/
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
<?php
define( 'REDIRECTION_VERSION', '5.8.0' );
define( 'REDIRECTION_BUILD', 'ccd61875d87555ffa26d4caa63395f50' );
define( 'REDIRECTION_MIN_WP', '6.6' );
@@ -0,0 +1 @@
<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-element', 'wp-i18n'), 'version' => '1e46303089c718887d2a');
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,15 @@
/*!
Copyright (c) 2018 Jed Watson.
Licensed under the MIT License (MIT), see
http://jedwatson.github.io/classnames
*/
/**
* @license React
* use-sync-external-store-with-selector.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
@@ -0,0 +1,573 @@
= 4.9.2 - 30th October =
* Fix warning with PHP 5.6
* Improve display of long URLs
= 4.9.1 - 26th October 2020 =
* Restore missing time and referrer URL from log pages
* Restore missing client information from debug reports
* Fix order by count when grouping by URL
* Check for duplicate columns in DB upgrade
= 4.9 - 24th October 2020 =
* Expand log information to capture HTTP headers, domain, HTTP code, and HTTP method
* Allow non-Redirection redirects to be logged - allows tracking of all redirects on a site
* Expand log and 404 pages with greatly improved filters
* Bulk delete logs and 404s by selected filter
* Logging is now optional per redirect rule
* Fix random action on a site with non-root URL
* Fix group and search being reset when searching
* Fix canonical alias not using request server name
= 4.8 - 23rd May 2020 =
* Add importer for Quick Post/Page Redirects plugin
* Add plugin imports to WP CLI
* Fix install wizard using wrong relative API
* Fix sub menu outputting invalid HTML
= 4.7.2 - 8th May 2020 =
* Fix PHP warning decoding an encoded question mark
* Fix site adding an extra period in a domain name
* Fix protocol appearing in .htaccess file server redirect
= 4.7.1 - 14th March 2020 =
* Fix HTTP header over-sanitizing the value
* Fix inability to remove .htaccess location
* Fix 404 group by 'delete all'
* Fix import of empty 'old slugs'
= 4.7 - 15th February 2020 =
* Relocate entire site to another domain, with exceptions
* Site aliases to map another site to current site
* Canonical settings for www/no-www
* Change content-type for API requests to help with mod_security
= 4.6.2 - 6th January 2020 =
* Fix 404 log export button
* Fix HTTPS option not appearing enabled
* Fix another PHP compat issue
= 4.6.1 - 30th December 2019 =
* Back-compatibility fix for old PHP versions
= 4.6 - 27th December 2019 =
* Add fine-grained permissions allowing greater customisation of the plugin, and removal of functionality
* Add an import step to the install wizard
* Remove overriding of default WordPress 'old slugs'
= 4.5.1 - 23rd November 2019 =
* Fix broken canonical redirects
= 4.5 - 23rd November 2019 =
* Add HTTP header feature, with x-robots-tag support
* Move HTTPS setting to new Site page
* Add filter to disable redirect hits
* Add 'Disable Redirection' option to stop Redirection, in case you break your site
* Fill out API documentation
* Fix style with WordPress 5.4
* Fix encoding of # in .htaccess
= 4.4.2 - 29th September 2019 =
* Fix missing options for monitor group
* Fix check redirect not appearing if position column not shown
= 4.4.1 - 28th September 2019 =
* Fix search highlighter causing problems with regex characters
* Fix 'show all' link not working
* Fix 'Request URI Too Long' error when switching pages after creating redirects
= 4.4 - 22nd September 2019 =
* Add 'URL and language' match
* Add page display type for configurable information
* Add 'search by' to search by different information
* Add filter dropdown to filter data
* Add warning about relative absolute URLs
* Add 451, 500, 501, 502, 503, 504 error codes
* Fix multiple 'URL and page type' redirects
* Improve invalid nonce warning
* Encode replaced values in regular expression targets
= 4.3.3 - 8th August 2019 ==
* Add back compatibility fix for URL sanitization
= 4.3.2 - 4th August 2019 ==
* Fix problem with UTF8 characters in a regex URL
* Fix invalid characters causing an error message
* Fix regex not disabled when removed
= 4.3.1 - 8th June 2019 =
* Fix + character being removed from source URL
= 4.3 - 2nd June 2019 =
* Add support for UTF8 URLs without manual encoding
* Add manual database install option
* Add check for pipe character in target URL
* Add warning when problems saving .htaccess file
* Switch from 'x-redirect-agent' to 'x-redirect-by', for WP 5+
* Improve handling of invalid query parameters
* Fix query param name is a number
* Fix redirect with blank target and auto target settings
* Fix monitor trash option applying when deleting a draft
* Fix case insensitivity not applying to query params
* Disable IP grouping when IP option is disabled
* Allow multisite database updates to run when more than 100 sites
= 4.2.3 - 16th Apr 2019 =
* Fix bug with old API routes breaking test
= 4.2.2 - 13th Apr 2019 =
* Improve API checking logic
* Fix '1' being logged for pass-through redirects
= 4.2.1 - 8th Apr 2019 =
* Fix incorrect CSV download link
= 4.2 - 6th Apr 2019 =
* Add auto-complete for target URLs
* Add manual database upgrade
* Add support for semi-colon separated import files
* Add user agent to 404 export
* Add workaround for qTranslate breaking REST API
* Improve API problem detection
* Fix JSON import ignoring group status
= 4.1.1 - 23rd Mar 2019 =
* Remove deprecated PHP
* Fix REST API warning
* Improve WP CLI database output
= 4.1 - 16th Mar 2019 =
* Move 404 export option to import/export page
* Add additional redirect suggestions
* Add import from Rank Math
* Fix 'force https' causing WP to redirect to admin URL when accessing www subdomain
* Fix .htaccess import adding ^ to the source
* Fix handling of double-slashed URLs
* Fix WP CLI on single site
* Add DB upgrade to catch URLs with double-slash URLs
* Remove unnecessary escaped slashes from JSON output
= 4.0.1 - 2nd Mar 2019 =
* Improve styling of query flags
* Match DB upgrade for new match_url to creation script
* Fix upgrade on some hosts where plugin is auto-updated
* Fix pagination button style in WP 5.1
* Fix IP match when action is 'error'
* Fix database upgrade on multisite WP CLI
= 4.0 - 23rd Feb 2019 =
* Add option for case insensitive redirects
* Add option to ignore trailing slashes
* Add option to copy query parameters to target URL
* Add option to ignore query parameters
* Add option to set defaults for case, trailing, and query settings
* Improve upgrade for sites with missing tables
= 3.7.3 - 2nd Feb 2019 =
* Add PHP < 5.4 message on plugins page
* Prevent upgrade message being hidden by other plugins
* Fix warning with regex and no leading slash
* Fix missing display of disabled redirects with a title
* Improve upgrade for sites with a missing IP column
* Improve API detection with plugins that use sessions
* Improve compatibility with ModSecurity
* Improve compatibility with custom API prefix
* Detect site where Redirection was once installed and has settings but no database tables
= 3.7.2 - 16th Jan 2019 =
* Add further partial upgrade detection
* Add fallback for sites with no REST API value
= 3.7.1 - 13th Jan 2019 =
* Clarify database upgrade text
* Fix Firefox problem with multiple URLs
* Fix 3.7 built against wrong dropzone module
* Add DB upgrade detection for people with partial 2.4 sites
= 3.7 - 12th Jan 2019 =
* Add redirect warning for known problem redirects
* Add new database install and upgrade process
* Add database functions to WP CLI
* Add introduction message when first installed
* Drop PHP < 5.4 support. Please use version 3.6.3 if your PHP is too old
* Improve export filename
* Fix IPs appearing for bulk redirect
* Fix disabled redirects appearing in htaccess
= 3.6.3 - 14th November 2018 =
* Remove potential CSRF
= 3.6.2 - 10th November 2018 =
* Add another PHP < 5.4 compat fix
* Fix 'delete all from 404 log' when ungrouped deleting all 404s
* Fix IDs shown in bulk add redirect
= 3.6.1 - 3rd November 2018 =
* Add another PHP < 5.4 fix. Sigh
= 3.6 - 3rd November 2018 =
* Add option to ignore 404s
* Add option to block 404s by IP
* Add grouping of 404s by IP and URL
* Add bulk block or redirect a group of 404s
* Add option to redirect on a 404
* Better page navigation change monitoring
* Add URL & IP match
* Add 303 and 304 redirect codes
* Add 400, 403, and 418 (I'm a teapot!) error codes
* Fix server match not supporting regex properly
* Deprecated file pass through removed
* 'Do nothing' now stops processing further rules
= 3.5 - 23rd September 2018 =
* Add redirect checker on redirects page
* Fix missing translations
* Restore 4.7 backwards compatibility
* Fix unable to delete server name in server match
* Fix error shown when source URL is blank
= 3.4.1 - 9th September 2018 =
* Fix import of WordPress redirects
* Fix incorrect parsing of URLs with 'http' in the path
* Fix 'force ssl' not including path
= 3.4 - 17th July 2018 =
* Add a redirect checker
* Fix incorrect host parsing with server match
* Fix PHP warning with CSV import
* Fix old capability check that was missed from 3.0
= 3.3.1 - 24th June 2018 =
* Add a minimum PHP check for people < 5.4
= 3.3 - 24th June 2018 =
* Add user role/capability match
* Add fix for IP blocking plugins
* Add server match to redirect other domains (beta)
* Add a force http to https option (beta)
* Use users locale setting, not site
* Check for mismatched site/home URLs
* Fix WP CLI not clearing logs
* Fix old capability check
* Detect BOM marker in response
* Improve detection of servers that block content-type json
* Fix incorrect encoding of entities in some locale files
* Fix table navigation parameters not affecting subsequent pages
* Fix .htaccess saving after WordPress redirects
* Fix get_plugin_data error
* Fix canonical redirect problem caused by change in WordPress
* Fix situation that prevented rules cascading
= 3.2 - 11th February 2018 =
* Add cookie match - redirect based on a cookie
* Add HTTP header match - redirect based on an HTTP header
* Add custom filter match - redirect based on a custom WordPress filter
* Add detection of REST API redirect, causing 'fetch error' on some sites
* Update table responsiveness
* Allow redirects for canonical WordPress URLs
* Fix double include error on some sites
* Fix delete action on some sites
* Fix trailing slash redirect of API on some sites
= 3.1.1 - 29th January 2018 =
* Fix problem fetching data on sites without https
= 3.1 - 27th January 2018 =
* Add alternative REST API routes to help servers that block the API
* Move DELETE API calls to POST, to help servers that block DELETE
* Move API nonce to query param, to help servers that don't pass HTTP headers
* Improve error messaging
* Preload support page so it can be used when REST API isn't working
* Fix bug editing Nginx redirects
* Fix import from JSON not setting status
= 3.0.1 - 21st Jan 2018 =
* Don't show warning if per page setting is greater than max
* Don't allow WP REST API to be redirected
= 3.0 - 20th Jan 2018 =
* Add support for IPv6
* Add support for disabling or anonymising IP collection
* Add support for monitoring custom post types
* Add support for monitoring from quick edit mode
* Default to last group used when editing
* Permissions changed from 'administrator' role to 'manage_options' capability
* Swap to WP REST API
* Add new IP map service
* Add new useragent service
* Add 'add new' button to redirect page
* Increase 'title' length
* Fix position not saving on creation
* Fix log pages not remembering table settings
* Fix incorrect column used for HTTP code when importing CSV
* Add support links from inside the plugin
= 2.10.1 - 26th November 2017 =
* Fix incorrect HTTP code reported in errors
* Improve management page hook usage
= 2.10 - 18th November 2017 =
* Add support for WordPress multisite
* Add new Redirection documentation
* Add extra actions when creating redirects
* Fix user agent dropdown not setting agent
= 2.9.2 - 11th November 2017 =
* Fix regex breaking .htaccess export
* Fix error when saving Error or No action
* Restore sortable table headers
= 2.9.1 - 4th November 2017 =
* Fix const issues with PHP 5
= 2.9 - 4th November 2017 =
* Add option to set redirect cache expiry, default 1 hour
* Add a check for unsupported versions of WordPress
* Add check for database tables before starting the plugin
* Improve JSON import memory usage
* Add importers for: Simple 301 Redirects, SEO Redirection, Safe Redirect Manager, and WordPress old post slugs
* Add responsive admin UI
= 2.8.1 - 22nd October 2017 =
* Fix redirect edit not closing after save
* Fix user agent dropdown not auto-selecting regex
* Fix focus to bottom of page on load
* Improve error message when failing to start
* Fix associated redirect appearing at start of URL, not end
= 2.8 - 18th October 2017 =
* Add a fixer to the support page
* Ignore case for imported files
* Fixes for Safari
* Fix WP CLI importing CSV
* Fix monitor not setting HTTP code
* Improve error, random, and pass-through actions
* Fix bug when saving long title
* Add user agent dropdown to user agent match
* Add pages and trashed posts to monitoring
* Add 'associated redirect' option to monitoring, for AMP
* Remove 404 after adding
* Allow search term to apply to deleting logs and 404s
* Deprecate file pass-through, needs to be enabled with REDIRECTION_SUPPORT_PASS_FILE and will be replaced with WP actions
* Further sanitize match data against bad serialization
= 2.7.3 - 26th August 2017 =
* Fix an import regression bug
= 2.7.2 - 25th August 2017 =
* Better IE11 support
* Fix Apache importer
* Show more detailed error messages
* Refactor match code and fix a problem saving referrer & user agent matches
* Fix save button not enabling for certain redirect types
= 2.7.1 - 14th August 2017 =
* Improve display of errors
* Improve handling of CSV
* Reset tables when changing menus
* Change how the page is displayed to reduce change of interference from other plugins
= 2.7 - 6th August 2017 =
* Finish conversion to React
* Add WP CLI support for import/export
* Add a JSON import/export that exports all data
* Edit redirect position
* Apache config moved to options page
* Fix 410 error code
* Fix page limits
* Fix problems with IE/Safari
= 2.6.6 =
* Use React on redirects page
* Use translate.wordpress.org for language files
= 2.6.5 =
* Use React on groups page
= 2.6.4 =
* Add a limit to per page screen options
* Fix warning in referrer match when referrer doesn't exist
* Fix 404 page showing options
* Fix RSS token not regenerating
* 404 and log filters can now avoid logging
* Use React on modules page
= 2.6.3 =
* Use React on log and 404 pages
* Fix log option not saving 'never'
* Additional check for auto-redirect from root
* Fix delete plugin button
* Improve IP detection for Cloudflare
= 2.6.2 =
* Set auto_detect_line_endings when importing CSV
* Replace options page with a fancy React version that looks exactly the same
= 2.6.1 =
* Fix CSV export merging everything into one line
* Fix bug with HTTP codes not being imported from CSV
* Add filters for source and target URLs
* Add filters for log and 404s
* Add filters for request data
* Add filter for monitoring post permalinks
* Fix export of 404 and logs
= 2.6 =
* Show example CSV
* Allow regex and redirect code to be set on import
* Fix a bunch of database installation problems
= 2.5 =
* Fix no group created on install
* Fix missing export key on install
* Add 308 HTTP code, props to radenui
* Fix imported URLs set to regex, props to alpipego
* Fix sorting of URLs, props to JordanReiter
* Don't cache 307s, props to rmarchant
* Abort redirect exit if no redirection happened, props to junc
= 2.4.5 =
* Ensure cleanup code runs even if plugin was updated
* Extra sanitization of Apache & Nginx files, props to Ed Shirey
* Fix regex bug, props to romulodl
* Fix bug in correct group not being shown in dropdown
= 2.4.4 =
* Fix large advanced settings icon
* Add text domain to plugin file, props Bernhard Kau
* Better PHP7 compatibility, props to Ohad Raz
* Better Polylang compatibility, props to imrehg
= 2.4.3 =
* Bump minimum WP to 4.0.0
* Updated German translation, props to Konrad Tadesse
* Additional check when creating redirections in case of bad data
= 2.4.2 =
* Add Gulp task to generate POT file
* Fix a problem with duplicate positions in the redirect table, props to Jon Jensen
* Fix URL monitor not triggering
* Fix CSV export
= 2.4.1 =
* Fix error for people with an unknown module in a group
= 2.4 =
* Reworked modules now no longer stored in database
* Nginx module (experimental)
* View .htaccess/Nginx inline
* Beginnings of some unit tests!
* Fix DB creation on activation, props syntax53
* Updated Japanese locale, props to Naoko
* Remove deprecated like escaping
= 2.3.16 =
* Fix export options not showing for some people
= 2.3.15 =
* Fix error on admin page for WP 4.2
= 2.3.14 =
* Remove error_log statements
* Fix incorrect table name when exporting 404 errors, props to brazenest/synchronos-t
= 2.3.13 =
* Split admin and front-end code out to streamline the loading a bit
* Fix bad groups link when viewing redirects in a group, props to Patrick Fabre
* Improved plugin activation/deactivation and cleanup
* Improved log clearing
= 2.3.12 =
* Persian translation by Danial Hatami
* Fix saving a redirection with login status, referrer, and user agent
* Fix problem where deleting your last group would cause Redirection to only show an error
* Add limits to referrer and destination in the logs
* Redirect title now shows in the main list again. The field is hidden when editing until toggled
* Fix 'bad nonce' error, props to Jonathan Harrell
* Remove old WP code
= 2.3.11 =
* Fix log cleanup options
* More space when editing redirects
* Better detection of regex when importing
* Restore export options
* Fix unnecessary protected
= 2.3.10 =
* Another compatibility fix for PHP < 5.3
* Fix incorrect module ID used when creating a group
* Fix .htaccess duplication, props to Jörg Liwa
= 2.3.9 =
* Compatibility fix for PHP < 5.3
= 2.3.8 =
* Fix plugin activation error
* Fix fatal error in table nav, props to spacedmonkey
= 2.3.7 =
* New redirect page to match WP style
* New module page to match WP style
* Configurable permissions via redirection_role filter, props to RodGer-GR
* Fix saving 2 month log period
* Fix importer
* Fix DB creation to check for existing tables
= 2.3.6 =
* Updated Italian translation, props to Raffaello Tesi
* Updated Romanian translation, props to Flo Bejgu
* Simplify logging options
* Fix log deletion by search term
* Export logs and 404s to CSV
= 2.3.5 =
* Default log settings to 7 days, props to Maura
* Updated Danish translation thanks to Mikael Rieck
* Add per-page screen option for log pages
* Remove all the corners
= 2.3.4 =
* Fix escaping of URL in admin page
= 2.3.3 =
* Fix PHP strict, props to Juliette Folmer
* Fix RSS entry date, props to Juliette
* Fix pagination
= 2.3.2 =
* WP 3.5 compatibility
* Fix export
= 2.3.0 and earlier =
* Remove 404 module and move 404 logs into a separate option
* Clean up log code, using WP_List_Table to power it
* Fix some broken links in admin pages
* Fix order of redirects, thanks to Nicolas Hatier
* Fix XSS in admin menu & referrers log
* Better database compatibility
* Remove warning from VaultPress
* Remove debug from htaccess module
* Fix encoding of JS strings
* Use fgetcsv for CSV importer - better handling
* Allow http as URL parameter
* Props to Ben Noordhuis for a patch
* WordPress 2.9+ only - cleaned up all the old cruft
* Better new-install process
* Upgrades from 1.0 of Redirection no longer supported
* Optimized DB tables
* Change to jQuery
* Nonce protection
* Disable category monitor in 2.7
* Refix log delete
* get_home_path seems not be available for some people
* Update plugin.php to better handle odd directories
* Correct DB install
* Install defaults when no existing redirection setup
* Fix problem with custom post types auto-redirecting (click on 'groups' and then 'modified posts' and clear any entries for '/' from your list)
* Database optimization
* Add patch to disable logs (thanks to Simon Wheatley!)
* Fix for some users with problems deleting redirections
* Fix group edit and log add entry
* Disable category monitoring
* Fix 'you do not permissions' error on some non-English sites
* Fix category change 'quick edit'
* RSS feed token
@@ -0,0 +1,569 @@
<?php
/**
* @phpstan-type DatabaseStatus array{
* status: string|false,
* inProgress: bool,
* current?: string,
* next?: string,
* time?: float,
* manual?: array<int, string>,
* result?: 'ok'|'error',
* reason?: string|false,
* debug?: array<int, string>,
* complete?: int|float
* }
*/
class Red_Database_Status {
// Used in < 3.7 versions of Redirection, but since migrated to general settings
const OLD_DB_VERSION = 'redirection_version';
const DB_UPGRADE_STAGE = 'database_stage';
const RESULT_OK = 'ok';
const RESULT_ERROR = 'error';
const STATUS_OK = 'ok';
const STATUS_NEED_INSTALL = 'need-install';
const STATUS_NEED_UPDATING = 'need-update';
const STATUS_FINISHED_INSTALL = 'finish-install';
const STATUS_FINISHED_UPDATING = 'finish-update';
/**
* Current upgrade stage
*
* @var string|false
*/
private $stage = false;
/**
* List of all upgrade stages
*
* @var array<int, string>
*/
private $stages = [];
/**
* Current database status
*
* @var string|false
*/
private $status = false;
/**
* Result of last operation
*
* @var string|false
*/
private $result = false;
/**
* Reason for current status
*
* @var string|false
*/
private $reason = false;
/**
* Debug information
*
* @var array<int, string>
*/
private $debug = [];
public function __construct() {
$this->status = self::STATUS_OK;
if ( $this->needs_installing() ) {
$this->status = self::STATUS_NEED_INSTALL;
}
$this->load_stage();
if ( $this->needs_updating() ) {
$this->status = self::STATUS_NEED_UPDATING;
}
}
/**
* Load current upgrade stage from options
*
* @return void
*/
public function load_stage(): void {
$settings = Red_Options::get();
if ( isset( $settings[ self::DB_UPGRADE_STAGE ] ) ) {
$stage_data = $settings[ self::DB_UPGRADE_STAGE ];
// Database stage can be set to false to clear it - only process if it's an array
// phpcs:ignore function.alreadyNarrowedType
// @phpstan-ignore function.alreadyNarrowedType
if ( ! is_array( $stage_data ) ) {
return;
}
$this->stage = isset( $stage_data['stage'] ) ? $stage_data['stage'] : false;
$this->stages = isset( $stage_data['stages'] ) ? $stage_data['stages'] : [];
// Only override status if we have a saved upgrade in progress
if ( isset( $stage_data['status'] ) && $stage_data['status'] !== false ) {
$this->status = $stage_data['status'];
}
}
}
/**
* Does the database need install
*
* @return bool true if needs installing, false otherwise
*/
public function needs_installing(): bool {
$settings = Red_Options::get();
if ( $settings['database'] === '' && $this->get_old_version() === false ) {
return true;
}
return false;
}
/**
* Does the current database need updating to the target
*
* @return bool true if needs updating, false otherwise
*/
public function needs_updating(): bool {
// We need updating if we don't need to install, and the current version is less than target version
if ( $this->needs_installing() === false && version_compare( $this->get_current_version(), REDIRECTION_DB_VERSION, '<' ) ) {
return true;
}
// Also if we're still in the process of upgrading
if ( $this->get_current_stage() !== false && $this->status !== self::STATUS_NEED_INSTALL ) {
return true;
}
return false;
}
/**
* Get current database version
*
* @return string Current database version
*/
public function get_current_version(): string {
$settings = Red_Options::get();
if ( $settings['database'] !== '' ) {
if ( $settings['database'] === '+OK' ) {
return REDIRECTION_DB_VERSION;
}
return $settings['database'];
}
$version = $this->get_old_version();
if ( $version !== false && $version !== '' && $version !== '0' && $version !== 0 ) {
// Upgrade the old value
$version = (string) $version;
red_set_options( array( 'database' => $version ) );
delete_option( self::OLD_DB_VERSION );
$this->clear_cache();
return $version;
}
return '';
}
/**
* Get old database version from legacy option
*
* @return mixed
*/
private function get_old_version() {
return get_option( self::OLD_DB_VERSION );
}
/**
* Check if required database tables exist
*
* @return void
*/
public function check_tables_exist(): void {
$latest = Red_Database::get_latest_database();
$missing = $latest->get_missing_tables();
// No tables installed - do a fresh install
if ( count( $missing ) === count( $latest->get_all_tables() ) ) {
delete_option( self::OLD_DB_VERSION );
red_set_options( [ 'database' => '' ] );
$this->clear_cache();
$this->status = self::STATUS_NEED_INSTALL;
$this->stop_update();
} elseif ( count( $missing ) > 0 && version_compare( $this->get_current_version(), '2.3.3', 'ge' ) ) {
// Some tables are missing - try and fill them in
$latest->install();
}
}
/**
* Does the current database support a particular version
*
* @param string $version Target version
* @return bool true if supported, false otherwise
*/
public function does_support( string $version ): bool {
return version_compare( $this->get_current_version(), $version, 'ge' );
}
/**
* Check if last operation resulted in error
*
* @return bool
*/
public function is_error(): bool {
return $this->result === self::RESULT_ERROR;
}
/**
* Set error status
*
* @param string $error Error message.
* @return void
*/
public function set_error( string $error ): void {
global $wpdb;
$this->result = self::RESULT_ERROR;
$this->reason = str_replace( "\t", ' ', $error );
if ( $wpdb->last_error ) {
$this->debug[] = $wpdb->last_error;
if ( strpos( $wpdb->last_error, 'command denied to user' ) !== false ) {
$this->reason .= ' - ' . __( 'Insufficient database permissions detected. Please give your database user appropriate permissions.', 'redirection' );
}
}
$latest = Red_Database::get_latest_database();
$this->debug = array_merge( $this->debug, $latest->get_table_schema() );
$this->debug[] = 'Stage: ' . $this->get_current_stage();
}
/**
* Set success status
*
* @param string $reason Success message.
* @return void
*/
public function set_ok( string $reason ): void {
$this->reason = $reason;
$this->result = self::RESULT_OK;
$this->debug = [];
}
/**
* Stop current upgrade
*
* @return void
*/
public function stop_update(): void {
$this->stage = false;
$this->stages = [];
$this->debug = [];
red_set_options( [ self::DB_UPGRADE_STAGE => false ] );
$this->clear_cache();
}
/**
* Finish upgrade process
*
* @return void
*/
public function finish(): void {
$this->stop_update();
if ( $this->status === self::STATUS_NEED_INSTALL ) {
$this->status = self::STATUS_FINISHED_INSTALL;
} elseif ( $this->status === self::STATUS_NEED_UPDATING ) {
$this->status = self::STATUS_FINISHED_UPDATING;
}
}
/**
* Get current upgrade stage
*
* @return string|false Current stage name, or false if not upgrading
*/
public function get_current_stage() {
return $this->stage;
}
/**
* Move current stage on to the next
*
* @return void
*/
public function set_next_stage(): void {
$this->debug = [];
$stage = $this->get_current_stage();
if ( $stage !== false ) {
$stage = $this->get_next_stage( $stage );
// Save next position
if ( $stage !== false ) {
$this->set_stage( $stage );
} else {
$this->finish();
}
}
}
/**
* Get current upgrade status
*
* @return DatabaseStatus Database status array
*/
public function get_json() {
// Base information
$result = [
'status' => $this->status,
'inProgress' => $this->stage !== false,
];
// Add on version status
if ( $this->status === self::STATUS_NEED_INSTALL || $this->status === self::STATUS_NEED_UPDATING ) {
$result = array_merge(
$result,
$this->get_version_upgrade(),
[ 'manual' => $this->get_manual_upgrade() ]
);
}
// Add on upgrade status
if ( $this->is_error() ) {
$result = array_merge( $result, $this->get_version_upgrade(), $this->get_progress_status(), $this->get_error_status() );
} elseif ( $result['inProgress'] ) {
$result = array_merge( $result, $this->get_progress_status() );
} elseif ( $this->status === self::STATUS_FINISHED_INSTALL || $this->status === self::STATUS_FINISHED_UPDATING ) {
$result['complete'] = 100;
$result['reason'] = $this->reason;
} elseif ( $this->status === self::STATUS_NEED_INSTALL || $this->status === self::STATUS_NEED_UPDATING ) {
// For fresh install/update that hasn't started yet, set initial progress state
$result['complete'] = 0;
$result['result'] = self::RESULT_OK;
$result['reason'] = false;
}
return $result;
}
/**
* Get error status information
*
* @phpstan-return array{reason: string|false, result: 'error', debug: array<int, string>}
* @return array<string, mixed>
*/
private function get_error_status(): array {
return [
'reason' => $this->reason,
'result' => self::RESULT_ERROR,
'debug' => $this->debug,
];
}
/**
* Get progress status information
*
* @return array{complete: int|float, result: 'ok', reason: string|false}
*/
private function get_progress_status() {
$complete = 0;
if ( $this->stage !== false ) {
$total = count( $this->stages );
$pos = array_search( $this->stage, $this->stages, true );
if ( $pos !== false && $total > 0 ) {
$complete = round( ( $pos / $total ) * 100, 1 );
}
}
return [
'complete' => $complete,
'result' => self::RESULT_OK,
'reason' => $this->reason,
];
}
/**
* Get version upgrade information
*
* @phpstan-return array{current: string, next: string, time: float}
* @return array<string, mixed>
*/
private function get_version_upgrade(): array {
return [
'current' => $this->get_current_version() ? $this->get_current_version() : '-',
'next' => REDIRECTION_DB_VERSION,
'time' => microtime( true ),
];
}
/**
* Set the status information for a database upgrade
*
* @param Red_Database_Upgrade[] $upgrades List of upgrade versions.
* @return void
*/
public function start_install( array $upgrades ) {
$this->set_stages( $upgrades );
$this->status = self::STATUS_NEED_INSTALL;
}
/**
* Start database upgrade process
*
* @param Red_Database_Upgrade[] $upgrades List of upgrade versions.
* @return void
*/
public function start_upgrade( array $upgrades ) {
$this->set_stages( $upgrades );
$this->status = self::STATUS_NEED_UPDATING;
}
/**
* Set upgrade stages
*
* @param Red_Database_Upgrade[] $upgrades List of upgrade versions.
* @return void
*/
private function set_stages( array $upgrades ) {
$this->stages = [];
foreach ( $upgrades as $upgrade ) {
$upgrader = Red_Database_Upgrader::get( $upgrade );
$this->stages = array_merge( $this->stages, array_keys( $upgrader->get_stages() ) );
}
if ( count( $this->stages ) > 0 ) {
$this->set_stage( $this->stages[0] );
}
}
/**
* @param string|false $stage
* @return void
*/
public function set_stage( $stage ): void {
$this->stage = $stage;
$this->save_details();
}
/**
* @return void
*/
private function save_details(): void {
$stages = [
self::DB_UPGRADE_STAGE => [
'stage' => $this->stage,
'stages' => $this->stages,
'status' => $this->status,
],
];
red_set_options( $stages );
$this->clear_cache();
}
/**
* @return array<int, string>
*/
private function get_manual_upgrade(): array {
$queries = [];
$database = new Red_Database();
$upgraders = $database->get_upgrades_for_version( $this->get_current_version(), false );
foreach ( $upgraders as $upgrade ) {
$upgrade = Red_Database_Upgrader::get( $upgrade );
$stages = $upgrade->get_stages();
foreach ( array_keys( $stages ) as $stage ) {
$queries = array_merge( $queries, $upgrade->get_queries_for_stage( $stage ) );
}
}
return $queries;
}
/**
* @param string $stage
* @return string|false
*/
private function get_next_stage( string $stage ) {
$database = new Red_Database();
$upgraders = $database->get_upgrades_for_version( $this->get_current_version(), $this->get_current_stage() );
if ( count( $upgraders ) === 0 ) {
$upgraders = $database->get_upgrades_for_version( $this->get_current_version(), false );
}
if ( count( $upgraders ) === 0 ) {
return false;
}
$upgrader = Red_Database_Upgrader::get( $upgraders[0] );
// Where are we in this?
$pos = array_search( $stage, $this->stages, true );
if ( $pos === false ) {
return false;
}
if ( $pos === count( $this->stages ) - 1 ) {
$this->save_db_version( REDIRECTION_DB_VERSION );
return false;
}
// Set current DB version
$current_stages = array_keys( $upgrader->get_stages() );
$current_position = array_search( $stage, $current_stages, true );
if ( $current_position !== false && $current_position === count( $current_stages ) - 1 && isset( $upgraders[1] ) ) {
$this->save_db_version( $upgraders[1]->get_version() );
}
// Move on to next in current version
return $this->stages[ $pos + 1 ];
}
/**
* @param string $version
* @return void
*/
public function save_db_version( string $version ): void {
red_set_options( array( 'database' => $version ) );
delete_option( self::OLD_DB_VERSION );
$this->clear_cache();
}
/**
* @return void
*/
private function clear_cache(): void {
// Clear Red_Options in-memory cache
Red_Options::reset();
// Clear WordPress object cache if available
if ( file_exists( WP_CONTENT_DIR . '/object-cache.php' ) && function_exists( 'wp_cache_flush' ) ) {
wp_cache_flush();
}
}
}
@@ -0,0 +1,44 @@
<?php
class Red_Database_Upgrade {
/**
* @var string
*/
private $version;
/**
* @var string
*/
private $file;
/**
* @var class-string<Red_Database_Upgrader>
*/
private $class;
/**
* @param string $version
* @param string $file
* @param class-string<Red_Database_Upgrader> $class
*/
public function __construct( string $version, string $file, string $class ) {
$this->version = $version;
$this->file = $file;
$this->class = $class;
}
public function get_version(): string {
return $this->version;
}
public function get_file(): string {
return $this->file;
}
/**
* @return class-string<Red_Database_Upgrader>
*/
public function get_class(): string {
return $this->class;
}
}
@@ -0,0 +1,177 @@
<?php
require_once __DIR__ . '/database-upgrade.php';
abstract class Red_Database_Upgrader {
/**
* @var list<string>
*/
private $queries = [];
/**
* @var bool
*/
private $live = true;
/**
* Return an array of all the stages for an upgrade
*
* @return array<string, string> stage name => reason
*/
abstract public function get_stages();
/**
* @param string $stage
* @return string
*/
public function get_reason( string $stage ): string {
$stages = $this->get_stages();
if ( isset( $stages[ $stage ] ) ) {
return $stages[ $stage ];
}
return 'Unknown';
}
/**
* Run a particular stage on the current upgrader
*
* @param Red_Database_Status $status
* @return void
*/
public function perform_stage( Red_Database_Status $status ): void {
global $wpdb;
$stage = $status->get_current_stage();
if ( is_string( $stage ) && $this->has_stage( $stage ) && method_exists( $this, $stage ) ) {
try {
$this->invoke_stage( $stage, $wpdb, true );
$status->set_ok( $this->get_reason( $stage ) );
} catch ( Exception $e ) {
$status->set_error( $e->getMessage() );
}
} else {
$status->set_error( 'No stage found for upgrade ' . $stage );
}
}
/**
* @param string $stage
* @return list<string>
*/
public function get_queries_for_stage( string $stage ): array {
global $wpdb;
$this->queries = [];
$this->live = false;
$this->invoke_stage( $stage, $wpdb, false );
$this->live = true;
return $this->queries;
}
/**
* Returns the current database charset
*
* @return string Database charset
*/
public function get_charset(): string {
global $wpdb;
$charset_collate = '';
if ( ! empty( $wpdb->charset ) ) {
// Fix some common invalid charset values
$fixes = [
'utf-8',
'utf',
];
$charset = $wpdb->charset;
if ( in_array( strtolower( $charset ), $fixes, true ) ) {
$charset = 'utf8';
}
$charset_collate = "DEFAULT CHARACTER SET $charset";
}
if ( ! empty( $wpdb->collate ) ) {
$charset_collate .= " COLLATE=$wpdb->collate";
}
return $charset_collate;
}
/**
* Performs a $wpdb->query, and throws an exception if an error occurs
*
* @param wpdb $wpdb WordPress database instance.
* @param string $sql SQL query.
* @return bool true if query is performed ok, otherwise an exception is thrown
*/
protected function do_query( wpdb $wpdb, string $sql ): bool {
if ( ! $this->live ) {
$this->queries[] = $sql;
return true;
}
// These are known queries without user input
// phpcs:ignore
$result = $wpdb->query( $sql );
if ( $result === false ) {
/* translators: 1: SQL string */
throw new Exception( sprintf( 'Failed to perform query "%s"', $sql ) ); // phpcs:ignore
}
return true;
}
/**
* Load a database upgrader class
*
* @param Red_Database_Upgrade $version
* @return Red_Database_Upgrader Database upgrader
*/
public static function get( Red_Database_Upgrade $version ): Red_Database_Upgrader {
include_once __DIR__ . '/schema/' . str_replace( [ '..', '/' ], '', $version->get_file() );
$class = $version->get_class();
return new $class();
}
/**
* @param string $stage
* @return bool
*/
private function has_stage( string $stage ): bool {
return in_array( $stage, array_keys( $this->get_stages() ), true );
}
/**
* @param string $stage
* @param wpdb $wpdb
* @param bool $live
* @return void
*/
private function invoke_stage( string $stage, wpdb $wpdb, bool $live ): void {
if ( ! method_exists( $this, $stage ) ) {
return;
}
// Methods that accept both $wpdb and $live parameters
// Most stage methods only accept $wpdb, but some (like create_groups) accept both
$two_param_methods = [ 'create_groups' ];
if ( in_array( $stage, $two_param_methods, true ) ) {
/** @var callable(wpdb, bool): void $callable */
$callable = [ $this, $stage ];
call_user_func( $callable, $wpdb, $live );
} else {
/** @var callable(wpdb): void $callable */
$callable = [ $this, $stage ];
call_user_func( $callable, $wpdb );
}
}
}
@@ -0,0 +1,138 @@
<?php
require_once __DIR__ . '/database-status.php';
require_once __DIR__ . '/database-upgrade.php';
require_once __DIR__ . '/database-upgrader.php';
class Red_Database {
/**
* Get all upgrades for a database version
*
* @param string $current_version
* @param string|false $current_stage
* @return list<Red_Database_Upgrade> Array of versions from self::get_upgrades()
*/
public function get_upgrades_for_version( $current_version, $current_stage ) {
if ( empty( $current_version ) ) {
return [
new Red_Database_Upgrade( REDIRECTION_DB_VERSION, 'latest.php', 'Red_Latest_Database' ),
];
}
$upgraders = [];
$found = false;
foreach ( $this->get_upgrades() as $upgrade ) {
if ( ! $found ) {
$upgrader = Red_Database_Upgrader::get( $upgrade );
$stage_present = is_string( $current_stage ) && in_array( $current_stage, array_keys( $upgrader->get_stages() ), true );
$same_version = $current_stage === false && version_compare( $upgrade->get_version(), $current_version, 'gt' );
if ( $stage_present || $same_version ) {
$found = true;
}
}
if ( $found ) {
$upgraders[] = $upgrade;
}
}
return $upgraders;
}
/**
* Apply a particular upgrade stage
*
* @return void
*/
public function apply_upgrade( Red_Database_Status $status ) {
$upgraders = $this->get_upgrades_for_version( $status->get_current_version(), $status->get_current_stage() );
if ( count( $upgraders ) === 0 ) {
$status->set_error( 'No upgrades found for version ' . $status->get_current_version() );
return;
}
if ( $status->get_current_stage() === false ) {
if ( $status->needs_installing() ) {
$status->start_install( $upgraders );
} else {
$status->start_upgrade( $upgraders );
}
}
// Look at first upgrade
$upgrader = Red_Database_Upgrader::get( $upgraders[0] );
// Perform the upgrade
$upgrader->perform_stage( $status );
if ( ! $status->is_error() ) {
$status->set_next_stage();
}
}
/**
* Apply a callback to all sites in a multisite network, or to the current site if not multisite.
*
* @param callable $callback Callback function to apply to each site.
* @return void
*/
public static function apply_to_sites( $callback ) {
if ( is_multisite() && ( is_network_admin() || ( defined( 'WP_CLI' ) && WP_CLI ) ) ) {
$total = get_sites( [ 'count' => true ] );
$per_page = 100;
// Paginate through all sites and apply the callback
for ( $offset = 0; $offset < $total; $offset += $per_page ) {
array_map(
function ( $site ) use ( $callback ) {
switch_to_blog( (int) $site->blog_id );
$callback();
restore_current_blog();
},
get_sites( [ 'number' => $per_page, 'offset' => $offset ] )
);
}
return;
}
$callback();
}
/**
* Get latest database installer
*
* @return Red_Latest_Database Red_Latest_Database
*/
public static function get_latest_database() {
include_once __DIR__ . '/schema/latest.php';
return new Red_Latest_Database();
}
/**
* List of all upgrades and their associated file
*
* @return list<Red_Database_Upgrade> Database upgrade array
*/
public function get_upgrades() {
return [
new Red_Database_Upgrade( '2.0.1', '201.php', 'Red_Database_201' ),
new Red_Database_Upgrade( '2.1.16', '216.php', 'Red_Database_216' ),
new Red_Database_Upgrade( '2.2', '220.php', 'Red_Database_220' ),
new Red_Database_Upgrade( '2.3.1', '231.php', 'Red_Database_231' ),
new Red_Database_Upgrade( '2.3.2', '232.php', 'Red_Database_232' ),
new Red_Database_Upgrade( '2.3.3', '233.php', 'Red_Database_233' ),
new Red_Database_Upgrade( '2.4', '240.php', 'Red_Database_240' ),
new Red_Database_Upgrade( '4.0', '400.php', 'Red_Database_400' ),
new Red_Database_Upgrade( '4.1', '410.php', 'Red_Database_410' ),
new Red_Database_Upgrade( '4.2', '420.php', 'Red_Database_420' ),
];
}
}
@@ -0,0 +1,21 @@
<?php
// Note: not localised as the messages aren't important enough
class Red_Database_201 extends Red_Database_Upgrader {
/**
* @return array<string, string>
*/
public function get_stages() {
return [
'add_title_201' => 'Add titles to redirects',
];
}
/**
* @param \wpdb $wpdb
* @return bool
*/
protected function add_title_201( $wpdb ) {
return $this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_items` ADD `title` varchar(50) NULL" );
}
}
@@ -0,0 +1,37 @@
<?php
// Note: not localised as the messages aren't important enough
class Red_Database_216 extends Red_Database_Upgrader {
/**
* @return array<string, string>
*/
public function get_stages() {
return [
'add_group_indices_216' => 'Add indices to groups',
'add_redirect_indices_216' => 'Add indices to redirects',
];
}
/**
* @param \wpdb $wpdb
* @return bool
*/
protected function add_group_indices_216( $wpdb ) {
$this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_groups` ADD INDEX(module_id)" );
$this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_groups` ADD INDEX(status)" );
return true;
}
/**
* @param \wpdb $wpdb
* @return bool
*/
protected function add_redirect_indices_216( $wpdb ) {
$this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_items` ADD INDEX(url(191))" );
$this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_items` ADD INDEX(status)" );
$this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_items` ADD INDEX(regex)" );
return true;
}
}
@@ -0,0 +1,37 @@
<?php
// Note: not localised as the messages aren't important enough
class Red_Database_220 extends Red_Database_Upgrader {
/**
* @return array<string, string>
*/
public function get_stages() {
return [
'add_group_indices_220' => 'Add group indices to redirects',
'add_log_indices_220' => 'Add indices to logs',
];
}
/**
* @param \wpdb $wpdb
* @return bool
*/
protected function add_group_indices_220( $wpdb ) {
$this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_items` ADD INDEX `group_idpos` (`group_id`,`position`)" );
$this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_items` ADD INDEX `group` (`group_id`)" );
return true;
}
/**
* @param \wpdb $wpdb
* @return bool
*/
protected function add_log_indices_220( $wpdb ) {
$this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_logs` ADD INDEX `created` (`created`)" );
$this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_logs` ADD INDEX `redirection_id` (`redirection_id`)" );
$this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_logs` ADD INDEX `ip` (`ip`)" );
$this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_logs` ADD INDEX `group_id` (`group_id`)" );
$this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_logs` ADD INDEX `module_id` (`module_id`)" );
return true;
}
}
@@ -0,0 +1,53 @@
<?php
// Note: not localised as the messages aren't important enough
class Red_Database_231 extends Red_Database_Upgrader {
/**
* @return array<string, string>
*/
public function get_stages() {
return [
'remove_404_module_231' => 'Remove 404 module',
'create_404_table_231' => 'Create 404 table',
];
}
/**
* @param \wpdb $wpdb
* @return bool
*/
protected function remove_404_module_231( $wpdb ) {
return $this->do_query( $wpdb, "UPDATE {$wpdb->prefix}redirection_groups SET module_id=1 WHERE module_id=3" );
}
/**
* @param \wpdb $wpdb
* @return bool
*/
protected function create_404_table_231( $wpdb ) {
$this->do_query( $wpdb, $this->get_404_table( $wpdb ) );
return true;
}
/**
* @param \wpdb $wpdb
* @return string
*/
private function get_404_table( $wpdb ) {
$charset_collate = $this->get_charset();
return "CREATE TABLE `{$wpdb->prefix}redirection_404` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`created` datetime NOT NULL,
`url` varchar(255) NOT NULL DEFAULT '',
`agent` varchar(255) DEFAULT NULL,
`referrer` varchar(255) DEFAULT NULL,
`ip` int(10) unsigned NOT NULL,
PRIMARY KEY (`id`),
KEY `created` (`created`),
KEY `url` (`url`(191)),
KEY `ip` (`ip`),
KEY `referrer` (`referrer`(191))
) $charset_collate";
}
}
@@ -0,0 +1,22 @@
<?php
// Note: not localised as the messages aren't important enough
class Red_Database_232 extends Red_Database_Upgrader {
/**
* @return array<string, string>
*/
public function get_stages() {
return [
'remove_modules_232' => 'Remove module table',
];
}
/**
* @param \wpdb $wpdb
* @return bool
*/
protected function remove_modules_232( $wpdb ) {
$this->do_query( $wpdb, "DROP TABLE IF EXISTS {$wpdb->prefix}redirection_modules" );
return true;
}
}
@@ -0,0 +1,24 @@
<?php
// Note: not localised as the messages aren't important enough
class Red_Database_233 extends Red_Database_Upgrader {
/**
* @return array<string, string>
*/
public function get_stages() {
return [
'fix_invalid_groups_233' => 'Migrate any groups with invalid module ID',
];
}
/**
* @param \wpdb $wpdb
* @return bool
*/
protected function fix_invalid_groups_233( $wpdb ) {
$this->do_query( $wpdb, "UPDATE {$wpdb->prefix}redirection_groups SET module_id=1 WHERE module_id > 2" );
$latest = Red_Database::get_latest_database();
return $latest->create_groups( $wpdb );
}
}
@@ -0,0 +1,119 @@
<?php
/**
* There are several problems with 2.3.3 => 2.4 that this attempts to cope with:
* - some sites have a misconfigured IP column
* - some sites don't have any IP column
*/
class Red_Database_240 extends Red_Database_Upgrader {
/**
* @return array<string, string>
*/
public function get_stages() {
return [
'convert_int_ip_to_varchar_240' => 'Convert integer IP values to support IPv6',
'expand_log_ip_column_240' => 'Expand IP size in logs to support IPv6',
'convert_title_to_text_240' => 'Expand size of redirect titles',
'add_missing_index_240' => 'Add missing IP index to 404 logs',
];
}
/**
* @param \wpdb $wpdb
* @return bool
*/
private function has_ip_index( $wpdb ) {
$wpdb->hide_errors();
$existing = $wpdb->get_row( "SHOW CREATE TABLE `{$wpdb->prefix}redirection_404`", ARRAY_N );
$wpdb->show_errors();
if ( isset( $existing[1] ) && strpos( strtolower( $existing[1] ), 'key `ip` (' ) !== false ) {
return true;
}
return false;
}
/**
* @param \wpdb $wpdb
* @return bool
*/
protected function has_varchar_ip( $wpdb ) {
$wpdb->hide_errors();
$existing = $wpdb->get_row( "SHOW CREATE TABLE `{$wpdb->prefix}redirection_404`", ARRAY_N );
$wpdb->show_errors();
if ( isset( $existing[1] ) && strpos( strtolower( $existing[1] ), '`ip` varchar(45)' ) !== false ) {
return true;
}
return false;
}
/**
* @param \wpdb $wpdb
* @return bool
*/
protected function has_int_ip( $wpdb ) {
$wpdb->hide_errors();
$existing = $wpdb->get_row( "SHOW CREATE TABLE `{$wpdb->prefix}redirection_404`", ARRAY_N );
$wpdb->show_errors();
if ( isset( $existing[1] ) && strpos( strtolower( $existing[1] ), '`ip` int' ) !== false ) {
return true;
}
return false;
}
/**
* @param \wpdb $wpdb
* @return bool
*/
protected function convert_int_ip_to_varchar_240( $wpdb ) {
if ( $this->has_int_ip( $wpdb ) ) {
$this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_404` ADD `ipaddress` VARCHAR(45) DEFAULT NULL AFTER `ip`" );
$this->do_query( $wpdb, "UPDATE {$wpdb->prefix}redirection_404 SET ipaddress=INET_NTOA(ip)" );
$this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_404` DROP `ip`" );
return $this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_404` CHANGE `ipaddress` `ip` VARCHAR(45) DEFAULT NULL" );
}
return true;
}
/**
* @param \wpdb $wpdb
* @return bool
*/
protected function expand_log_ip_column_240( $wpdb ) {
return $this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_logs` CHANGE `ip` `ip` VARCHAR(45) DEFAULT NULL" );
}
/**
* @param \wpdb $wpdb
* @return bool
*/
protected function add_missing_index_240( $wpdb ) {
if ( $this->has_ip_index( $wpdb ) ) {
// Remove index
$this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_404` DROP INDEX ip" );
}
// Ensure we have an IP column
$this->convert_int_ip_to_varchar_240( $wpdb );
if ( ! $this->has_varchar_ip( $wpdb ) ) {
$this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_404` ADD `ip` VARCHAR(45) DEFAULT NULL" );
}
// Finally add the index
return $this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_404` ADD INDEX `ip` (`ip`)" );
}
/**
* @param \wpdb $wpdb
* @return bool
*/
protected function convert_title_to_text_240( $wpdb ) {
return $this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_items` CHANGE `title` `title` text" );
}
}
@@ -0,0 +1,115 @@
<?php
class Red_Database_400 extends Red_Database_Upgrader {
/**
* @return array<string, string>
*/
public function get_stages() {
return [
'add_match_url_400' => 'Add a matched URL column',
'add_match_url_index' => 'Add match URL index',
'add_redirect_data_400' => 'Add column to store new flags',
'convert_existing_urls_400' => 'Convert existing URLs to new format',
];
}
/**
* @param \wpdb $wpdb
* @param string $column
* @return bool
*/
private function has_column( $wpdb, $column ) {
$wpdb->hide_errors();
$wpdb->suppress_errors( true );
$existing = $wpdb->get_row( "SHOW CREATE TABLE `{$wpdb->prefix}redirection_items`", ARRAY_N );
$wpdb->show_errors();
$wpdb->suppress_errors( false );
if ( isset( $existing[1] ) && strpos( strtolower( $existing[1] ), strtolower( $column ) ) !== false ) {
return true;
}
return false;
}
/**
* @param \wpdb $wpdb
* @return bool
*/
private function has_match_index( $wpdb ) {
$wpdb->hide_errors();
$wpdb->suppress_errors( true );
$existing = $wpdb->get_row( "SHOW CREATE TABLE `{$wpdb->prefix}redirection_items`", ARRAY_N );
$wpdb->show_errors();
$wpdb->suppress_errors( false );
if ( isset( $existing[1] ) && strpos( strtolower( $existing[1] ), 'key `match_url' ) !== false ) {
return true;
}
return false;
}
/**
* @param \wpdb $wpdb
* @return bool
*/
protected function add_match_url_400( $wpdb ) {
if ( ! $this->has_column( $wpdb, '`match_url` varchar(2000)' ) ) {
return $this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_items` ADD `match_url` VARCHAR(2000) NULL DEFAULT NULL AFTER `url`" );
}
return true;
}
/**
* @param \wpdb $wpdb
* @return bool|void
*/
protected function add_match_url_index( $wpdb ) {
if ( ! $this->has_match_index( $wpdb ) ) {
return $this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_items` ADD INDEX `match_url` (`match_url`(191))" );
}
}
/**
* @param \wpdb $wpdb
* @return bool
*/
protected function add_redirect_data_400( $wpdb ) {
if ( ! $this->has_column( $wpdb, '`match_data` TEXT' ) ) {
return $this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_items` ADD `match_data` TEXT NULL DEFAULT NULL AFTER `match_url`" );
}
return true;
}
/**
* @param \wpdb $wpdb
* @return bool
*/
protected function convert_existing_urls_400( $wpdb ) {
// All regex get match_url=regex
$this->do_query( $wpdb, "UPDATE `{$wpdb->prefix}redirection_items` SET match_url='regex' WHERE regex=1" );
// Remove query part from all URLs and lowercase
$this->do_query( $wpdb, "UPDATE `{$wpdb->prefix}redirection_items` SET match_url=LOWER(url) WHERE regex=0" );
// Set exact match if query param present
$table_name = $wpdb->prefix . 'redirection_items';
// phpstan requires literal-string but table prefix must be dynamic for WordPress multisite
/** @phpstan-ignore argument.type */
$prepared = $wpdb->prepare( "UPDATE `{$table_name}` SET match_data=%s WHERE regex=0 AND match_url LIKE %s", '{"source":{"flag_query":"exactorder"}}', '%?%' ); // phpcs:ignore
if ( $prepared !== null ) {
$this->do_query( $wpdb, $prepared );
}
// Trim the last / from a URL
$this->do_query( $wpdb, "UPDATE `{$wpdb->prefix}redirection_items` SET match_url=LEFT(match_url,LENGTH(match_url)-1) WHERE regex=0 AND match_url != '/' AND RIGHT(match_url, 1) = '/'" );
$this->do_query( $wpdb, "UPDATE `{$wpdb->prefix}redirection_items` SET match_url=REPLACE(match_url, '/?', '?') WHERE regex=0" );
// Any URL that is now empty becomes /
return $this->do_query( $wpdb, "UPDATE `{$wpdb->prefix}redirection_items` SET match_url='/' WHERE match_url=''" );
}
}
@@ -0,0 +1,24 @@
<?php
class Red_Database_410 extends Red_Database_Upgrader {
/**
* @return array<string, string>
*/
public function get_stages() {
return [
'handle_double_slash' => 'Support double-slash URLs',
];
}
/**
* @param \wpdb $wpdb
* @return bool
*/
protected function handle_double_slash( $wpdb ) {
// Update any URL with a double slash at the end
$this->do_query( $wpdb, "UPDATE `{$wpdb->prefix}redirection_items` SET match_url=LOWER(LEFT(SUBSTRING_INDEX(url, '?', 1),LENGTH(SUBSTRING_INDEX(url, '?', 1)) - 1)) WHERE RIGHT(SUBSTRING_INDEX(url, '?', 1), 2) = '//' AND regex=0" );
// Any URL that is now empty becomes /
return $this->do_query( $wpdb, "UPDATE `{$wpdb->prefix}redirection_items` SET match_url='/' WHERE match_url=''" );
}
}
@@ -0,0 +1,134 @@
<?php
class Red_Database_420 extends Red_Database_Upgrader {
/**
* @return array<string, string>
*/
public function get_stages() {
return [
'add_extra_logging' => 'Add extra logging support',
'remove_module_id' => 'Remove module ID from logs',
'remove_group_id' => 'Remove group ID from logs',
'add_extra_404' => 'Add extra 404 logging support',
];
}
/**
* @param \wpdb $wpdb
* @return bool
*/
protected function remove_module_id( $wpdb ) {
if ( ! $this->has_module_id( $wpdb ) ) {
return true;
}
return $this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_logs` DROP `module_id`" );
}
/**
* @param \wpdb $wpdb
* @return bool
*/
protected function remove_group_id( $wpdb ) {
if ( ! $this->has_group_id( $wpdb ) ) {
return true;
}
return $this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_logs` DROP `group_id`" );
}
/**
* @param \wpdb $wpdb
* @return bool
*/
private function has_module_id( $wpdb ) {
$existing = $wpdb->get_row( "SHOW CREATE TABLE `{$wpdb->prefix}redirection_logs`", ARRAY_N );
if ( isset( $existing[1] ) && strpos( strtolower( $existing[1] ), 'module_id' ) !== false ) {
return true;
}
return false;
}
/**
* @param \wpdb $wpdb
* @return bool
*/
private function has_group_id( $wpdb ) {
$existing = $wpdb->get_row( "SHOW CREATE TABLE `{$wpdb->prefix}redirection_logs`", ARRAY_N );
if ( isset( $existing[1] ) && strpos( strtolower( $existing[1] ), 'group_id' ) !== false ) {
return true;
}
return false;
}
/**
* @param \wpdb $wpdb
* @return bool
*/
private function has_log_domain( $wpdb ) {
$existing = $wpdb->get_row( "SHOW CREATE TABLE `{$wpdb->prefix}redirection_logs`", ARRAY_N );
if ( isset( $existing[1] ) && strpos( strtolower( $existing[1] ), 'domain` varchar' ) !== false ) {
return true;
}
return false;
}
/**
* @param \wpdb $wpdb
* @return bool
*/
private function has_404_domain( $wpdb ) {
$existing = $wpdb->get_row( "SHOW CREATE TABLE `{$wpdb->prefix}redirection_404`", ARRAY_N );
if ( isset( $existing[1] ) && strpos( strtolower( $existing[1] ), 'domain` varchar' ) !== false ) {
return true;
}
return false;
}
/**
* @param \wpdb $wpdb
* @return bool
*/
protected function add_extra_logging( $wpdb ) {
if ( $this->has_log_domain( $wpdb ) ) {
return true;
}
// Update any URL with a double slash at the end
$this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_logs` ADD `domain` VARCHAR(255) NULL DEFAULT NULL AFTER `url`" );
$this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_logs` ADD `http_code` INT(11) unsigned NOT NULL DEFAULT 0 AFTER `referrer`" );
$this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_logs` ADD `request_method` VARCHAR(10) NULL DEFAULT NULL AFTER `http_code`" );
$this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_logs` ADD `redirect_by` VARCHAR(50) NULL DEFAULT NULL AFTER `request_method`" );
$this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_logs` ADD `request_data` MEDIUMTEXT NULL DEFAULT NULL AFTER `request_method`" );
return $this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_logs` CHANGE COLUMN `agent` `agent` MEDIUMTEXT NULL" );
}
/**
* @param \wpdb $wpdb
* @return bool
*/
protected function add_extra_404( $wpdb ) {
if ( $this->has_404_domain( $wpdb ) ) {
return true;
}
// Update any URL with a double slash at the end
$this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_404` ADD `domain` VARCHAR(255) NULL DEFAULT NULL AFTER `url`" );
$this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_404` ADD `http_code` INT(11) unsigned NOT NULL DEFAULT 0 AFTER `referrer`" );
$this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_404` ADD `request_method` VARCHAR(10) NULL DEFAULT NULL AFTER `http_code`" );
$this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_404` ADD `request_data` MEDIUMTEXT NULL DEFAULT NULL AFTER `request_method`" );
// Same as log table
$this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_404` DROP INDEX `url`" );
return $this->do_query( $wpdb, "ALTER TABLE `{$wpdb->prefix}redirection_404` CHANGE COLUMN `url` `url` MEDIUMTEXT NOT NULL" );
}
}
@@ -0,0 +1,313 @@
<?php
/**
* Latest database schema
*/
class Red_Latest_Database extends Red_Database_Upgrader {
/**
* @return array<string, string>
*/
public function get_stages() {
return [
/* translators: displayed when installing the plugin */
'create_tables' => __( 'Install Redirection tables', 'redirection' ),
/* translators: displayed when installing the plugin */
'create_groups' => __( 'Create basic data', 'redirection' ),
];
}
/**
* Install the latest database
*
* @return bool|WP_Error true if installed, WP_Error otherwise
*/
/**
* @return bool|\WP_Error
*/
public function install() {
global $wpdb;
foreach ( $this->get_stages() as $stage => $info ) {
// @phpstan-ignore method.dynamicName
$result = $this->$stage( $wpdb );
if ( is_wp_error( $result ) ) {
if ( $wpdb->last_error ) {
$result->add_data( $wpdb->last_error );
}
return $result;
}
}
red_set_options( array( 'database' => REDIRECTION_DB_VERSION ) );
return true;
}
/**
* Remove the database and any options (including unused ones)
*/
/**
* @return void
*/
public function remove() {
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}redirection_items" );
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}redirection_logs" );
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}redirection_groups" );
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}redirection_modules" );
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}redirection_404" );
delete_option( 'redirection_lookup' );
delete_option( 'redirection_post' );
delete_option( 'redirection_root' );
delete_option( 'redirection_index' );
delete_option( 'redirection_options' );
delete_option( Red_Database_Status::OLD_DB_VERSION );
}
/**
* Return any tables that are missing from the database
*
* @return array Array of missing table names
*/
/**
* @return list<string>
*/
public function get_missing_tables() {
global $wpdb;
$tables = array_keys( $this->get_all_tables() );
$missing = [];
foreach ( $tables as $table ) {
$result = $wpdb->query( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
if ( intval( $result, 10 ) !== 1 ) {
$missing[] = $table;
}
}
return $missing;
}
/**
* Get table schema for latest database tables
*
* @return array Database schema array
*/
/**
* @return list<string>
*/
public function get_table_schema() {
global $wpdb;
$tables = array_keys( $this->get_all_tables() );
$show = array();
foreach ( $tables as $table ) {
// Suppress errors when checking for table existence
$wpdb->hide_errors();
$wpdb->suppress_errors( true );
// These are known queries without user input
// phpcs:ignore
$row = $wpdb->get_row( 'SHOW CREATE TABLE ' . $table, ARRAY_N );
$wpdb->show_errors();
$wpdb->suppress_errors( false );
if ( $row ) {
$show = array_merge( $show, explode( "\n", $row[1] ) );
$show[] = '';
} else {
/* translators: 1: table name */
$show[] = sprintf( __( 'Table "%s" is missing', 'redirection' ), $table );
}
}
return $show;
}
/**
* Return array of table names and table schema
*
* @return array
*/
/**
* @return array<string, string>
*/
public function get_all_tables() {
global $wpdb;
$charset_collate = $this->get_charset();
return array(
"{$wpdb->prefix}redirection_items" => $this->create_items_sql( $wpdb->prefix, $charset_collate ),
"{$wpdb->prefix}redirection_groups" => $this->create_groups_sql( $wpdb->prefix, $charset_collate ),
"{$wpdb->prefix}redirection_logs" => $this->create_log_sql( $wpdb->prefix, $charset_collate ),
"{$wpdb->prefix}redirection_404" => $this->create_404_sql( $wpdb->prefix, $charset_collate ),
);
}
/**
* Creates default group information
*/
/**
* @param \wpdb $wpdb
* @param bool $is_live
* @return bool
*/
public function create_groups( $wpdb, $is_live = true ) {
if ( ! $is_live ) {
return true;
}
$defaults = [
[
'name' => __( 'Redirections', 'redirection' ),
'module_id' => 1,
'position' => 0,
],
[
'name' => __( 'Modified Posts', 'redirection' ),
'module_id' => 1,
'position' => 1,
],
];
$existing_groups = $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}redirection_groups" );
// Default groups
if ( intval( $existing_groups, 10 ) === 0 ) {
$wpdb->insert( $wpdb->prefix . 'redirection_groups', $defaults[0] );
$wpdb->insert( $wpdb->prefix . 'redirection_groups', $defaults[1] );
}
$group = $wpdb->get_row( "SELECT * FROM {$wpdb->prefix}redirection_groups LIMIT 1" );
if ( $group !== null ) {
red_set_options( array( 'last_group_id' => $group->id ) );
}
return true;
}
/**
* Creates all the tables
*/
/**
* @param \wpdb $wpdb
* @return bool
*/
public function create_tables( $wpdb ) {
global $wpdb;
foreach ( $this->get_all_tables() as $table => $sql ) {
$sql = (string) preg_replace( '/[ \t]{2,}/', '', $sql );
$this->do_query( $wpdb, $sql );
}
return true;
}
/**
* @param string $prefix
* @param string $charset_collate
* @return string
*/
private function create_items_sql( $prefix, $charset_collate ) {
return "CREATE TABLE IF NOT EXISTS `{$prefix}redirection_items` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`url` mediumtext NOT NULL,
`match_url` VARCHAR(2000) DEFAULT NULL,
`match_data` TEXT DEFAULT NULL,
`regex` INT(11) unsigned NOT NULL DEFAULT 0,
`position` INT(11) unsigned NOT NULL DEFAULT 0,
`last_count` INT(10) unsigned NOT NULL DEFAULT 0,
`last_access` datetime NOT NULL DEFAULT '1970-01-01 00:00:00',
`group_id` INT(11) NOT NULL DEFAULT 0,
`status` enum('enabled','disabled') NOT NULL DEFAULT 'enabled',
`action_type` VARCHAR(20) NOT NULL,
`action_code` INT(11) unsigned NOT NULL,
`action_data` MEDIUMTEXT DEFAULT NULL,
`match_type` VARCHAR(20) NOT NULL,
`title` TEXT DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `url` (`url`(191)),
KEY `status` (`status`),
KEY `regex` (`regex`),
KEY `group_idpos` (`group_id`,`position`),
KEY `group` (`group_id`),
KEY `match_url` (`match_url`(191))
) $charset_collate";
}
/**
* @param string $prefix
* @param string $charset_collate
* @return string
*/
private function create_groups_sql( $prefix, $charset_collate ) {
return "CREATE TABLE IF NOT EXISTS `{$prefix}redirection_groups` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` VARCHAR(50) NOT NULL,
`tracking` INT(11) NOT NULL DEFAULT 1,
`module_id` INT(11) unsigned NOT NULL DEFAULT 0,
`status` enum('enabled','disabled') NOT NULL DEFAULT 'enabled',
`position` INT(11) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `module_id` (`module_id`),
KEY `status` (`status`)
) $charset_collate";
}
/**
* @param string $prefix
* @param string $charset_collate
* @return string
*/
private function create_log_sql( $prefix, $charset_collate ) {
return "CREATE TABLE IF NOT EXISTS `{$prefix}redirection_logs` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`created` datetime NOT NULL,
`url` MEDIUMTEXT NOT NULL,
`domain` VARCHAR(255) DEFAULT NULL,
`sent_to` MEDIUMTEXT DEFAULT NULL,
`agent` MEDIUMTEXT DEFAULT NULL,
`referrer` MEDIUMTEXT DEFAULT NULL,
`http_code` INT(11) unsigned NOT NULL DEFAULT 0,
`request_method` VARCHAR(10) DEFAULT NULL,
`request_data` MEDIUMTEXT DEFAULT NULL,
`redirect_by` VARCHAR(50) DEFAULT NULL,
`redirection_id` INT(11) unsigned DEFAULT NULL,
`ip` VARCHAR(45) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `created` (`created`),
KEY `redirection_id` (`redirection_id`),
KEY `ip` (`ip`)
) $charset_collate";
}
/**
* @param string $prefix
* @param string $charset_collate
* @return string
*/
private function create_404_sql( $prefix, $charset_collate ) {
return "CREATE TABLE IF NOT EXISTS `{$prefix}redirection_404` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`created` datetime NOT NULL,
`url` MEDIUMTEXT NOT NULL,
`domain` VARCHAR(255) DEFAULT NULL,
`agent` VARCHAR(255) DEFAULT NULL,
`referrer` VARCHAR(255) DEFAULT NULL,
`http_code` INT(11) unsigned NOT NULL DEFAULT 0,
`request_method` VARCHAR(10) DEFAULT NULL,
`request_data` MEDIUMTEXT DEFAULT NULL,
`ip` VARCHAR(45) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `created` (`created`),
KEY `referrer` (`referrer`(191)),
KEY `ip` (`ip`)
) $charset_collate";
}
}
@@ -0,0 +1,234 @@
<?php
/**
* @phpstan-import-type GroupJson from Red_Group
*/
class Red_Apache_File extends Red_FileIO {
public function force_download() {
parent::force_download();
header( 'Content-Type: application/octet-stream' );
header( 'Content-Disposition: attachment; filename="' . $this->export_filename( 'htaccess' ) . '"' );
}
/**
* @param array<Red_Item> $items
* @param array<GroupJson> $groups
* @return string
*/
public function get_data( array $items, array $groups ) {
include_once dirname( __DIR__ ) . '/models/htaccess.php';
$htaccess = new Red_Htaccess();
foreach ( $items as $item ) {
$htaccess->add( $item );
}
return $htaccess->get() . PHP_EOL;
}
/**
* @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
*/
public function load( $group, $filename, $data ) {
if ( $data === false ) {
return 0;
}
// Remove any comments
$data = str_replace( "\n", "\r", $data );
// Split it into lines
$lines = array_filter( explode( "\r", $data ) );
$count = 0;
foreach ( $lines as $line ) {
$item = $this->get_as_item( $line );
if ( $item !== false ) {
$item['group_id'] = $group;
$redirect = Red_Item::create( $item );
if ( ! is_wp_error( $redirect ) ) {
$count++;
}
}
}
return $count;
}
/**
* @param string $line
* @return array<string, mixed>|false
*/
public function get_as_item( $line ) {
$item = false;
if ( preg_match( '@rewriterule\s+(.*?)\s+(.*?)\s+(\[.*\])*@i', $line, $matches ) > 0 ) {
$item = array(
'url' => $this->regex_url( $matches[1] ),
'match_type' => 'url',
'action_type' => 'url',
'action_data' => array( 'url' => $this->decode_url( $matches[2] ) ),
'action_code' => $this->get_code( isset( $matches[3] ) ? $matches[3] : '' ),
'regex' => $this->is_regex( $matches[1] ),
);
} elseif ( preg_match( '@Redirect\s+(.*?)\s+"(.*?)"\s+(.*)@i', $line, $matches ) > 0 || preg_match( '@Redirect\s+(.*?)\s+(.*?)\s+(.*)@i', $line, $matches ) > 0 ) {
$item = array(
'url' => $this->decode_url( $matches[2] ),
'match_type' => 'url',
'action_type' => 'url',
'action_data' => array( 'url' => $this->decode_url( $matches[3] ) ),
'action_code' => $this->get_code( $matches[1] ),
);
} elseif ( preg_match( '@Redirect\s+"(.*?)"\s+(.*)@i', $line, $matches ) > 0 || preg_match( '@Redirect\s+(.*?)\s+(.*)@i', $line, $matches ) > 0 ) {
$item = array(
'url' => $this->decode_url( $matches[1] ),
'match_type' => 'url',
'action_type' => 'url',
'action_data' => array( 'url' => $this->decode_url( $matches[2] ) ),
'action_code' => 302,
);
} elseif ( preg_match( '@Redirectmatch\s+(.*?)\s+(.*?)\s+(.*)@i', $line, $matches ) > 0 ) {
$item = array(
'url' => $this->decode_url( $matches[2] ),
'match_type' => 'url',
'action_type' => 'url',
'action_data' => array( 'url' => $this->decode_url( $matches[3] ) ),
'action_code' => $this->get_code( $matches[1] ),
'regex' => true,
);
} elseif ( preg_match( '@Redirectmatch\s+(.*?)\s+(.*)@i', $line, $matches ) > 0 ) {
$item = array(
'url' => $this->decode_url( $matches[1] ),
'match_type' => 'url',
'action_type' => 'url',
'action_data' => array( 'url' => $this->decode_url( $matches[2] ) ),
'action_code' => 302,
'regex' => true,
);
}
if ( $item !== false ) {
$item['action_type'] = 'url';
$item['match_type'] = 'url';
if ( $item['action_code'] === 0 ) {
$item['action_type'] = 'pass';
}
return $item;
}
return false;
}
/**
* @param string $url
* @return string
*/
private function decode_url( $url ) {
$url = rawurldecode( $url );
// Replace quoted slashes
$url = (string) preg_replace( '@\\\/@', '/', $url );
// Ensure escaped '.' is still escaped
$url = (string) preg_replace( '@\\\\.@', '\\\\.', $url );
return $url;
}
/**
* @param string $url
* @return bool
*/
private function is_str_regex( $url ) {
$regex = '()[]$^?+.';
$len = strlen( $url );
for ( $x = 0; $x < $len; $x++ ) {
$escape = false;
$char = substr( $url, $x, 1 );
if ( $char === '\\' ) {
$escape = true;
} elseif ( strpos( $regex, $char ) !== false ) {
return true;
}
}
return false;
}
/**
* @param string $url
* @return bool
*/
private function is_regex( $url ) {
if ( $this->is_str_regex( $url ) ) {
$tmp = ltrim( $url, '^' );
$tmp = rtrim( $tmp, '$' );
if ( $this->is_str_regex( $tmp ) ) {
return true;
}
}
return false;
}
/**
* @param string $url
* @return string
*/
private function regex_url( $url ) {
$url = $this->decode_url( $url );
if ( $this->is_str_regex( $url ) ) {
$tmp = ltrim( $url, '^' );
$tmp = rtrim( $tmp, '$' );
if ( $this->is_str_regex( $tmp ) ) {
return '^/' . ltrim( $tmp, '/' );
}
return '/' . ltrim( $tmp, '/' );
}
return $this->decode_url( $url );
}
/**
* @param string $code
* @return int
*/
private function get_code( $code ) {
if ( strpos( $code, '301' ) !== false || stripos( $code, 'permanent' ) !== false ) {
return 301;
}
if ( strpos( $code, '302' ) !== false ) {
return 302;
}
if ( strpos( $code, '307' ) !== false || stripos( $code, 'seeother' ) !== false ) {
return 307;
}
if ( strpos( $code, '404' ) !== false || stripos( $code, 'forbidden' ) !== false || strpos( $code, 'F' ) !== false ) {
return 404;
}
if ( strpos( $code, '410' ) !== false || stripos( $code, 'gone' ) !== false || strpos( $code, 'G' ) !== false ) {
return 410;
}
return 302;
}
}
@@ -0,0 +1,254 @@
<?php
/**
* CSV import/export handler
*
* @phpstan-type CsvItem array{
* url: string,
* action_data: array{url: string},
* regex: bool,
* group_id: int,
* match_type: 'url',
* action_type: 'url'|'error',
* action_code: int,
* status?: 'enabled'|'disabled'
* }
* @phpstan-import-type GroupJson from Red_Group
*/
class Red_Csv_File extends Red_FileIO {
const CSV_SOURCE = 0;
const CSV_TARGET = 1;
const CSV_REGEX = 2;
const CSV_CODE = 3;
public function force_download() {
parent::force_download();
header( 'Content-Type: text/csv' );
header( 'Content-Disposition: attachment; filename="' . $this->export_filename( 'csv' ) . '"' );
}
/**
* @param array<Red_Item> $items
* @param array<GroupJson> $groups
* @return string
*/
public function get_data( array $items, array $groups ) {
$lines = [ implode( ',', array( 'source', 'target', 'regex', 'code', 'type', 'hits', 'title', 'status' ) ) ];
foreach ( $items as $line ) {
$lines[] = $this->item_as_csv( $line );
}
return implode( PHP_EOL, $lines ) . PHP_EOL;
}
/**
* @param Red_Item $item
* @return string
*/
public function item_as_csv( $item ) {
$data = [];
if ( $item->match !== null ) {
$data = $item->match->get_data();
}
if ( isset( $data['url'] ) ) {
$data = $data['url'];
} else {
$data = '/unknown';
}
if ( $item->get_action_code() > 400 && $item->get_action_code() < 500 ) {
$data = '';
}
$csv = array(
$item->get_url(),
$data,
$item->is_regex() ? 1 : 0,
$item->get_action_code(),
$item->get_action_type(),
$item->get_hits(),
$item->get_title(),
$item->is_enabled() ? 'active' : 'disabled',
);
$csv = array_map( array( $this, 'escape_csv' ), $csv );
return implode( ',', $csv );
}
/**
* @param string|int|float $item
* @return string|int|float
*/
public function escape_csv( $item ) {
if ( is_numeric( $item ) ) {
return $item;
}
return '"' . str_replace( '"', '""', $item ) . '"';
}
/**
* @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
*/
public function load( $group, $filename, $data ) {
$file = fopen( $filename, 'r' );
if ( $file !== false ) {
$separators = [
',',
';',
'|',
];
foreach ( $separators as $separator ) {
fseek( $file, 0 );
$count = $this->load_from_file( $group, $file, $separator );
if ( $count > 0 ) {
return $count;
}
}
}
return 0;
}
/**
* @param int $group_id
* @param resource $file
* @param string $separator
* @return int
*/
public function load_from_file( $group_id, $file, $separator ) {
global $wpdb;
$count = 0;
$group = Red_Group::get( $group_id );
if ( $group === false ) {
return 0;
}
/** @var Red_Group $group */
while ( ( $csv = fgetcsv( $file, 5000, $separator ) ) !== false ) {
if ( $csv === null ) {
continue;
}
/** @var array<int, string> $csv */
$csv = array_map(
function ( $v ) {
return (string) $v;
},
$csv
);
$item = $this->csv_as_item( $csv, $group );
if ( $item !== false && $this->item_is_valid( $item ) ) {
$created = Red_Item::create( $item );
// The query log can use up all the memory
$wpdb->queries = [];
if ( ! is_wp_error( $created ) ) {
$count++;
}
}
}
return $count;
}
/**
* @param CsvItem $csv
* @return bool
*/
private function item_is_valid( array $csv ) {
if ( strlen( $csv['url'] ) === 0 ) {
return false;
}
if ( $csv['action_data']['url'] === $csv['url'] ) {
return false;
}
return true;
}
/**
* @param mixed $code
* @return int
*/
private function get_valid_code( $code ) {
if ( get_status_header_desc( $code ) !== '' ) {
return intval( $code, 10 );
}
return 301;
}
/**
* @param int $code
* @return 'url'|'error'
*/
private function get_action_type( $code ) {
if ( $code > 400 && $code < 500 ) {
return 'error';
}
return 'url';
}
/**
* @param array<int, string> $csv
* @param Red_Group $group
* @return CsvItem|false
*/
public function csv_as_item( $csv, Red_Group $group ) {
if ( count( $csv ) > 1 && $csv[ self::CSV_SOURCE ] !== 'source' && $csv[ self::CSV_TARGET ] !== 'target' ) {
$code = isset( $csv[ self::CSV_CODE ] ) ? $this->get_valid_code( $csv[ self::CSV_CODE ] ) : 301;
return array(
'url' => trim( $csv[ self::CSV_SOURCE ] ),
'action_data' => array( 'url' => trim( $csv[ self::CSV_TARGET ] ) ),
'regex' => isset( $csv[ self::CSV_REGEX ] ) ? $this->parse_regex( $csv[ self::CSV_REGEX ] ) : $this->is_regex( $csv[ self::CSV_SOURCE ] ),
'group_id' => $group->get_id(),
'match_type' => 'url',
'action_type' => $this->get_action_type( $code ),
'action_code' => $code,
'status' => $group->is_enabled() ? 'enabled' : 'disabled',
);
}
return false;
}
/**
* @param string|int $value
* @return bool
*/
private function parse_regex( $value ) {
return intval( $value, 10 ) === 1 ? true : false;
}
/**
* @param string $url
* @return bool
*/
private function is_regex( $url ) {
$regex = '()[]$^*';
if ( strpbrk( $url, $regex ) === false ) {
return false;
}
return true;
}
}
@@ -0,0 +1,109 @@
<?php
/**
* @phpstan-import-type GroupJson from Red_Group
*/
class Red_Json_File extends Red_FileIO {
public function force_download() {
parent::force_download();
header( 'Content-Type: application/json' );
header( 'Content-Disposition: attachment; filename="' . $this->export_filename( 'json' ) . '"' );
}
/**
* @param array<Red_Item> $items
* @param array<GroupJson> $groups
* @return string
*/
public function get_data( array $items, array $groups ) {
$version = red_get_plugin_data( dirname( __DIR__ ) . '/redirection.php' );
$items = array(
'plugin' => array(
'version' => trim( $version['Version'] ),
'date' => gmdate( 'r' ),
),
'groups' => $groups,
'redirects' => array_map(
function ( $item ) {
return $item->to_json();
},
$items
),
);
return wp_json_encode( $items, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . PHP_EOL;
}
/**
* @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
*/
public function load( $group, $filename, $data ) {
global $wpdb;
if ( $data === false ) {
return 0;
}
$count = 0;
/** @var array<string, mixed>|false $json */
$json = @json_decode( $data, true );
if ( $json === false ) {
return 0;
}
// Import groups
$group_map = array();
if ( isset( $json['groups'] ) ) {
foreach ( $json['groups'] as $json_group ) {
$old_group_id = $json_group['id'];
unset( $json_group['id'] );
$json_group = Red_Group::create( $json_group['name'], $json_group['module_id'], $json_group['enabled'] ? true : false );
if ( $json_group !== false ) {
$group_map[ $old_group_id ] = $json_group->get_id();
}
}
}
unset( $json['groups'] );
// Import redirects
if ( isset( $json['redirects'] ) ) {
foreach ( $json['redirects'] as $pos => $redirect ) {
unset( $redirect['id'] );
if ( ! isset( $group_map[ $redirect['group_id'] ] ) ) {
$new_group = Red_Group::create( 'Group', 1 );
if ( $new_group !== false ) {
$group_map[ $redirect['group_id'] ] = $new_group->get_id();
}
}
if ( $redirect['match_type'] === 'url' && isset( $redirect['action_data'] ) && ! is_array( $redirect['action_data'] ) ) {
$redirect['action_data'] = array( 'url' => $redirect['action_data'] );
}
$redirect['group_id'] = $group_map[ $redirect['group_id'] ];
$created = Red_Item::create( $redirect );
if ( $created instanceof Red_Item ) {
$count++;
}
// Helps reduce memory usage
unset( $json['redirects'][ $pos ] );
$wpdb->queries = array();
$wpdb->num_queries = 0;
}
}
return $count;
}
}
@@ -0,0 +1,189 @@
<?php
/**
* @phpstan-import-type GroupJson from Red_Group
* @phpstan-import-type RedirectMatchData from Red_Item
*/
class Red_Nginx_File extends Red_FileIO {
public function force_download() {
parent::force_download();
header( 'Content-Type: application/octet-stream' );
header( 'Content-Disposition: attachment; filename="' . $this->export_filename( 'nginx' ) . '"' );
}
/**
* @param array<Red_Item> $items
* @param array<GroupJson> $groups
* @return string
*/
public function get_data( array $items, array $groups ) {
$lines = array();
$version = red_get_plugin_data( dirname( __DIR__ ) . '/redirection.php' );
$lines[] = '# Created by Redirection';
$lines[] = '# ' . gmdate( 'r' );
$lines[] = '# Redirection ' . trim( $version['Version'] ) . ' - https://redirection.me';
$lines[] = '';
$lines[] = 'server {';
$parts = array();
foreach ( $items as $item ) {
if ( $item->is_enabled() ) {
$parts[] = $this->get_nginx_item( $item );
}
}
$lines = array_merge( $lines, array_filter( $parts ) );
$lines[] = '}';
$lines[] = '';
$lines[] = '# End of Redirection';
return implode( PHP_EOL, $lines ) . PHP_EOL;
}
/**
* @return 'permanent'|'redirect'
*/
private function get_redirect_code( Red_Item $item ) {
if ( $item->get_action_code() === 301 ) {
return 'permanent';
}
return 'redirect';
}
/**
* @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
*/
public function load( $group, $filename, $data ) {
return 0;
}
/**
* @return string|false
*/
private function get_nginx_item( Red_Item $item ) {
$target = 'add_' . $item->get_match_type();
if ( method_exists( $this, $target ) ) {
$match_data = $item->get_match_data();
$match_data = is_array( $match_data ) ? $match_data : array();
// @phpstan-ignore method.dynamicName
return ' ' . $this->$target( $item, $match_data );
}
return false;
}
/**
* @param RedirectMatchData $match_data
* @return string
*/
private function add_url( Red_Item $item, array $match_data ) {
// @phpstan-ignore booleanAnd.rightAlwaysTrue
$source = isset( $match_data['source'] ) && is_array( $match_data['source'] ) ? $match_data['source'] : null;
$regex = $item->source_flags !== null && $item->source_flags->is_regex();
return $this->get_redirect( $item->get_url(), $item->get_action_data(), $this->get_redirect_code( $item ), $source, $regex );
}
/**
* @param RedirectMatchData $match_data
* @return string
*/
private function add_agent( Red_Item $item, array $match_data ) {
$lines = array();
// @phpstan-ignore booleanAnd.rightAlwaysTrue
$source = isset( $match_data['source'] ) && is_array( $match_data['source'] ) ? $match_data['source'] : null;
// Help PHPStan: ensure we operate on an Agent_Match
$match = $item->match;
if ( ! ( $match instanceof Agent_Match ) ) {
return '';
}
if ( $match->url_from !== '' ) {
$lines[] = 'if ( $http_user_agent ~* ^' . $match->agent . '$ ) {';
$lines[] = ' ' . $this->get_redirect( $item->get_url(), $match->url_from, $this->get_redirect_code( $item ), $source );
$lines[] = ' }';
}
if ( $match->url_notfrom !== '' ) {
$lines[] = 'if ( $http_user_agent !~* ^' . $match->agent . '$ ) {';
$lines[] = ' ' . $this->get_redirect( $item->get_url(), $match->url_notfrom, $this->get_redirect_code( $item ), $source );
$lines[] = ' }';
}
return implode( "\n", $lines );
}
/**
* @param RedirectMatchData $match_data
* @return string
*/
private function add_referrer( Red_Item $item, array $match_data ) {
$lines = array();
// @phpstan-ignore booleanAnd.rightAlwaysTrue
$source = isset( $match_data['source'] ) && is_array( $match_data['source'] ) ? $match_data['source'] : null;
// Help PHPStan: ensure we operate on a Referrer_Match
$match = $item->match;
if ( ! ( $match instanceof Referrer_Match ) ) {
return '';
}
if ( $match->url_from !== '' ) {
$lines[] = 'if ( $http_referer ~* ^' . $match->referrer . '$ ) {';
$lines[] = ' ' . $this->get_redirect( $item->get_url(), $match->url_from, $this->get_redirect_code( $item ), $source );
$lines[] = ' }';
}
if ( $match->url_notfrom !== '' ) {
$lines[] = 'if ( $http_referer !~* ^' . $match->referrer . '$ ) {';
$lines[] = ' ' . $this->get_redirect( $item->get_url(), $match->url_notfrom, $this->get_redirect_code( $item ), $source );
$lines[] = ' }';
}
return implode( "\n", $lines );
}
/**
* @param string $line
* @param string $target
* @param 'permanent'|'redirect' $code
* @param array{
* flag_query?: 'ignore'|'exact'|'pass'|'exactorder',
* flag_case?: bool,
* flag_trailing?: bool,
* flag_regex?: bool
* }|null $source
* @param bool $regex
* @return string
*/
private function get_redirect( $line, $target, $code, $source, $regex = false ) {
$line = ltrim( $line, '^' );
$line = rtrim( $line, '$' );
$source_url = new Red_Url_Encode( $line, $regex );
$target_url = new Red_Url_Encode( $target );
// Remove any existing start/end from a regex
$from = $source_url->get_as_source();
$from = ltrim( $from, '^' );
$from = rtrim( $from, '$' );
if ( isset( $source['flag_case'] ) && $source['flag_case'] ) {
$from = '(?i)^' . $from;
} else {
$from = '^' . $from;
}
return 'rewrite ' . $from . '$ ' . $target_url->get_as_target() . ' ' . $code . ';';
}
}
@@ -0,0 +1,62 @@
<?php
/**
* @phpstan-import-type GroupJson from Red_Group
*/
class Red_Rss_File extends Red_FileIO {
public function force_download() {
header( 'Content-type: text/xml; charset=' . get_option( 'blog_charset' ), true );
}
/**
* @param array<Red_Item> $items
* @param array<GroupJson> $groups
* @return string
*/
public function get_data( array $items, array $groups ) {
$xml = '<?xml version="1.0" encoding="' . get_option( 'blog_charset' ) . '"?' . ">\r\n";
ob_start();
?>
<rss version="2.0"
xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:wfw="http://wellformedweb.org/CommentAPI/"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>Redirection - <?php bloginfo_rss( 'name' ); ?></title>
<description><?php esc_html( bloginfo_rss( 'description' ) ); ?></description>
<pubDate><?php echo esc_html( (string) mysql2date( 'D, d M Y H:i:s +0000', get_lastpostmodified( 'gmt' ), false ) ); ?></pubDate>
<generator>
<?php echo esc_html( 'http://wordpress.org/?v=' ); ?>
<?php bloginfo_rss( 'version' ); ?>
</generator>
<language><?php echo esc_html( get_option( 'rss_language' ) ); ?></language>
<?php foreach ( $items as $log ) : ?>
<item>
<title><?php echo esc_html( $log->get_url() ); ?></title>
<link><![CDATA[<?php echo esc_url( home_url() ) . esc_url( $log->get_url() ); ?>]]></link>
<pubDate><?php echo esc_html( gmdate( 'D, d M Y H:i:s +0000', intval( $log->get_last_hit(), 10 ) ) ); ?></pubDate>
<guid isPermaLink="false"><?php echo esc_html( (string) $log->get_id() ); ?></guid>
<description><?php echo esc_html( $log->get_url() ); ?></description>
</item>
<?php endforeach; ?>
</channel>
</rss>
<?php
$xml .= ob_get_contents();
ob_end_clean();
return $xml;
}
/**
* @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
*/
public function load( $group, $filename, $data ) {
return 0;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
@@ -0,0 +1,26 @@
<?php
require_once __DIR__ . '/http-header.php';
/**
* Check that a cookie value exists
*/
class Cookie_Match extends Header_Match {
public function name() {
return __( 'URL and cookie', 'redirection' );
}
public function is_match( $url ) {
if ( $this->regex ) {
$regex = new Red_Regex( $this->value, true );
$cookie = Redirection_Request::get_cookie( $this->name );
if ( $cookie === false ) {
return false;
}
return $regex->is_match( $cookie );
}
return Redirection_Request::get_cookie( $this->name ) === $this->value;
}
}
@@ -0,0 +1,111 @@
<?php
/**
* @phpstan-type CustomFilterMap array{
* filter?: string,
* url_from?: string,
* url_notfrom?: string
* }
* @phpstan-type CustomFilterResult array{
* filter: string,
* url_from?: string,
* url_notfrom?: string
* }
* @phpstan-type CustomFilterData array{
* filter: string,
* url_from: string,
* url_notfrom: string
* }
* @phpstan-extends Red_Match<CustomFilterMap, CustomFilterResult>
*
* Perform a check against the results of a custom filter
*
*/
class Custom_Match extends Red_Match {
use FromNotFrom_Match;
/**
* Filter name
*
* @var string
*/
public $filter = '';
/**
* Name of this match used in UI.
*
* @return string
*/
public function name() {
return __( 'URL and custom filter', 'redirection' );
}
/**
* Save data to an array, ready for serializing.
*
* @param CustomFilterMap $details New match data.
* @param boolean $no_target_url Does the action have a target URL.
* @return CustomFilterResult
*/
public function save( array $details, $no_target_url = false ) {
$data = [
'filter' => isset( $details['filter'] ) ? $this->sanitize_filter( $details['filter'] ) : '',
];
/** @var CustomFilterResult $result */
$result = $this->save_data( $details, $no_target_url, $data );
return $result;
}
/**
* Sanitize filter name to allow only alphanumeric, dash and underscore.
*
* @param string $name Filter name.
* @return string
*/
public function sanitize_filter( $name ) {
$name = (string) preg_replace( '/[^A-Za-z0-9\-_]/', '', sanitize_text_field( $name ) );
return trim( $name );
}
/**
* Determine if the current request matches using the custom filter.
*
* @param string $url Requested URL.
* @return boolean
*/
public function is_match( $url ) {
if ( $this->filter === '' ) {
return false;
}
return apply_filters( $this->filter, false, $url );
}
/**
* Get the match data for persistence.
*
* @return CustomFilterData
*/
public function get_data() {
return array_merge(
[
'filter' => $this->filter,
],
$this->get_from_data()
);
}
/**
* Load the match data into this instance.
*
* @param string|CustomFilterMap $values Match values from database (serialized PHP or parsed array).
* @return void
*/
public function load( $values ) {
$values = $this->load_data( $values );
/** @var CustomFilterMap $values */
$this->filter = isset( $values['filter'] ) ? $values['filter'] : '';
}
}
@@ -0,0 +1,128 @@
<?php
/**
* @phpstan-type FromNotFromMap (array{
* url_from?: string,
* url_notfrom?: string
* } & array<string, mixed>)
* @phpstan-type FromNotFromData array{
* url_from: string,
* url_notfrom: string
* }
*
* Trait to add redirect matching that adds a matched target
*/
trait FromNotFrom_Match {
/**
* Target URL if matched
*
* @var string
*/
public $url_from = '';
/**
* Target URL if not matched
*
* @var string
*/
public $url_notfrom = '';
/**
* Save data to an array, ready for serializing.
*
* @phpstan-template TData of array<string, mixed>
* @param FromNotFromMap $details New match data.
* @param bool $no_target_url Does the action have a target URL.
* @phpstan-param TData $data Existing match data.
* @param array<string, mixed> $data Existing match data.
* @phpstan-return TData&FromNotFromMap
* @return array<string, mixed>
*/
private function save_data( array $details, $no_target_url, array $data ) {
if ( $no_target_url === false ) {
return array_merge(
array(
'url_from' => isset( $details['url_from'] ) ? $this->sanitize_url( $details['url_from'] ) : '',
'url_notfrom' => isset( $details['url_notfrom'] ) ? $this->sanitize_url( $details['url_notfrom'] ) : '',
),
$data
);
}
return $data;
}
/**
* Get target URL for this match, depending on whether we match or not
*
* @param string $requested_url Request URL.
* @param string $source_url Redirect source URL.
* @param Red_Source_Flags $flags Redirect flags.
* @param boolean $matched Has the source been matched.
* @return string|false
*/
public function get_target_url( $requested_url, $source_url, Red_Source_Flags $flags, $matched ) {
// Action needs a target URL based on whether we matched or not
$target = $this->get_matched_target( $matched );
if ( $flags->is_regex() && $target !== false ) {
return $this->get_target_regex_url( $source_url, $target, $requested_url, $flags );
}
return $target;
}
/**
* Return the matched target if we have matched and one exists, or return the unmatched target if not matched.
*
* @param boolean $matched Is it matched.
* @return false|string
*/
private function get_matched_target( $matched ) {
if ( $this->url_from !== '' && $matched ) {
return $this->url_from;
}
if ( $this->url_notfrom !== '' && ! $matched ) {
return $this->url_notfrom;
}
return false;
}
/**
* Load the data into the instance.
*
* @phpstan-template TValues of array<string, mixed>
* @param string|TValues $values Serialized PHP data or parsed array.
* @phpstan-return TValues&FromNotFromMap
* @return array<string, mixed>&FromNotFromMap
*/
private function load_data( $values ) {
if ( is_string( $values ) ) {
$values = @unserialize( $values ); // phpcs:ignore
}
if ( isset( $values['url_from'] ) ) {
$this->url_from = $values['url_from'];
}
if ( isset( $values['url_notfrom'] ) ) {
$this->url_notfrom = $values['url_notfrom'];
}
return is_array( $values ) ? $values : [];
}
/**
* Get the match data
*
* @return FromNotFromData
*/
private function get_from_data(): array {
return [
'url_from' => $this->url_from,
'url_notfrom' => $this->url_notfrom,
];
}
}
@@ -0,0 +1,108 @@
<?php
/**
* @phpstan-type FromUrlMap (array{
* url?: string
* } & array<string, mixed>)
* @phpstan-type FromUrlData array{
* url: string
* }
*
* Trait to add redirect matching that adds a matched target
*/
trait FromUrl_Match {
/**
* URL to match against
*
* @var string
*/
public $url = '';
/**
* Save data to an array, ready for serializing.
*
* @phpstan-template TData of array<string, mixed>
* @param FromUrlMap $details New match data.
* @param bool $no_target_url Does the action have a target URL.
* @phpstan-param TData $data Existing match data.
* @param array<string, mixed> $data Existing match data.
* @phpstan-return TData&FromUrlMap
* @return array<string, mixed>
*/
private function save_data( array $details, $no_target_url, array $data ) {
if ( $no_target_url === false ) {
return array_merge(
[
'url' => isset( $details['url'] ) ? $this->sanitize_url( $details['url'] ) : '',
],
$data
);
}
return $data;
}
/**
* Get target URL for this match, depending on whether we match or not
*
* @param string $requested_url Request URL.
* @param string $source_url Redirect source URL.
* @param Red_Source_Flags $flags Redirect flags.
* @param boolean $matched Is the URL matched.
* @return string|false
*/
public function get_target_url( $requested_url, $source_url, Red_Source_Flags $flags, $matched ) {
$target = $this->get_matched_target( $matched );
if ( $flags->is_regex() && $target !== false ) {
return $this->get_target_regex_url( $source_url, $target, $requested_url, $flags );
}
return $target;
}
/**
* Return the matched target, if one exists.
*
* @param boolean $matched Is it matched.
* @return false|string
*/
private function get_matched_target( $matched ) {
if ( $matched ) {
return $this->url;
}
return false;
}
/**
* Load the data into the instance.
*
* @phpstan-template TValues of array<string, mixed>
* @param string|TValues $values Serialized PHP or parsed array.
* @phpstan-return TValues&FromUrlMap
* @return array<string, mixed>&FromUrlMap
*/
private function load_data( $values ) {
if ( is_string( $values ) ) {
$values = unserialize( $values ); // phpcs:ignore
}
if ( isset( $values['url'] ) ) {
$this->url = $values['url'];
}
return is_array( $values ) ? $values : [];
}
/**
* Get the loaded data as an array.
*
* @return FromUrlData
*/
private function get_from_data(): array {
return [
'url' => $this->url,
];
}
}
@@ -0,0 +1,142 @@
<?php
/**
* @phpstan-type HeaderMap array{
* name?: string,
* value?: string,
* regex?: bool,
* url_from?: string,
* url_notfrom?: string
* }
* @phpstan-type HeaderResult array{
* name: string,
* value: string,
* regex: bool,
* url_from?: string,
* url_notfrom?: string
* }
* @phpstan-type HeaderData array{
* name: string,
* value: string,
* regex: bool,
* url_from: string,
* url_notfrom: string
* }
*
* Check a HTTP request header
*
* @phpstan-extends Red_Match<HeaderMap, HeaderResult>
*/
class Header_Match extends Red_Match {
use FromNotFrom_Match;
/**
* HTTP header name
*
* @var string
*/
public $name = '';
/**
* HTTP header value
*
* @var string
*/
public $value = '';
/**
* Is this a regex?
*
* @var boolean
*/
public $regex = false;
/**
* @return string
*/
public function name() {
return __( 'URL and HTTP header', 'redirection' );
}
/**
* @param HeaderMap $details
* @param bool $no_target_url
* @return HeaderResult
*/
public function save( array $details, $no_target_url = false ) {
$data = array(
'regex' => isset( $details['regex'] ) && $details['regex'] ? true : false,
'name' => isset( $details['name'] ) ? $this->sanitize_name( $details['name'] ) : '',
'value' => isset( $details['value'] ) ? $this->sanitize_value( $details['value'] ) : '',
);
$result = $this->save_data( $details, $no_target_url, $data );
return $result; // @phpstan-ignore-line
}
/**
* @param string $name
* @return string
*/
public function sanitize_name( $name ) {
$name = $this->sanitize_url( sanitize_text_field( $name ) );
$name = str_replace( ' ', '', $name );
$name = (string) preg_replace( '/[^A-Za-z0-9\-_]/', '', $name );
return trim( trim( $name, ':' ) );
}
/**
* @param string $value
* @return string
*/
public function sanitize_value( $value ) {
return $this->sanitize_url( sanitize_text_field( $value ) );
}
/**
* @param string $url
* @return boolean
*/
public function is_match( $url ) {
if ( $this->regex ) {
$regex = new Red_Regex( $this->value, true );
$header = Redirection_Request::get_header( $this->name );
if ( ! is_string( $header ) ) {
return false;
}
return $regex->is_match( $header );
}
return Redirection_Request::get_header( $this->name ) === $this->value;
}
/**
* @return HeaderData
*/
public function get_data() {
return array_merge(
array(
'regex' => $this->regex,
'name' => $this->name,
'value' => $this->value,
),
$this->get_from_data()
);
}
/**
* Load the match data into this instance.
*
* @param string|array<string, mixed> $values Match values, as read from the database (plain text, serialized PHP, or parsed array).
* @return void
*/
public function load( $values ) {
$data = $this->load_data( $values );
$this->regex = isset( $data['regex'] ) ? (bool) $data['regex'] : false; // @phpstan-ignore-line
$this->name = isset( $data['name'] ) ? (string) $data['name'] : ''; // @phpstan-ignore-line
$this->value = isset( $data['value'] ) ? (string) $data['value'] : ''; // @phpstan-ignore-line
}
}
@@ -0,0 +1,120 @@
<?php
/**
* @phpstan-type IpMap array{
* ip?: string[],
* url_from?: string,
* url_notfrom?: string
* }
* @phpstan-type IpResult array{
* ip: string[],
* url_from?: string,
* url_notfrom?: string
* }
* @phpstan-type IpData array{
* ip: string[],
* url_from: string,
* url_notfrom: string
* }
*
* Check the request IP
*
* @phpstan-extends Red_Match<IpMap, IpResult>
*/
class IP_Match extends Red_Match {
use FromNotFrom_Match;
/**
* Array of IP addresses
*
* @var string[]
*/
public $ip = [];
public function name() {
return __( 'URL and IP', 'redirection' );
}
/**
* @param IpMap $details
* @return IpResult
*/
public function save( array $details, $no_target_url = false ) {
$data = array( 'ip' => isset( $details['ip'] ) && is_array( $details['ip'] ) ? $this->sanitize_ips( $details['ip'] ) : [] ); // @phpstan-ignore-line
$result = $this->save_data( $details, $no_target_url, $data );
return $result; // @phpstan-ignore-line
}
/**
* Sanitize a single IP
*
* @param string $ip IP.
* @return string|false
*/
private function sanitize_single_ip( $ip ) {
$ip = @inet_pton( trim( sanitize_text_field( $ip ) ) );
if ( $ip !== false ) {
return @inet_ntop( $ip ); // Convert back to string
}
return false;
}
/**
* Sanitize a list of IPs
*
* @param string[] $ips List of IPs.
* @return string[]
*/
private function sanitize_ips( array $ips ) {
$ips = array_map( array( $this, 'sanitize_single_ip' ), $ips );
return array_values( array_filter( array_unique( $ips ) ) );
}
/**
* Get a list of IPs that match.
*
* @param string $match_ip IP to match.
* @return string[]
*/
private function get_matching_ips( $match_ip ) {
$current_ip = @inet_pton( $match_ip );
return array_filter(
$this->ip,
function ( $ip ) use ( $current_ip ) {
return @inet_pton( $ip ) === $current_ip;
}
);
}
public function is_match( $url ) {
$matched = $this->get_matching_ips( Redirection_Request::get_ip() );
return count( $matched ) > 0;
}
/**
* @return IpData
*/
public function get_data() {
return array_merge(
array(
'ip' => $this->ip,
),
$this->get_from_data()
);
}
/**
* Load the match data into this instance.
*
* @param string|IpMap $values Match values, as read from the database (plain text, serialized PHP, or parsed array).
* @return void
*/
public function load( $values ) {
$data = $this->load_data( $values );
$this->ip = isset( $data['ip'] ) ? $data['ip'] : []; // @phpstan-ignore-line
}
}
@@ -0,0 +1,95 @@
<?php
/**
* @phpstan-type LanguageMap array{
* language?: string,
* url_from?: string,
* url_notfrom?: string
* }
* @phpstan-type LanguageResult array{
* language: string,
* url_from?: string,
* url_notfrom?: string
* }
* @phpstan-type LanguageData array{
* language: string,
* url_from: string,
* url_notfrom: string
* }
*
* Check the client language
*
* @phpstan-extends Red_Match<LanguageMap, LanguageResult>
*/
class Language_Match extends Red_Match {
use FromNotFrom_Match;
/**
* Language to check.
*
* @var string
*/
public $language = '';
public function name() {
return __( 'URL and language', 'redirection' );
}
/**
* @param LanguageMap $details
* @return LanguageResult
*/
public function save( array $details, $no_target_url = false ) {
$data = array( 'language' => isset( $details['language'] ) ? $this->sanitize_language( $details['language'] ) : '' );
$result = $this->save_data( $details, $no_target_url, $data );
return $result; // @phpstan-ignore-line
}
/**
* Sanitize the language value to a CSV string
*
* @param string $language User supplied language strings.
* @return string
*/
private function sanitize_language( $language ) {
$parts = explode( ',', str_replace( ' ', '', sanitize_text_field( $language ) ) );
return implode( ',', $parts );
}
public function is_match( $url ) {
$matches = explode( ',', $this->language );
$requested = Redirection_Request::get_accept_language();
foreach ( $matches as $match ) {
if ( in_array( $match, $requested, true ) ) {
return true;
}
}
return false;
}
/**
* @return LanguageData
*/
public function get_data() {
return array_merge(
array(
'language' => $this->language,
),
$this->get_from_data()
);
}
/**
* Load the match data into this instance.
*
* @param string|LanguageMap $values Match values, as read from the database (plain text, serialized PHP, or parsed array).
* @return void
*/
public function load( $values ) {
$data = $this->load_data( $values );
$this->language = isset( $data['language'] ) ? $data['language'] : ''; // @phpstan-ignore-line
}
}
@@ -0,0 +1,101 @@
<?php
/**
* @phpstan-type LoginMap array{
* logged_in?: string,
* logged_out?: string
* }
* @phpstan-type LoginResult array{
* logged_in: string,
* logged_out: string
* }
* @phpstan-type LoginData array{
* logged_in: string,
* logged_out: string
* }
*
* Check whether the user is logged in or out
*
* @phpstan-extends Red_Match<LoginMap, LoginResult>
*/
class Login_Match extends Red_Match {
/**
* Target URL when logged in.
*
* @var string
*/
public $logged_in = '';
/**
* Target URL when logged out.
*
* @var string
*/
public $logged_out = '';
public function name() {
return __( 'URL and login status', 'redirection' );
}
/**
* @param LoginMap $details
* @return LoginResult|null
*/
public function save( array $details, $no_target_url = false ) {
if ( $no_target_url ) {
return null;
}
return [
'logged_in' => isset( $details['logged_in'] ) ? $this->sanitize_url( $details['logged_in'] ) : '',
'logged_out' => isset( $details['logged_out'] ) ? $this->sanitize_url( $details['logged_out'] ) : '',
];
}
public function is_match( $url ) {
return is_user_logged_in();
}
public function get_target_url( $requested_url, $source_url, Red_Source_Flags $flags, $match ) {
$target = false;
if ( $match && $this->logged_in !== '' ) {
$target = $this->logged_in;
} elseif ( ! $match && $this->logged_out !== '' ) {
$target = $this->logged_out;
}
if ( $flags->is_regex() && $target !== false ) {
$target = $this->get_target_regex_url( $source_url, $target, $requested_url, $flags );
}
return $target;
}
/**
* @return LoginData
*/
public function get_data() {
return [
'logged_in' => $this->logged_in,
'logged_out' => $this->logged_out,
];
}
/**
* Load the match data into this instance.
*
* @param string|LoginMap $values Match values, as read from the database (plain text, serialized PHP, or parsed array).
* @return void
*/
public function load( $values ) {
if ( is_string( $values ) ) {
$values = @unserialize( $values );
}
if ( is_array( $values ) ) {
$this->logged_in = isset( $values['logged_in'] ) ? $values['logged_in'] : '';
$this->logged_out = isset( $values['logged_out'] ) ? $values['logged_out'] : '';
}
}
}
@@ -0,0 +1,85 @@
<?php
/**
* @phpstan-type PageMap array{
* page?: string,
* url?: string
* }
* @phpstan-type PageResult array{
* page: string,
* url?: string
* }
* @phpstan-type PageData array{
* page: string,
* url: string
* }
*
* Match the WordPress page type
*
* @phpstan-extends Red_Match<PageMap, PageResult>
*/
class Page_Match extends Red_Match {
use FromUrl_Match;
/**
* Page type
*
* @var string
*/
public $page = '404';
public function name() {
return __( 'URL and WordPress page type', 'redirection' );
}
/**
* Save data to an array, ready for serializing.
*
* @param PageMap $details New match data.
* @param bool $no_target_url Does the action have a target URL.
* @return PageResult
*/
public function save( array $details, $no_target_url = false ) {
$data = array( 'page' => isset( $details['page'] ) ? $this->sanitize_page( $details['page'] ) : '404' );
$result = $this->save_data( $details, $no_target_url, $data );
return $result; // @phpstan-ignore-line
}
/**
* @param string $page
* @return string
*/
private function sanitize_page( $page ) {
return '404';
}
public function is_match( $url ) {
return is_404();
}
/**
* Get the match data for persistence.
*
* @return PageData
*/
public function get_data() {
return array_merge(
array(
'page' => $this->page,
),
$this->get_from_data()
);
}
/**
* Load the match data into this instance.
*
* @param string|PageMap $values Match values, as read from the database (plain text, serialized PHP, or parsed array).
* @return void
*/
public function load( $values ) {
$data = $this->load_data( $values );
$this->page = isset( $data['page'] ) ? $data['page'] : '404'; // @phpstan-ignore-line
}
}
@@ -0,0 +1,103 @@
<?php
/**
* @phpstan-type ReferrerMap array{
* regex?: bool,
* referrer?: string,
* url_from?: string,
* url_notfrom?: string
* }
* @phpstan-type ReferrerResult array{
* regex: bool,
* referrer: string,
* url_from?: string,
* url_notfrom?: string
* }
* @phpstan-type ReferrerData array{
* regex: bool,
* referrer: string,
* url_from: string,
* url_notfrom: string
* }
*
* Match the referrer
*
* @phpstan-extends Red_Match<ReferrerMap, ReferrerResult>
*/
class Referrer_Match extends Red_Match {
use FromNotFrom_Match;
/**
* Referrer
*
* @var string
*/
public $referrer = '';
/**
* Regex match?
*
* @var boolean
*/
public $regex = false;
public function name() {
return __( 'URL and referrer', 'redirection' );
}
/**
* @param ReferrerMap $details
* @return ReferrerResult
*/
public function save( array $details, $no_target_url = false ) {
$data = array(
'regex' => isset( $details['regex'] ) && $details['regex'] ? true : false,
'referrer' => isset( $details['referrer'] ) ? $this->sanitize_referrer( $details['referrer'] ) : '',
);
$result = $this->save_data( $details, $no_target_url, $data );
return $result; // @phpstan-ignore-line
}
/**
* @param string $agent
* @return string
*/
public function sanitize_referrer( $agent ) {
return $this->sanitize_url( $agent );
}
public function is_match( $url ) {
if ( $this->regex ) {
$regex = new Red_Regex( $this->referrer, true );
return $regex->is_match( Redirection_Request::get_referrer() );
}
return Redirection_Request::get_referrer() === $this->referrer;
}
/**
* @return ReferrerData
*/
public function get_data() {
return array_merge(
array(
'regex' => $this->regex,
'referrer' => $this->referrer,
),
$this->get_from_data()
);
}
/**
* Load the match data into this instance.
*
* @param string|ReferrerMap $values Match values, as read from the database (plain text, serialized PHP, or parsed array).
* @return void
*/
public function load( $values ) {
$data = $this->load_data( $values );
$this->regex = isset( $data['regex'] ) ? $data['regex'] : false; // @phpstan-ignore-line
$this->referrer = isset( $data['referrer'] ) ? $data['referrer'] : ''; // @phpstan-ignore-line
}
}
@@ -0,0 +1,103 @@
<?php
/**
* @phpstan-type ServerMap array{
* server?: string,
* url_from?: string,
* url_notfrom?: string
* }
* @phpstan-type ServerResult array{
* server: string,
* url_from?: string,
* url_notfrom?: string
* }
* @phpstan-type ServerData array{
* server: string,
* url_from: string,
* url_notfrom: string
* }
*
* Match the server URL. Used to match requests for another domain.
*
* @phpstan-extends Red_Match<ServerMap, ServerResult>
*/
class Server_Match extends Red_Match {
use FromNotFrom_Match;
/**
* Server URL.
*
* @var string
*/
public $server = '';
/**
* @return string
*/
public function name() {
return __( 'URL and server', 'redirection' );
}
/**
* @param ServerMap $details
* @param bool $no_target_url
* @return ServerResult
*/
public function save( array $details, $no_target_url = false ) {
$data = array( 'server' => isset( $details['server'] ) ? $this->sanitize_server( $details['server'] ) : '' );
$result = $this->save_data( $details, $no_target_url, $data );
return $result; // @phpstan-ignore-line
}
/**
* @param string $server
* @return string
*/
private function sanitize_server( $server ) {
if ( strpos( $server, 'http' ) === false ) {
$server = ( is_ssl() ? 'https://' : 'http://' ) . $server;
}
$parts = wp_parse_url( $server );
if ( isset( $parts['host'] ) && isset( $parts['scheme'] ) ) {
return $parts['scheme'] . '://' . $parts['host'];
}
return '';
}
/**
* @param string $url
* @return boolean
*/
public function is_match( $url ) {
$server = wp_parse_url( $this->server, PHP_URL_HOST );
return $server === Redirection_Request::get_server_name();
}
/**
* @return ServerData
*/
public function get_data() {
return array_merge(
array(
'server' => $this->server,
),
$this->get_from_data()
);
}
/**
* Load the match data into this instance.
*
* @param string|ServerMap $values Match values, as read from the database (plain text, serialized PHP, or parsed array).
* @return void
*/
public function load( $values ) {
$data = $this->load_data( $values );
$this->server = isset( $data['server'] ) ? $data['server'] : ''; // @phpstan-ignore-line
}
}
@@ -0,0 +1,86 @@
<?php
/**
* @phpstan-type UrlMap array{
* url?: string
* }
* @phpstan-type UrlData array{
* url: string
* }
*
* Match the URL only.
*
* @phpstan-extends Red_Match<UrlMap, string>
*/
class URL_Match extends Red_Match {
/**
* URL
*
* @var string
*/
public $url = '';
public function name() {
return __( 'URL only', 'redirection' );
}
/**
* @param UrlMap $details
* @return string|null
*/
public function save( array $details, $no_target_url = false ) {
$data = isset( $details['url'] ) ? $details['url'] : '';
if ( strlen( $data ) === 0 ) {
$data = '/';
}
if ( $no_target_url ) {
return null;
}
return $this->sanitize_url( $data );
}
public function is_match( $url ) {
return true;
}
public function get_target_url( $original_url, $matched_url, Red_Source_Flags $flag, $is_matched ) {
$target = $this->url;
if ( $flag->is_regex() ) {
$target = $this->get_target_regex_url( $matched_url, $target, $original_url, $flag );
}
return $target;
}
/**
* @return UrlData|null
*/
public function get_data() {
if ( $this->url !== '' ) {
return [
'url' => $this->url,
];
}
return null;
}
/**
* Load the match data into this instance.
*
* @param string|UrlMap $values Match values, as read from the database (plain text, serialized PHP, or parsed array).
* @return void
*/
public function load( $values ) {
if ( is_array( $values ) ) {
$this->url = isset( $values['url'] ) ? $values['url'] : '';
return;
}
$this->url = $values;
}
}
@@ -0,0 +1,103 @@
<?php
/**
* @phpstan-type AgentMap array{
* regex?: bool,
* agent?: string,
* url_from?: string,
* url_notfrom?: string
* }
* @phpstan-type AgentResult array{
* regex: bool,
* agent: string,
* url_from?: string,
* url_notfrom?: string
* }
* @phpstan-type AgentData array{
* regex: bool,
* agent: string,
* url_from: string,
* url_notfrom: string
* }
*
* Match the user agent
*
* @phpstan-extends Red_Match<AgentMap, AgentResult>
*/
class Agent_Match extends Red_Match {
use FromNotFrom_Match;
/**
* User agent.
*
* @var string
*/
public $agent = '';
/**
* Is this a regex match?
*
* @var boolean
*/
public $regex = false;
public function name() {
return __( 'URL and user agent', 'redirection' );
}
/**
* @param AgentMap $details
* @return AgentResult
*/
public function save( array $details, $no_target_url = false ) {
$data = array(
'regex' => isset( $details['regex'] ) && $details['regex'] ? true : false,
'agent' => isset( $details['agent'] ) ? $this->sanitize_agent( $details['agent'] ) : '',
);
$result = $this->save_data( $details, $no_target_url, $data );
return $result; // @phpstan-ignore-line
}
/**
* @param string $agent User agent string.
* @return string
*/
private function sanitize_agent( $agent ) {
return $this->sanitize_url( $agent );
}
public function is_match( $url ) {
if ( $this->regex ) {
$regex = new Red_Regex( $this->agent, true );
return $regex->is_match( Redirection_Request::get_user_agent() );
}
return $this->agent === Redirection_Request::get_user_agent();
}
/**
* @return AgentData
*/
public function get_data() {
return array_merge(
array(
'regex' => $this->regex,
'agent' => $this->agent,
),
$this->get_from_data()
);
}
/**
* Load the match data into this instance.
*
* @param string|AgentMap $values Match values, as read from the database (plain text, serialized PHP, or parsed array).
* @return void
*/
public function load( $values ) {
$data = $this->load_data( $values );
$this->regex = isset( $data['regex'] ) ? $data['regex'] : false; // @phpstan-ignore-line
$this->agent = isset( $data['agent'] ) ? $data['agent'] : ''; // @phpstan-ignore-line
}
}
@@ -0,0 +1,77 @@
<?php
/**
* @phpstan-type RoleMap array{
* role?: string,
* url_from?: string,
* url_notfrom?: string
* }
* @phpstan-type RoleResult array{
* role: string,
* url_from?: string,
* url_notfrom?: string
* }
* @phpstan-type RoleData array{
* role: string,
* url_from: string,
* url_notfrom: string
* }
*
* Match a particular role or capability
*
* @phpstan-extends Red_Match<RoleMap, RoleResult>
*/
class Role_Match extends Red_Match {
use FromNotFrom_Match;
/**
* WordPress role or capability
*
* @var string
*/
public $role = '';
public function name() {
return __( 'URL and role/capability', 'redirection' );
}
/**
* @param RoleMap $details
* @return RoleResult
*/
public function save( array $details, $no_target_url = false ) {
$data = array( 'role' => isset( $details['role'] ) ? $details['role'] : '' );
/** @var RoleResult $result */
$result = $this->save_data( $details, $no_target_url, $data );
return $result;
}
public function is_match( $url ) {
return current_user_can( $this->role );
}
/**
* @return RoleData
*/
public function get_data() {
return array_merge(
array(
'role' => $this->role,
),
$this->get_from_data()
);
}
/**
* Load the match data into this instance.
*
* @param string|RoleMap $values Match values, as read from the database (plain text, serialized PHP, or parsed array).
* @return void
*/
public function load( $values ) {
$values = $this->load_data( $values );
/** @var RoleMap $values */
$this->role = isset( $values['role'] ) ? $values['role'] : '';
}
}
@@ -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;
}
}

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