0, 'until' => time() + $window ); } $hit['n']++; set_transient( $key, $hit, max( 1, $hit['until'] - time() ) ); if ( $hit['n'] > $limit ) { return new WP_Error( 'seatt_rate_limited', __( 'Too many requests — please wait a moment and try again.', 'seathive' ), array( 'status' => 429 ) ); } return null; } /* ---------------- cart session id ---------------- */ /** Cart ids are generated in the browser (booking.js); anything that * doesn't look like one is refused rather than stored. */ public static function valid_session_id( $id ) { return is_string( $id ) && (bool) preg_match( '/^[A-Za-z0-9_-]{16,64}$/', $id ); } /* ---------------- order size ---------------- */ public static function max_per_order() { return max( 1, (int) get_option( 'seatt_max_seats_per_order', 10 ) ); } /** Per-event limit (event edit screen) or, if empty, the global one. */ public static function max_for_event( $event_id ) { $v = (int) get_post_meta( (int) $event_id, '_seatt_max_seats', true ); return $v > 0 ? $v : self::max_per_order(); } /** 'order' = per checkout; 'person' = total per buyer (email or phone) for the whole event. */ public static function limit_scope( $event_id ) { return 'person' === get_post_meta( (int) $event_id, '_seatt_max_seats_scope', true ) ? 'person' : 'order'; } public static function limit_message( $event_id ) { $max = self::max_for_event( $event_id ); return 'person' === self::limit_scope( $event_id ) ? sprintf( /* translators: %d: number */ __( 'Maximum %d seats per person for this event.', 'seathive' ), $max ) : sprintf( __( 'You can book up to %d tickets per order.', 'seathive' ), $max ); } /** * Seats + GA tickets this buyer already holds for the event, in paid * or awaiting-payment bookings — matched on email OR phone (phones * compared by digits, 08… = 628…), so a second checkout with the * same contact details can't get round a per-person limit. */ public static function booked_by_person( $event_id, $email, $phone ) { global $wpdb; $email = strtolower( trim( (string) $email ) ); $norm = function ( $p ) { $d = preg_replace( '/\D+/', '', (string) $p ); return 0 === strpos( $d, '0' ) ? '62' . substr( $d, 1 ) : $d; }; $phone = $norm( $phone ); if ( '' === $email && strlen( $phone ) < 6 ) { return 0; } $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT b.id, b.buyer_email, b.buyer_phone, ( SELECT COUNT(*) FROM %i bs WHERE bs.booking_id = b.id ) AS seats, ( SELECT COALESCE(SUM(bz.quantity),0) FROM %i bz WHERE bz.booking_id = b.id ) AS zone_qty FROM %i b WHERE b.event_id = %d AND b.status IN ( \'paid\', \'pending_payment\' )', $wpdb->seatt_booking_seats, $wpdb->seatt_booking_zones, $wpdb->seatt_bookings, $event_id ) ); $n = 0; foreach ( (array) $rows as $r ) { $same = ( '' !== $email && strtolower( trim( (string) $r->buyer_email ) ) === $email ) || ( strlen( $phone ) >= 6 && $norm( $r->buyer_phone ) === $phone ); if ( $same ) { $n += (int) $r->seats + (int) $r->zone_qty; } } return $n; } /* ---------------- secrets at rest ---------------- */ private static function key() { $salt = function_exists( 'wp_salt' ) ? wp_salt( 'secure_auth' ) : ( defined( 'SECURE_AUTH_KEY' ) ? SECURE_AUTH_KEY : '' ); return hash( 'sha256', $salt . '|esm-secrets', true ); } public static function can_encrypt() { return function_exists( 'openssl_encrypt' ) && in_array( 'aes-256-gcm', array_map( 'strtolower', openssl_get_cipher_methods() ), true ); } public static function encrypt( $plain ) { $plain = (string) $plain; if ( '' === $plain || ! self::can_encrypt() || 0 === strpos( $plain, self::ENC_PREFIX ) ) { return $plain; } $iv = random_bytes( 12 ); $tag = ''; $ct = openssl_encrypt( $plain, 'aes-256-gcm', self::key(), OPENSSL_RAW_DATA, $iv, $tag ); if ( false === $ct ) { return $plain; } return self::ENC_PREFIX . base64_encode( $iv . $tag . $ct ); } public static function decrypt( $stored ) { $stored = (string) $stored; if ( 0 !== strpos( $stored, self::ENC_PREFIX ) ) { return $stored; // legacy plaintext — still works until re-saved } if ( ! self::can_encrypt() ) { return ''; } $raw = base64_decode( substr( $stored, strlen( self::ENC_PREFIX ) ), true ); if ( false === $raw || strlen( $raw ) < 29 ) { return ''; } $plain = openssl_decrypt( substr( $raw, 28 ), 'aes-256-gcm', self::key(), OPENSSL_RAW_DATA, substr( $raw, 0, 12 ), substr( $raw, 12, 16 ) ); // false = the site's salts changed since it was saved; the admin // has to enter the key again (Settings shows it as not set). return false === $plain ? '' : $plain; } public static function decrypt_option( $value ) { return self::decrypt( $value ); } /** * pre_update_option_* for secrets. The settings screen never prints * a saved secret back into the page, so the field arrives EMPTY when * the admin didn't retype it — that means "keep the saved one", not * "erase it". Ticking the field's "clear" box sends the sentinel * __seatt_clear__ to really remove it. */ public static function encrypt_on_save( $new, $old, $option = '' ) { if ( '__seatt_clear__' === $new ) { return ''; } if ( '' === (string) $new ) { $new = (string) $old; // $old is already decrypted by option_* filter } return self::encrypt( trim( (string) $new ) ); } /** One-off on upgrade: encrypt secrets saved in plaintext before. */ public static function migrate_plaintext_secrets() { if ( ! self::can_encrypt() ) { return; } global $wpdb; foreach ( self::SECRET_OPTIONS as $opt ) { $raw = $wpdb->get_var( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name = %s', $wpdb->options, $opt ) ); if ( null !== $raw && '' !== $raw && 0 !== strpos( $raw, self::ENC_PREFIX ) ) { $wpdb->update( $wpdb->options, array( 'option_value' => self::encrypt( $raw ) ), array( 'option_name' => $opt ) ); wp_cache_delete( $opt, 'options' ); wp_cache_delete( 'alloptions', 'options' ); } } } /** * Masked input for a secret: never echoes the saved value, shows * whether one is set, and offers a "clear" checkbox. */ public static function secret_field( $option, $placeholder = '' ) { $is_set = '' !== (string) get_option( $option, '' ); $id = $option; ?>