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;
?>