rest_authorization_required_code() ) ); } /** * Wrap a permission callback with the Pro licence gate. * * Applied to endpoints that perform a *Pro action* — never to reads. Per * docs/architecture/07-licensing-client.md § 3 an inactive licence pauses * new work; it never hides existing data and never touches the front end. * * Returns HTTP 402 rather than 403 so the admin UI can tell "you need a * licence" apart from "you lack the capability". * * @param callable $inner Capability (and optionally rate-limit) callback. * @param string $feature Feature id for the gate. * @return callable */ protected function pro( callable $inner, string $feature = 'pro' ): callable { return static function ( $request ) use ( $inner, $feature ) { $check = $inner( $request ); if ( true !== $check ) { return $check; } /* * No licensing module, no gate. * * The WordPress.org edition ships without the Licensing module and * without the Freemius SDK, because guideline 5 forbids a hosted * plugin from locking a feature behind a licence. Its absence is * the permissive case by construction: there is no code path left * that could answer "no". */ $gate_class = \AiRankSuite\Modules\Licensing\Services\LicenseGate::class; if ( ! class_exists( $gate_class ) ) { return true; } if ( $gate_class::instance()->allows( $feature ) ) { return true; } return new WP_Error( 'airanksuite_license_required', __( 'Your AI Rank Suite licence is not active on this site, so this action is paused. Activate your licence key to continue — nothing has been deleted or changed.', 'ai-rank-suite' ), array( 'status' => 402, // $gate never existed: the variable holding the gate is // $gate_class. Reading a property off it made the one path // that tells a customer their licence lapsed fatal instead. 'manageUrl' => $gate_class::instance()->account_url(), ) ); }; } /** * Take one token from a bucket, or say how long until there is one. * * Split out of `can_limited()` so a callback can decide for itself whether * this particular request deserves to be charged. The link graph is why: * pressing "Build the graph" while a crawl is already running resumes it * rather than starting anything, and charging that to a six-an-hour budget * meant a customer whose run had stalled could press the button six times, * start nothing, and then be locked out of their own site for an hour. * * @return true|\WP_Error */ protected static function spend( string $bucket, int $max, int $window = HOUR_IN_SECONDS ) { { $key = 'airanksuite_rl_' . $bucket . '_' . get_current_user_id(); $count = (int) get_transient( $key ); if ( $count >= $max ) { /* * Say how long, because "a moment" was a lie. * * The window is an hour on every bucket, and the message told * people to wait a moment and try again — so they tried again, * and again, each attempt landing on the same wall with the * same encouraging sentence. The remaining time is knowable: * WordPress stores a transient's expiry as its own option. */ $timeout = (int) get_option( '_transient_timeout_' . $key, 0 ); $left = $timeout > 0 ? max( 0, $timeout - time() ) : $window; $minutes = (int) max( 1, (int) ceil( $left / MINUTE_IN_SECONDS ) ); return new WP_Error( 'airanksuite_rate_limited', sprintf( /* * One form, not _n(). Every bucket in this plugin allows * at least three runs an hour, so the singular can never * be reached — and bin/po2mo.php does not compile plural * blocks, so a _n() here would ship untranslated. */ /* translators: 1: how many runs are allowed per hour, 2: minutes until the limit resets. */ __( 'This action is limited to %1$d runs an hour, and they are used up. It can run again in %2$d minutes.', 'ai-rank-suite' ), $max, $minutes ), array( 'status' => 429, 'retryAfter' => $left, ) ); } set_transient( $key, $count + 1, $window ); return true; } } }