Files

797 lines
22 KiB
PHP

<?php
/**
* REST API for remote management.
*
* All routes require an authenticated user with the manage_woocommerce
* capability (e.g. via a WordPress Application Password over HTTPS).
* There is deliberately no token-in-code or unauthenticated access.
*
* @package WC_Email_Discount_Manager
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Registers wc-edm/v1 REST routes for groups, emails and shipping rules.
*/
class WC_EDM_REST_API {
/**
* REST namespace.
*/
const REST_NAMESPACE = 'wc-edm/v1';
/**
* Hook route registration.
*/
public function __construct() {
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
add_filter( 'rest_request_after_callbacks', array( $this, 'log_request' ), 10, 3 );
}
/**
* Permission check shared by all routes.
*
* Rate limiting runs before the capability check so that unauthenticated
* floods are throttled too.
*
* @param WP_REST_Request|null $request Current request.
* @return bool|WP_Error
*/
public function permission_check( $request = null ) {
$method = $request instanceof WP_REST_Request ? $request->get_method() : 'GET';
$rate = WC_EDM_Rate_Limiter::check( $method );
if ( $rate['limited'] ) {
return new WP_Error(
'wc_edm_rate_limited',
__( 'Too many requests. Please slow down.', 'wc-email-discount' ),
array(
'status' => 429,
'retry_after' => $rate['retry_after'],
)
);
}
if ( current_user_can( 'manage_woocommerce' ) ) {
return true;
}
return new WP_Error(
'wc_edm_forbidden',
__( 'You are not allowed to manage email discounts.', 'wc-email-discount' ),
array( 'status' => rest_authorization_required_code() )
);
}
/**
* Write an audit entry for wc-edm requests.
*
* Runs after the route callback (and also when a permission check failed),
* so both successful writes and rejected attempts are recorded. Successful
* reads are skipped to keep the log signal-heavy.
*
* @param WP_REST_Response|WP_HTTP_Response|WP_Error $response Result to send.
* @param array $handler Route handler.
* @param WP_REST_Request $request Current request.
* @return mixed The unmodified response.
*/
public function log_request( $response, $handler, $request ) {
if ( ! $request instanceof WP_REST_Request ) {
return $response;
}
$route = $request->get_route();
if ( 0 !== strpos( ltrim( $route, '/' ), self::REST_NAMESPACE ) ) {
return $response;
}
$method = $request->get_method();
$is_write = WC_EDM_Rate_Limiter::is_write_method( $method );
$details = array();
$outcome = 'allowed';
$status = 200;
if ( is_wp_error( $response ) ) {
$error_data = $response->get_error_data();
$status = is_array( $error_data ) && isset( $error_data['status'] ) ? (int) $error_data['status'] : 500;
$outcome = 'wc_edm_rate_limited' === $response->get_error_code() ? 'rate_limited' : 'denied';
$details['code'] = $response->get_error_code();
} elseif ( $response instanceof WP_HTTP_Response ) {
$status = $response->get_status();
if ( $status >= 400 ) {
$outcome = 'denied';
$data = $response->get_data();
$details['code'] = is_array( $data ) && isset( $data['code'] ) ? $data['code'] : 'error';
}
}
// Successful reads are noise; everything else is worth keeping.
if ( 'allowed' === $outcome && ! $is_write ) {
return $response;
}
// Don't let a blocked caller flood the log with identical rows.
if ( 'rate_limited' === $outcome && ! WC_EDM_Rate_Limiter::should_log_limit_hit() ) {
return $response;
}
WC_EDM_Audit_Log::record( $outcome, $method, $route, $status, $details );
return $response;
}
/**
* Register all routes.
*/
public function register_routes() {
$perm = array( $this, 'permission_check' );
register_rest_route(
self::REST_NAMESPACE,
'/status',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_status' ),
'permission_callback' => $perm,
)
);
register_rest_route(
self::REST_NAMESPACE,
'/groups',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_groups' ),
'permission_callback' => $perm,
),
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'create_group' ),
'permission_callback' => $perm,
),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/groups/(?P<id>\d+)',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_group' ),
'permission_callback' => $perm,
),
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'update_group' ),
'permission_callback' => $perm,
),
array(
'methods' => WP_REST_Server::DELETABLE,
'callback' => array( $this, 'delete_group' ),
'permission_callback' => $perm,
),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/emails',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_emails' ),
'permission_callback' => $perm,
),
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'create_email' ),
'permission_callback' => $perm,
),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/emails/(?P<id>\d+)',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_email' ),
'permission_callback' => $perm,
),
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'update_email' ),
'permission_callback' => $perm,
),
array(
'methods' => WP_REST_Server::DELETABLE,
'callback' => array( $this, 'delete_email' ),
'permission_callback' => $perm,
),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/shipping-methods',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_shipping_methods' ),
'permission_callback' => $perm,
)
);
register_rest_route(
self::REST_NAMESPACE,
'/groups/(?P<id>\d+)/shipping-rules',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_shipping_rules' ),
'permission_callback' => $perm,
),
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'update_shipping_rules' ),
'permission_callback' => $perm,
),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/audit-log',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_audit_log' ),
'permission_callback' => $perm,
'args' => array(
'limit' => array(
'type' => 'integer',
'default' => 50,
),
'offset' => array(
'type' => 'integer',
'default' => 0,
),
'outcome' => array(
'type' => 'string',
'enum' => array( 'allowed', 'denied', 'rate_limited' ),
),
),
)
);
register_rest_route(
self::REST_NAMESPACE,
'/match',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'match_email' ),
'permission_callback' => $perm,
'args' => array(
'email' => array(
'required' => true,
'type' => 'string',
),
),
)
);
}
/*
|--------------------------------------------------------------------------
| Formatters
|--------------------------------------------------------------------------
*/
/**
* Format a group row for API output.
*
* @param object $group Group row.
* @return array
*/
private function format_group( $group ) {
return array(
'id' => (int) $group->id,
'name' => $group->name,
'discount_percent' => (float) $group->discount_percent,
'category_ids' => WC_EDM_DB::get_group_category_ids( (int) $group->id ),
'created_at' => $group->created_at,
'updated_at' => $group->updated_at,
);
}
/**
* Format an email assignment row for API output.
*
* @param object $assignment Email row.
* @return array
*/
private function format_email( $assignment ) {
return array(
'id' => (int) $assignment->id,
'email_pattern' => $assignment->email_pattern,
'assignment_type' => $assignment->assignment_type,
'group_id' => $assignment->group_id ? (int) $assignment->group_id : null,
'coupon_code' => $assignment->coupon_code,
'custom_discount' => null !== $assignment->custom_discount ? (float) $assignment->custom_discount : null,
'is_active' => (bool) $assignment->is_active,
'free_shipping' => ! empty( $assignment->free_shipping ),
'created_at' => $assignment->created_at,
'updated_at' => $assignment->updated_at,
);
}
/**
* Standard 404 error.
*
* @return WP_Error
*/
private function not_found() {
return new WP_Error( 'wc_edm_not_found', __( 'Resource not found.', 'wc-email-discount' ), array( 'status' => 404 ) );
}
/*
|--------------------------------------------------------------------------
| Status
|--------------------------------------------------------------------------
*/
/**
* GET /status — connectivity test plus quick stats.
*
* @return WP_REST_Response
*/
public function get_status() {
return rest_ensure_response(
array(
'plugin' => 'woocommerce-email-discount-manager',
'version' => WC_EDM_VERSION,
'woocommerce' => defined( 'WC_VERSION' ) ? WC_VERSION : null,
'groups' => WC_EDM_DB::count_groups(),
'email_assignments' => WC_EDM_DB::count_emails(),
'active_assignments' => WC_EDM_DB::count_emails( true ),
)
);
}
/*
|--------------------------------------------------------------------------
| Groups
|--------------------------------------------------------------------------
*/
/**
* GET /groups
*
* @return WP_REST_Response
*/
public function get_groups() {
return rest_ensure_response( array_map( array( $this, 'format_group' ), WC_EDM_DB::get_groups() ) );
}
/**
* GET /groups/{id}
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function get_group( $request ) {
$group = WC_EDM_DB::get_group( (int) $request['id'] );
return $group ? rest_ensure_response( $this->format_group( $group ) ) : $this->not_found();
}
/**
* POST /groups
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function create_group( $request ) {
$name = sanitize_text_field( (string) $request['name'] );
if ( '' === $name ) {
return new WP_Error( 'wc_edm_invalid', __( 'A group name is required.', 'wc-email-discount' ), array( 'status' => 400 ) );
}
$id = WC_EDM_DB::insert_group(
array(
'name' => $name,
'discount_percent' => WC_EDM_Helpers::sanitize_percent( $request['discount_percent'] ?? 0 ),
)
);
if ( ! $id ) {
return new WP_Error( 'wc_edm_db_error', __( 'Could not create the group.', 'wc-email-discount' ), array( 'status' => 500 ) );
}
if ( is_array( $request['category_ids'] ) ) {
WC_EDM_DB::set_group_categories( $id, array_map( 'absint', $request['category_ids'] ) );
}
return rest_ensure_response( $this->format_group( WC_EDM_DB::get_group( $id ) ) );
}
/**
* PUT/PATCH /groups/{id}
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function update_group( $request ) {
$id = (int) $request['id'];
$group = WC_EDM_DB::get_group( $id );
if ( ! $group ) {
return $this->not_found();
}
$name = null !== $request['name'] ? sanitize_text_field( (string) $request['name'] ) : $group->name;
$percent = null !== $request['discount_percent'] ? WC_EDM_Helpers::sanitize_percent( $request['discount_percent'] ) : (float) $group->discount_percent;
if ( '' === $name ) {
return new WP_Error( 'wc_edm_invalid', __( 'A group name is required.', 'wc-email-discount' ), array( 'status' => 400 ) );
}
WC_EDM_DB::update_group(
$id,
array(
'name' => $name,
'discount_percent' => $percent,
)
);
if ( is_array( $request['category_ids'] ) ) {
WC_EDM_DB::set_group_categories( $id, array_map( 'absint', $request['category_ids'] ) );
}
return rest_ensure_response( $this->format_group( WC_EDM_DB::get_group( $id ) ) );
}
/**
* DELETE /groups/{id}
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function delete_group( $request ) {
$id = (int) $request['id'];
if ( ! WC_EDM_DB::get_group( $id ) ) {
return $this->not_found();
}
WC_EDM_DB::delete_group( $id );
return rest_ensure_response( array( 'deleted' => true, 'id' => $id ) );
}
/*
|--------------------------------------------------------------------------
| Email assignments
|--------------------------------------------------------------------------
*/
/**
* GET /emails
*
* @return WP_REST_Response
*/
public function get_emails() {
return rest_ensure_response( array_map( array( $this, 'format_email' ), WC_EDM_DB::get_emails() ) );
}
/**
* GET /emails/{id}
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function get_email( $request ) {
$assignment = WC_EDM_DB::get_email( (int) $request['id'] );
return $assignment ? rest_ensure_response( $this->format_email( $assignment ) ) : $this->not_found();
}
/**
* Validate and normalize email assignment input.
*
* @param WP_REST_Request $request Request.
* @param object|null $existing Existing row when updating.
* @return array|WP_Error
*/
private function prepare_email_data( $request, $existing = null ) {
$pattern = null !== $request['email_pattern']
? strtolower( sanitize_text_field( (string) $request['email_pattern'] ) )
: ( $existing ? $existing->email_pattern : '' );
$type = null !== $request['assignment_type']
? sanitize_key( (string) $request['assignment_type'] )
: ( $existing ? $existing->assignment_type : 'group' );
if ( '' === $pattern ) {
return new WP_Error( 'wc_edm_invalid', __( 'An email pattern is required.', 'wc-email-discount' ), array( 'status' => 400 ) );
}
if ( ! in_array( $type, array( 'group', 'coupon', 'custom' ), true ) ) {
return new WP_Error( 'wc_edm_invalid', __( 'assignment_type must be one of: group, coupon, custom.', 'wc-email-discount' ), array( 'status' => 400 ) );
}
$data = array(
'email_pattern' => $pattern,
'assignment_type' => $type,
'group_id' => null,
'coupon_code' => null,
'custom_discount' => null,
'is_active' => null !== $request['is_active']
? ( rest_sanitize_boolean( $request['is_active'] ) ? 1 : 0 )
: ( $existing ? (int) $existing->is_active : 1 ),
'free_shipping' => null !== $request['free_shipping']
? ( rest_sanitize_boolean( $request['free_shipping'] ) ? 1 : 0 )
: ( $existing ? (int) ! empty( $existing->free_shipping ) : 0 ),
);
switch ( $type ) {
case 'group':
$group_id = null !== $request['group_id']
? absint( $request['group_id'] )
: ( $existing && $existing->group_id ? (int) $existing->group_id : 0 );
if ( ! $group_id || ! WC_EDM_DB::get_group( $group_id ) ) {
return new WP_Error( 'wc_edm_invalid', __( 'A valid group_id is required for group assignments.', 'wc-email-discount' ), array( 'status' => 400 ) );
}
$data['group_id'] = $group_id;
break;
case 'coupon':
$coupon = null !== $request['coupon_code']
? wc_format_coupon_code( (string) $request['coupon_code'] )
: ( $existing ? (string) $existing->coupon_code : '' );
if ( '' === $coupon ) {
return new WP_Error( 'wc_edm_invalid', __( 'A coupon_code is required for coupon assignments.', 'wc-email-discount' ), array( 'status' => 400 ) );
}
$data['coupon_code'] = $coupon;
break;
case 'custom':
$data['custom_discount'] = null !== $request['custom_discount']
? WC_EDM_Helpers::sanitize_percent( $request['custom_discount'] )
: ( $existing && null !== $existing->custom_discount ? (float) $existing->custom_discount : 0 );
break;
}
return $data;
}
/**
* POST /emails
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function create_email( $request ) {
$data = $this->prepare_email_data( $request );
if ( is_wp_error( $data ) ) {
return $data;
}
$id = WC_EDM_DB::insert_email( $data );
if ( ! $id ) {
return new WP_Error( 'wc_edm_db_error', __( 'Could not create the assignment.', 'wc-email-discount' ), array( 'status' => 500 ) );
}
return rest_ensure_response( $this->format_email( WC_EDM_DB::get_email( $id ) ) );
}
/**
* PUT/PATCH /emails/{id}
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function update_email( $request ) {
$id = (int) $request['id'];
$existing = WC_EDM_DB::get_email( $id );
if ( ! $existing ) {
return $this->not_found();
}
$data = $this->prepare_email_data( $request, $existing );
if ( is_wp_error( $data ) ) {
return $data;
}
WC_EDM_DB::update_email( $id, $data );
return rest_ensure_response( $this->format_email( WC_EDM_DB::get_email( $id ) ) );
}
/**
* DELETE /emails/{id}
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function delete_email( $request ) {
$id = (int) $request['id'];
if ( ! WC_EDM_DB::get_email( $id ) ) {
return $this->not_found();
}
WC_EDM_DB::delete_email( $id );
return rest_ensure_response( array( 'deleted' => true, 'id' => $id ) );
}
/*
|--------------------------------------------------------------------------
| Shipping
|--------------------------------------------------------------------------
*/
/**
* GET /shipping-methods — all shipping method instances across zones.
*
* @return WP_REST_Response
*/
public function get_shipping_methods() {
return rest_ensure_response( array_values( WC_EDM_Helpers::get_all_shipping_methods() ) );
}
/**
* GET /groups/{id}/shipping-rules
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function get_shipping_rules( $request ) {
$group_id = (int) $request['id'];
if ( ! WC_EDM_DB::get_group( $group_id ) ) {
return $this->not_found();
}
$rules = array();
foreach ( WC_EDM_DB::get_shipping_rules( $group_id ) as $rule ) {
$rules[] = array(
'shipping_method_id' => $rule->shipping_method_id,
'action' => $rule->action,
'sort_order' => (int) $rule->sort_order,
);
}
return rest_ensure_response( $rules );
}
/**
* PUT /groups/{id}/shipping-rules — replace all rules for the group.
*
* Body: { "rules": [ { "shipping_method_id": "flat_rate:3", "action": "show" }, ... ] }
* Sort order follows array order. An empty rules array clears all rules.
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response|WP_Error
*/
public function update_shipping_rules( $request ) {
$group_id = (int) $request['id'];
if ( ! WC_EDM_DB::get_group( $group_id ) ) {
return $this->not_found();
}
$raw = $request['rules'];
if ( ! is_array( $raw ) ) {
return new WP_Error( 'wc_edm_invalid', __( 'A rules array is required.', 'wc-email-discount' ), array( 'status' => 400 ) );
}
$rules = array();
foreach ( array_values( $raw ) as $index => $rule ) {
$method_id = isset( $rule['shipping_method_id'] ) ? sanitize_text_field( (string) $rule['shipping_method_id'] ) : '';
$action = isset( $rule['action'] ) ? sanitize_key( (string) $rule['action'] ) : '';
if ( '' === $method_id || ! in_array( $action, array( 'show', 'hide' ), true ) ) {
return new WP_Error(
'wc_edm_invalid',
__( 'Each rule needs a shipping_method_id and an action of "show" or "hide".', 'wc-email-discount' ),
array( 'status' => 400 )
);
}
$rules[] = array(
'shipping_method_id' => $method_id,
'action' => $action,
'sort_order' => $index,
);
}
WC_EDM_DB::replace_shipping_rules( $group_id, $rules );
return $this->get_shipping_rules( $request );
}
/*
|--------------------------------------------------------------------------
| Audit log
|--------------------------------------------------------------------------
*/
/**
* GET /audit-log — recent REST activity, newest first.
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response
*/
public function get_audit_log( $request ) {
$limit = min( 200, max( 1, (int) $request['limit'] ) );
$offset = max( 0, (int) $request['offset'] );
$outcome = $request['outcome'] ? sanitize_key( (string) $request['outcome'] ) : '';
$entries = array();
foreach ( WC_EDM_DB::get_audit_entries( $limit, $offset, $outcome ) as $entry ) {
$entries[] = array(
'id' => (int) $entry->id,
'created_at' => $entry->created_at,
'user_id' => (int) $entry->user_id,
'user_login' => $entry->user_login,
'ip' => $entry->ip,
'method' => $entry->method,
'route' => $entry->route,
'status' => (int) $entry->status,
'outcome' => $entry->outcome,
'details' => $entry->details ? json_decode( $entry->details, true ) : null,
);
}
$response = rest_ensure_response( $entries );
$response->header( 'X-WP-Total', (string) WC_EDM_DB::count_audit_entries( $outcome ) );
return $response;
}
/*
|--------------------------------------------------------------------------
| Match test
|--------------------------------------------------------------------------
*/
/**
* GET /match?email=... — test which assignment an email would get.
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response
*/
public function match_email( $request ) {
$email = sanitize_email( (string) $request['email'] );
$match = $email ? WC_EDM_Helpers::find_email_match( $email ) : null;
return rest_ensure_response(
array(
'email' => $email,
'matched' => (bool) $match,
'match' => $match ? $this->format_email( $match ) : null,
)
);
}
}