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,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"
* }
*/