time(); } /** * Re-evaluate after a real send, from the log. * * Reads the log rather than keeping a counter: the log is written anyway, it * survives a deploy, and it is what the store owner looks at when asking why * the login form changed. * * @param int $failures Current run of failed sends. */ public static function evaluate( int $failures ): void { $threshold = self::threshold(); if ( $threshold > 0 && $failures >= $threshold ) { self::trip( $failures ); return; } if ( 0 === $failures && self::state()['since'] > 0 ) { self::clear( true ); } } /** * Close the breaker by hand, from the monitoring screen. */ public static function reset(): void { self::clear( false ); } /** * Failed sends in a row that trip it. Zero switches the failsafe off. */ public static function threshold(): int { return max( 0, (int) Settings::get( OtpAuthModule::SLUG . '.failsafe_threshold', self::DEFAULT_THRESHOLD ) ); } /** * What the admin should be told, or null when all is well. * * @return array{level: string, key: string, failures: int, since: int}|null */ public static function status(): ?array { if ( ! Gateways::canSend() ) { // Running on test mode alone is a legitimate state while a store is // being set up, so it is a warning, not an error. return [ 'level' => TestMode::isEnabled() ? 'warning' : 'error', 'key' => TestMode::isEnabled() ? 'test_only' : 'no_gateway', 'failures' => 0, 'since' => 0, ]; } $failures = OtpLog::consecutiveFailures(); $threshold = self::threshold(); $state = self::state(); if ( $state['since'] > 0 ) { return [ 'level' => 'error', 'key' => $state['until'] > time() ? 'tripped' : 'retrying', 'failures' => $failures, 'since' => $state['since'], ]; } // Warn before it trips, while a balance can still be topped up rather // than read about in a customer's complaint. if ( $threshold > 0 && $failures > 0 && $failures >= (int) ceil( $threshold / 2 ) ) { return [ 'level' => 'warning', 'key' => 'failing', 'failures' => $failures, 'since' => 0, ]; } return null; } /** * Open the breaker, or extend the hold of one already open. * * @param int $failures Current run of failed sends. */ private static function trip( int $failures ): void { $state = self::state(); $fresh = 0 === $state['since']; // Autoloaded on purpose: it is read from the loaded options on page views // with a login form, which is every page in some themes. update_option( self::OPTION, [ 'until' => time() + self::HOLD, 'since' => $fresh ? time() : $state['since'], ], true ); if ( $fresh ) { OtpLog::record( OtpLog::EVENT_FAILSAFE, OtpLog::RESULT_BLOCKED, [ 'message' => sprintf( /* translators: %d: failed sends in a row */ __( 'پس از %d ارسال ناموفق پیاپی، ورود پیامکی کنار رفت و فرم ورود عادی نشان داده می‌شود.', 'rahkar-toolkit' ), $failures ), 'ip' => '', ] ); Logger::error( 'otp', 'Failsafe tripped: the code form steps aside.', [ 'failures' => $failures ] ); } } /** * Close the breaker. * * @param bool $recovered Whether a successful send closed it, rather than an admin. */ private static function clear( bool $recovered ): void { if ( 0 === self::state()['since'] ) { return; } delete_option( self::OPTION ); OtpLog::record( OtpLog::EVENT_FAILSAFE, OtpLog::RESULT_OK, [ 'message' => $recovered ? __( 'ارسال دوباره موفق شد و ورود پیامکی برگشت.', 'rahkar-toolkit' ) : __( 'مدیر ورود پیامکی را دستی برگرداند.', 'rahkar-toolkit' ), 'ip' => '', ] ); Logger::info( 'otp', 'Failsafe cleared.', [ 'by' => $recovered ? 'send' : 'admin' ] ); } /** * The stored state, from the loaded options. * * @return array{until: int, since: int} */ private static function state(): array { $all = wp_load_alloptions(); $state = isset( $all[ self::OPTION ] ) ? maybe_unserialize( $all[ self::OPTION ] ) : null; return [ 'until' => is_array( $state ) ? (int) ( $state['until'] ?? 0 ) : 0, 'since' => is_array( $state ) ? (int) ( $state['since'] ?? 0 ) : 0, ]; } }