WP_REST_Server::CREATABLE, 'callback' => [ $this, 'start' ], // Public by necessity — the caller has no account. The gate is the // code, and the rate limiter below. 'permission_callback' => '__return_true', 'args' => [ 'query' => [ 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ], ], ] ); register_rest_route( $ns, '/track/verify', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $this, 'verify' ], 'permission_callback' => '__return_true', 'args' => [ 'token' => [ 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ], 'code' => [ 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ], ], ] ); register_rest_route( $ns, '/track/detail', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $this, 'detail' ], 'permission_callback' => '__return_true', 'args' => [ 'access' => [ 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ], 'order_id' => [ 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint' ], ], ] ); } /** * An opaque handle. 32 bytes of randomness, hex. */ private static function token(): string { return bin2hex( random_bytes( 16 ) ); } /** * Resolve what was typed, send a code to the order's number, open a session. * * @param WP_REST_Request $request Incoming request. * * @return mixed * * @internal Hooked callback. */ public function start( WP_REST_Request $request ) { $query = trim( (string) $request['query'] ); if ( '' === $query ) { return new WP_Error( 'rahkar_track_empty', __( 'شماره موبایل یا کد سفارش را وارد کنید.', 'rahkar-toolkit' ), [ 'status' => 400 ] ); } $parsed = PhoneNumber::parse( $query ); $mode = ''; $orderId = 0; if ( $parsed ) { $mode = 'phone'; } else { $order = OrderLookup::resolveOrder( $query ); if ( ! $order instanceof WC_Order ) { return new WP_Error( 'rahkar_track_not_found', __( 'سفارشی با این مشخصات پیدا نشد. شماره موبایل یا کد سفارش را بررسی کنید.', 'rahkar-toolkit' ), [ 'status' => 404 ] ); } // The code goes to the number ON THE ORDER, never to whatever was // typed. This is the whole reason an order number is safe to accept. $parsed = PhoneNumber::parse( OrderLookup::orderPhone( $order ) ); if ( ! $parsed ) { return new WP_Error( 'rahkar_track_no_phone', __( 'برای این سفارش شماره‌ی همراهی ثبت نشده است.', 'rahkar-toolkit' ), [ 'status' => 400 ] ); } $mode = 'order'; $orderId = $order->get_id(); } /* * Two forms of the same number, and they are not interchangeable. * * `canonical()` (989121234567) is what the OTP store and the rate limiter * key on everywhere else in the plugin. Using anything else here would give * a customer a separate bucket for tracking than for logging in, so issuing * a tracking code would not invalidate a login code they already had. * * `withoutTrunk()` (9121234567) is what the order lookup wants, because the * stored billing phone is that with a `0` in front and the legacy verified- * phone meta is that exactly. Passing the canonical form here silently * matches no orders at all — which is how this was first written, and it * failed quietly rather than loudly. */ $canonical = $parsed->canonical(); $local = $parsed->withoutTrunk(); if ( ! RateLimiter::hit( 'track', $canonical, self::RATE_MAX, self::RATE_WINDOW ) ) { return new WP_Error( 'rahkar_track_rate', __( 'درخواست بیش از حد. کمی بعد دوباره تلاش کنید.', 'rahkar-toolkit' ), [ 'status' => 429, 'cooldown' => RateLimiter::secondsRemaining( 'track', $canonical ) ] ); } $issued = Plugin::make( OtpService::class )->issue( $canonical ); if ( ! $issued ) { return new WP_Error( 'rahkar_track_otp', __( 'ارسال کد ممکن نشد. کمی بعد دوباره تلاش کنید.', 'rahkar-toolkit' ), [ 'status' => 500 ] ); } $outcome = Plugin::make( OtpDispatcher::class )->send( $parsed, $issued['code'] ); // Test mode counts as delivered: it is how a shop verifies the flow // before a gateway is connected, and failing here would make that // impossible. if ( ! $outcome->isDelivered() && ! $outcome->isTestMode() ) { return new WP_Error( 'rahkar_track_sms', __( 'ارسال پیامک ناموفق بود. کمی بعد دوباره تلاش کنید.', 'rahkar-toolkit' ), [ 'status' => 502 ] ); } $token = self::token(); // The OTP token lives in the server-side session, never in the reply: the // browser gets only this opaque handle, so a leaked response cannot be // replayed against the OTP store directly. set_transient( self::TOKEN_PREFIX . $token, [ 'mode' => $mode, // The lookup form — this is what verify() hands to queryOrders(). 'phone' => $local, 'order_id' => $orderId, 'otp' => $issued['token'], ], max( 60, (int) $issued['expires'] - time() ) + 60 ); return rest_ensure_response( [ 'token' => $token, 'masked' => OrderLookup::maskPhone( $local ), 'cooldown' => RateLimiter::secondsRemaining( 'track', $canonical ), 'resends_left' => max( 0, self::RATE_MAX - 1 ), ] ); } /** * Check the code and hand back the orders it unlocked. * * @param WP_REST_Request $request Incoming request. * * @return mixed * * @internal Hooked callback. */ public function verify( WP_REST_Request $request ) { $token = (string) $request['token']; $session = $token ? get_transient( self::TOKEN_PREFIX . $token ) : false; if ( ! is_array( $session ) || empty( $session['phone'] ) || empty( $session['otp'] ) ) { return new WP_Error( 'rahkar_track_session', __( 'نشست منقضی شده است. لطفاً دوباره از ابتدا تلاش کنید.', 'rahkar-toolkit' ), [ 'status' => 400 ] ); } // Persian digits are what a customer's keyboard produces. $code = (string) preg_replace( '/\D+/', '', Digits::toLatin( (string) $request['code'] ) ); if ( '' === $code ) { return new WP_Error( 'rahkar_track_code', __( 'کد تأیید را وارد کنید.', 'rahkar-toolkit' ), [ 'status' => 400 ] ); } $check = Plugin::make( OtpService::class )->verify( (string) $session['otp'], $code ); if ( empty( $check['ok'] ) ) { $blocked = OtpService::FAIL_BLOCKED === $check['reason']; return new WP_Error( 'rahkar_track_bad_code', $blocked ? __( 'تعداد تلاش‌ها بیش از حد بود. از ابتدا شروع کنید.', 'rahkar-toolkit' ) : __( 'کد وارد‌شده درست نیست یا منقضی شده است.', 'rahkar-toolkit' ), [ 'status' => $blocked ? 429 : 400 ] ); } delete_transient( self::TOKEN_PREFIX . $token ); // One-shot. $terminal = OrderLookup::terminalStatuses(); if ( 'order' === $session['mode'] ) { $order = wc_get_order( (int) $session['order_id'] ); if ( ! $order instanceof WC_Order ) { return new WP_Error( 'rahkar_track_gone', __( 'سفارش دیگر در دسترس نیست.', 'rahkar-toolkit' ), [ 'status' => 404 ] ); } $orders = [ $order ]; $primary = $order; } else { $orders = OrderLookup::queryOrders( (string) $session['phone'] ); if ( ! $orders ) { return rest_ensure_response( [ 'view' => 'empty', 'html' => Views::render( 'empty', [] ) ] ); } $active = array_values( array_filter( $orders, static fn( $o ): bool => ! in_array( $o->get_status(), $terminal, true ) ) ); // Exactly one in-progress order goes straight to its tracking, and so // does a single order overall. Anything else needs a chooser. if ( 1 === count( $active ) ) { $primary = $active[0]; } elseif ( 1 === count( $orders ) ) { $primary = $orders[0]; } else { $primary = null; } } $access = self::token(); set_transient( self::ACCESS_PREFIX . $access, [ 'phone' => (string) $session['phone'], 'ids' => array_map( static fn( WC_Order $o ): int => $o->get_id(), $orders ), ], self::ACCESS_TTL ); if ( $primary instanceof WC_Order ) { return rest_ensure_response( [ 'access' => $access, 'view' => 'detail', 'html' => Views::render( 'detail', [ 'order' => $primary ] ) ] ); } // In-progress first; the query already returned date-descending. usort( $orders, static function ( WC_Order $a, WC_Order $b ) use ( $terminal ): int { return ( in_array( $a->get_status(), $terminal, true ) ? 1 : 0 ) <=> ( in_array( $b->get_status(), $terminal, true ) ? 1 : 0 ); } ); return rest_ensure_response( [ 'access' => $access, 'view' => 'list', 'html' => Views::render( 'list', [ 'orders' => $orders, 'terminal' => $terminal ] ), ] ); } /** * Render one order out of the set the access token unlocked. * * @param WP_REST_Request $request Incoming request. * * @return mixed * * @internal Hooked callback. */ public function detail( WP_REST_Request $request ) { $access = (string) $request['access']; $orderId = absint( $request['order_id'] ); $data = $access ? get_transient( self::ACCESS_PREFIX . $access ) : false; // The id must be one this token already proved. Without that check the // token would be a key to every order, not to the caller's own. if ( ! is_array( $data ) || empty( $data['ids'] ) || ! in_array( $orderId, array_map( 'intval', $data['ids'] ), true ) ) { return new WP_Error( 'rahkar_track_denied', __( 'دسترسی نامعتبر یا منقضی شده است.', 'rahkar-toolkit' ), [ 'status' => 403 ] ); } $order = wc_get_order( $orderId ); if ( ! $order instanceof WC_Order ) { return new WP_Error( 'rahkar_track_gone', __( 'سفارش در دسترس نیست.', 'rahkar-toolkit' ), [ 'status' => 404 ] ); } return rest_ensure_response( [ 'html' => Views::render( 'detail', [ 'order' => $order ] ) ] ); } }