'GET', 'callback' => [ $this, 'state' ], 'permission_callback' => '__return_true', ] ); register_rest_route( self::NS, '/subscribe', [ 'methods' => 'POST', 'callback' => [ $this, 'subscribe' ], 'permission_callback' => [ $this, 'checkNonce' ], 'args' => [ 'email' => [ 'type' => 'string', 'required' => true, ], 'consent' => [ 'type' => 'boolean', 'default' => false, ], ], ] ); register_rest_route( self::NS, '/doors/(?P\d{1,2})/open', [ 'methods' => 'POST', 'callback' => [ $this, 'open' ], 'permission_callback' => [ $this, 'checkNonce' ], ] ); register_rest_route( self::NS, '/doors/(?P\d{1,2})/apply', [ 'methods' => 'POST', 'callback' => [ $this, 'apply' ], 'permission_callback' => [ $this, 'checkNonce' ], ] ); } /** * Logged-out visitors are not covered by core's cookie nonce check, so the * nonce is verified here for everyone. * * @return true|\WP_Error */ public function checkNonce( \WP_REST_Request $request ) { $nonce = (string) $request->get_header( 'X-WP-Nonce' ); if ( '' !== $nonce && wp_verify_nonce( $nonce, 'wp_rest' ) ) { return true; } return new \WP_Error( 'advently_bad_nonce', __( 'Your session has expired. Please reload the page.', 'advently' ), [ 'status' => 403 ] ); } public function state( \WP_REST_Request $request ): \WP_REST_Response { $config = Option::get(); $schedule = Option::schedule( $config ); $preview = self::previewNow( $request, $config ); $now = $preview ? $preview : Option::now(); $subscriber = $preview ? null : $this->subscriber( $config['mode'], false ); $next = $schedule->nextUnlock( $now ); $opened = $subscriber ? ( new Opens( $config['start'] ) )->openedBy( $subscriber['id'] ) : []; $response = new \WP_REST_Response( [ 'mode' => $config['mode'], // Login mode creates the subscriber row on the first open, so being logged in is enough. 'identified' => $preview || 'open' === $config['mode'] || null !== $subscriber || ( 'login' === $config['mode'] && is_user_logged_in() ), 'preview' => null !== $preview, 'opened' => $opened, 'doors' => $schedule->statuses( $now ), 'now' => $now->format( DATE_ATOM ), 'next_unlock' => $next ? $next->format( DATE_ATOM ) : null, 'nonce' => wp_create_nonce( 'wp_rest' ), ] ); $response->header( 'Cache-Control', 'no-store, private' ); return $response; } public function subscribe( \WP_REST_Request $request ): \WP_REST_Response { $config = Option::get(); if ( 'email' !== $config['mode'] ) { return self::refuse( 400, 'wrong_mode' ); } if ( ! $this->limiter( self::LIMIT_SUBSCRIBE_IP )->hit( 'subscribe:' . self::ipHash() ) ) { return self::refuse( 429, 'rate_limited' ); } $email = sanitize_email( (string) $request->get_param( 'email' ) ); if ( '' === $email || ! is_email( $email ) ) { return self::refuse( 400, 'bad_email' ); } // Sending the coupons is what the visitor asked for; offers need their own, optional yes. $marketing = true === $request->get_param( 'consent' ); $subscribers = $this->subscribers(); $subscriber = $subscribers->subscribe( $email, $marketing, self::ipHash() ); $expires = Option::schedule( $config )->end()->modify( '+30 days' )->getTimestamp(); setcookie( self::COOKIE, $subscribers->token( $subscriber['id'] ), [ 'expires' => $expires, 'path' => COOKIEPATH ? COOKIEPATH : '/', 'domain' => COOKIE_DOMAIN ? COOKIE_DOMAIN : '', 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax', ] ); $response = new \WP_REST_Response( [ 'ok' => true, 'opened' => ( new Opens( $config['start'] ) )->openedBy( $subscriber['id'] ), ] ); $response->header( 'Cache-Control', 'no-store, private' ); return $response; } public function open( \WP_REST_Request $request ): \WP_REST_Response { $config = Option::get(); $door = (int) $request->get_param( 'door' ); $preview = self::previewNow( $request, $config ); if ( $preview ) { return self::previewOpen( $config, $door, $preview ); } if ( ! $this->limiter( self::LIMIT_OPEN_IP )->hit( 'open:' . self::ipHash() ) ) { return self::refuse( 429, 'rate_limited' ); } $subscriber = $this->subscriber( $config['mode'], true ); if ( $subscriber && ! $this->limiter( self::LIMIT_OPEN_SUBSCRIBER )->hit( 'open-sub:' . $subscriber['id'] ) ) { return self::refuse( 429, 'rate_limited' ); } $now = Option::now(); $schedule = Option::schedule( $config ); $opener = new Opener( $config, $schedule, new Opens( $config['start'] ), new Issuer( $schedule, $config['prefix'], $config['start'], $now ), new CouponEmail( $config ) ); $user = wp_get_current_user(); $result = $opener->open( $door, [ 'mode' => $config['mode'], 'subscriber' => $subscriber, 'user_email' => $user->exists() ? $user->user_email : null, ], $now ); if ( ! $result['ok'] ) { $extra = 'locked' === $result['reason'] ? [ 'opens_at' => $schedule->dateOf( $door )->format( DATE_ATOM ) ] : []; return self::refuse( $result['status'], $result['reason'], $extra ); } $response = new \WP_REST_Response( [ 'ok' => true, 'door' => $door, 'repeat' => $result['repeat'], 'late' => 'past' === $result['reason'], 'coupon' => self::coupon( $result, $config['door_list'][ $door ] ), 'content' => self::content( $result['door']['content'] ), ] ); $response->header( 'Cache-Control', 'no-store, private' ); return $response; } /** * Applies the coupon this visitor already received for door n. The code is * looked up on the server, never taken from the request, so this cannot be * used to try codes. */ public function apply( \WP_REST_Request $request ): \WP_REST_Response { $config = Option::get(); $door = (int) $request->get_param( 'door' ); if ( self::previewNow( $request, $config ) ) { return self::refuse( 409, 'preview' ); } if ( ! $this->limiter( self::LIMIT_OPEN_IP )->hit( 'open:' . self::ipHash() ) ) { return self::refuse( 429, 'rate_limited' ); } $code = null; $email = null; if ( 'open' === $config['mode'] ) { $decision = Option::schedule( $config )->decide( $door, Option::now(), $config['missed'] ); if ( $decision['allowed'] && $decision['coupon'] ) { $code = $this->couponCode( (int) get_option( Issuer::sharedOption( $config['start'], $door ), 0 ) ); } } else { $subscriber = $this->subscriber( $config['mode'], false ); if ( ! $subscriber ) { return self::refuse( 403, 'login' === $config['mode'] ? 'need_login' : 'need_email' ); } $open = ( new Opens( $config['start'] ) )->find( $subscriber['id'], $door ); $code = $open && $open['coupon_id'] ? $this->couponCode( $open['coupon_id'] ) : null; $email = $subscriber['email']; } if ( ! $code ) { return self::refuse( 404, 'no_coupon' ); } wc_load_cart(); $cart = WC()->cart; // REST requests run after wp_loaded, so the session cart is only read on first access. $cart->get_cart(); // Guests with an empty cart have no session cookie yet; without one the coupon would be lost. WC()->session->set_customer_session_cookie( true ); if ( $email && '' === (string) WC()->customer->get_billing_email() ) { // Restricted coupons need the customer's email; it also pre-fills checkout. WC()->customer->set_billing_email( $email ); WC()->customer->save(); } $coupon = new \WC_Coupon( $code ); /** * Filters whether the cart is ready for the coupon, right before it is applied. * Add-ons put what their coupon kind needs in the cart here, or return a * WP_Error whose message the visitor sees. * * @param true|\WP_Error $ready True to go ahead. * @param \WC_Coupon $coupon The visitor's coupon. * @param \WC_Cart $cart The visitor's cart. */ $ready = apply_filters( 'advently_before_apply', true, $coupon, $cart ); if ( is_wp_error( $ready ) ) { wc_clear_notices(); $response = self::refuse( 409, (string) $ready->get_error_code() ); $response->set_data( array_merge( $response->get_data(), [ 'message' => $ready->get_error_message() ] ) ); return $response; } if ( ! $cart->has_discount( $code ) && ! $cart->apply_coupon( $code ) ) { $errors = wc_get_notices( 'error' ); wc_clear_notices(); $message = $errors ? wp_strip_all_tags( html_entity_decode( (string) $errors[0]['notice'], ENT_QUOTES, 'UTF-8' ) ) : self::message( 'apply_failed' ); $response = self::refuse( 409, 'apply_failed' ); $response->set_data( array_merge( $response->get_data(), [ 'message' => $message ] ) ); return $response; } wc_clear_notices(); $cart->calculate_totals(); $response = new \WP_REST_Response( [ 'ok' => true, 'code' => $code, 'cart_url' => wc_get_cart_url(), 'checkout_url' => wc_get_checkout_url(), 'cart_count' => $cart->get_cart_contents_count(), // A coupon in an empty cart waits for products; the card says so and points to the shop. 'cart_empty' => $cart->is_empty(), 'shop_url' => wc_get_page_permalink( 'shop' ), ] ); $response->header( 'Cache-Control', 'no-store, private' ); return $response; } /** * The moment a store manager is previewing (noon of the chosen day), or null. * * Owners set the calendar up before it starts, when every door is still * locked; the preview lets them open any door. It never issues a coupon, * records an open or sends an email, and only answers users who can manage * the store (the REST nonce proves the request is theirs). */ private static function previewNow( \WP_REST_Request $request, array $config ): ?\DateTimeImmutable { $day = (string) $request->get_param( 'preview' ); if ( '' === $day || ! current_user_can( 'manage_woocommerce' ) ) { return null; } $date = \DateTimeImmutable::createFromFormat( '!Y-m-d', $day, wp_timezone() ); if ( ! $date || $date->format( 'Y-m-d' ) !== $day ) { return null; } $schedule = Option::schedule( $config ); $noon = $date->setTime( 12, 0 ); // Any day from a month before the start to the day after the end. if ( $noon < $schedule->dateOf( 1 )->modify( '-31 days' ) || $noon > $schedule->end()->modify( '+1 day' ) ) { return null; } return $noon; } /** * What the door would show on the previewed day, with a sample code. */ private static function previewOpen( array $config, int $door, \DateTimeImmutable $now ): \WP_REST_Response { $schedule = Option::schedule( $config ); $decision = $schedule->decide( $door, $now, $config['missed'] ); if ( ! $decision['allowed'] ) { $extra = 'locked' === $decision['reason'] ? [ 'opens_at' => $schedule->dateOf( $door )->format( DATE_ATOM ) ] : []; return self::refuse( 'invalid' === $decision['reason'] ? 404 : 403, $decision['reason'], $extra ); } $settings = $config['door_list'][ $door ]; $hasCoupon = 'content' !== $settings['type']; $hours = (int) $settings['coupon']['validity_hours']; $expires = $hours > 0 ? $now->modify( '+' . $hours . ' hours' ) : $schedule->end(); $result = [ 'coupon_withheld' => $hasCoupon && ! $decision['coupon'], 'coupon' => $hasCoupon && $decision['coupon'] ? [ 'code' => sprintf( '%s-%02d-SAMPLE', $config['prefix'], $door ), 'expires' => $expires->format( DATE_ATOM ), ] : null, 'emailed' => false, ]; $response = new \WP_REST_Response( [ 'ok' => true, 'door' => $door, 'repeat' => false, 'late' => 'past' === $decision['reason'], 'preview' => true, 'coupon' => self::coupon( $result, $settings ), 'content' => 'coupon' === $settings['type'] ? null : self::content( $settings['content'] ), ] ); $response->header( 'Cache-Control', 'no-store, private' ); return $response; } private function couponCode( int $couponId ): ?string { if ( $couponId <= 0 || 'shop_coupon' !== get_post_type( $couponId ) || 'publish' !== get_post_status( $couponId ) ) { return null; } return ( new \WC_Coupon( $couponId ) )->get_code(); } /** * The visitor's subscriber row: from the signed cookie in email mode, from * the logged-in account in login mode (created on first use when $create). */ private function subscriber( string $mode, bool $create ): ?array { if ( 'email' === $mode ) { $cookie = isset( $_COOKIE[ self::COOKIE ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE ] ) ) : ''; return '' === $cookie ? null : $this->subscribers()->findByToken( $cookie ); } if ( 'login' === $mode && is_user_logged_in() ) { $user = wp_get_current_user(); if ( $create ) { return $this->subscribers()->forUser( $user, self::ipHash() ); } return $this->subscribers()->findByEmail( $user->user_email ); } return null; } private static function coupon( array $result, array $door ): ?array { if ( $result['coupon_withheld'] ) { return [ 'withheld' => true, 'message' => __( "This door's gift was only available on its day.", 'advently' ), ]; } $coupon = $result['coupon']; if ( ! $coupon ) { return null; } $settings = $door['coupon']; return [ 'withheld' => false, 'code' => $coupon['code'], 'discount' => Label::discount( $settings ), 'conditions' => Label::conditions( $settings ), 'expires' => $coupon['expires'], 'expiry_label' => Label::expiry( $coupon['expires'] ), 'emailed' => $result['emailed'], /** * Filters extra lines shown under a coupon on its card, e.g. how a gift reaches the cart. * * @param string[] $notes Plain-text lines. * @param array $settings The door's coupon settings. */ 'notes' => array_values( array_map( 'wp_strip_all_tags', (array) apply_filters( 'advently_coupon_notes', [], $settings ) ) ), ]; } private static function content( ?array $content ): ?array { if ( null === $content ) { return null; } $image = $content['image_id'] ? wp_get_attachment_image_src( $content['image_id'], 'large' ) : false; $link = null; if ( $content['product_id'] ) { $product = wc_get_product( $content['product_id'] ); if ( $product && 'publish' === $product->get_status() ) { $thumb = wp_get_attachment_image_src( $product->get_image_id(), 'woocommerce_thumbnail' ); $link = [ 'url' => $product->get_permalink(), 'label' => '' !== $content['button_label'] ? $content['button_label'] : __( 'Shop now', 'advently' ), 'name' => wp_strip_all_tags( $product->get_name() ), 'price' => html_entity_decode( wp_strip_all_tags( wc_price( (float) $product->get_price() ) ), ENT_QUOTES, 'UTF-8' ), 'image' => $thumb ? $thumb[0] : '', ]; } } return [ 'title' => wp_strip_all_tags( $content['title'] ), 'message' => wp_kses_post( wpautop( $content['message'] ) ), 'image' => $image ? [ 'url' => $image[0], 'width' => (int) $image[1], 'height' => (int) $image[2], 'alt' => wp_strip_all_tags( (string) get_post_meta( $content['image_id'], '_wp_attachment_image_alt', true ) ), ] : null, 'link' => $link, ]; } private static function refuse( int $status, string $reason, array $extra = [] ): \WP_REST_Response { $response = new \WP_REST_Response( array_merge( [ 'ok' => false, 'reason' => $reason, 'message' => self::message( $reason ), ], $extra ), $status ); $response->header( 'Cache-Control', 'no-store, private' ); return $response; } private static function message( string $reason ): string { $messages = [ 'locked' => __( 'Patience! This door is still locked.', 'advently' ), 'missed' => __( 'This door can no longer be opened.', 'advently' ), 'ended' => __( 'The advent calendar has ended. See you next year!', 'advently' ), 'invalid' => __( 'There is no such door.', 'advently' ), 'need_email' => __( 'Enter your email to open the doors.', 'advently' ), 'need_login' => __( 'Please log in to open the doors.', 'advently' ), 'rate_limited' => __( 'Too many attempts. Please wait a few minutes and try again.', 'advently' ), 'bad_email' => __( 'Please enter a valid email address.', 'advently' ), 'wrong_mode' => __( 'This calendar does not use email sign-up.', 'advently' ), 'no_coupon' => __( 'Open this door first to get its coupon.', 'advently' ), 'apply_failed' => __( 'The coupon could not be added to your cart.', 'advently' ), 'preview' => __( 'This is a sample code from the owner preview. Shoppers get a real, single-use code here.', 'advently' ), ]; return $messages[ $reason ] ?? __( 'Something went wrong. Please try again.', 'advently' ); } private function subscribers(): Subscribers { return new Subscribers( new SignedId( wp_salt( 'auth' ) ) ); } private function limiter( array $limit ): RateLimiter { return new RateLimiter( new TransientStore(), $limit[0], $limit[1] ); } /** * Keyed hash of the client IP: enough to rate-limit, never stored in the clear. */ private static function ipHash(): string { $server = []; foreach ( [ 'REMOTE_ADDR', 'HTTP_CF_CONNECTING_IP' ] as $key ) { if ( isset( $_SERVER[ $key ] ) ) { $server[ $key ] = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ); } } /** * Filters the visitor IP used for rate limiting (for proxies other than Cloudflare). * * @param string $ip Resolved IP. * @param array $server The REMOTE_ADDR / CF-Connecting-IP values it was resolved from. */ $ip = (string) apply_filters( 'advently_client_ip', ClientIp::resolve( $server ), $server ); return hash_hmac( 'sha256', $ip, wp_salt( 'nonce' ) ); } }