*/ public const FLUSH_FLAGS = [ 'llms.enabled', 'llms.full_enabled', 'humans.enabled', 'security.enabled', 'ai_txt.enabled', 'ai_context.enabled', 'indexnow.enabled', 'static_files.enabled', ]; /** * The namespace the admin scripts should call. * * @return string REST namespace, e.g. `acid-aeo/v1`. */ public static function namespace(): string { return self::REST_NAMESPACE; } /** * Hooks the controller into WordPress. * * @return void */ public function register(): void { add_action( 'rest_api_init', [ $this, 'register_routes' ] ); } /** * Registers every route of the namespace. * * @return void */ public function register_routes(): void { foreach ( $this->routes() as $route => $endpoints ) { register_rest_route( self::REST_NAMESPACE, $route, $endpoints ); } } /** * Whether the current user may call these routes. * * @return bool True for administrators. */ public function can_manage(): bool { return current_user_can( 'manage_options' ); } /** * Saves a batch of settings sections and reports whether rules changed. * * Every section name is checked before anything is written, so a request * naming one section that does not exist saves none of the others rather * than half of them. * * @param array $sections Section name => raw section data. * @return array|WP_Error Keys `settings` and `flush_needed`, or the first problem found. */ public static function save_sections( array $sections ) { $schema = Options::schema(); foreach ( $sections as $name => $data ) { $name = (string) $name; if ( ! array_key_exists( $name, $schema ) ) { return new WP_Error( 'acid_aeo_unknown_section', sprintf( /* translators: %s: settings section name. */ __( 'Unknown settings section: %s', 'acid-aeo' ), sanitize_key( $name ) ), [ 'status' => 400 ] ); } if ( ! is_array( $data ) ) { return new WP_Error( 'acid_aeo_invalid_section', sprintf( /* translators: %s: settings section name. */ __( 'Section "%s" has to be an object.', 'acid-aeo' ), sanitize_key( $name ) ), [ 'status' => 400 ] ); } } $before = Options::all(); foreach ( $sections as $name => $data ) { Options::update_section( (string) $name, (array) $data ); } $after = Options::all(); $flush = self::flush_needed_from( $before, $after ); if ( $flush ) { update_option( self::FLUSH_OPTION, 1 ); } self::forget_derived_data(); return [ 'settings' => $after, 'flush_needed' => $flush, ]; } /** * Whether the rewrite rules have to be rebuilt after a settings change. * * Pure: the same two settings arrays always give the same answer. * * @param array $before Settings before the save. * @param array $after Settings after the save. * @return bool True when a module's file was switched on or off. */ public static function flush_needed_from( array $before, array $after ): bool { foreach ( self::FLUSH_FLAGS as $path ) { if ( self::flag( $before, $path ) !== self::flag( $after, $path ) ) { return true; } } return false; } /** * Drops every memo derived from the settings. * * A settings write inside one request leaves `Detect\Environment`, * `Data\SiteData` and `Data\ContentData` holding answers computed from the * settings that were just replaced, and the audit or the preview that * follows in the same request would read them. * * @return void */ public static function forget_derived_data(): void { Environment::reset(); SiteData::reset(); ContentData::reset(); } /** * A module instance, booted or fresh. * * A module that is switched off is instantiated by `Plugin` but never * registered, and a module class added after this one was written may not * exist at all — so the booted instance is preferred and a fresh one is * built when there is none. Rendering and submitting do not depend on the * module having hooked itself in. * * @param string $slug Module slug, e.g. `indexnow`. * @param string $class_name Fully qualified module class name. * @return object|null The module, or null when the class does not exist. */ public static function module( string $slug, string $class_name ): ?object { $booted = Plugin::instance()->module( $slug ); if ( $booted instanceof $class_name ) { return $booted; } return class_exists( $class_name ) ? new $class_name() : null; } /** * A boolean argument schema. * * @param bool $default_value Value used when the argument is absent. * @return array Argument schema. */ public static function bool_arg( bool $default_value ): array { return [ 'type' => 'boolean', 'required' => false, 'default' => $default_value, 'sanitize_callback' => 'rest_sanitize_boolean', 'validate_callback' => 'rest_validate_request_arg', ]; } /** * An integer argument schema, bounded on both sides. * * @param int|null $default_value Value used when the argument is absent; null leaves it absent. * @param int $minimum Smallest accepted value. * @param int $maximum Largest accepted value. * @return array Argument schema. */ public static function int_arg( ?int $default_value, int $minimum, int $maximum ): array { $arg = [ 'type' => 'integer', 'required' => false, 'minimum' => $minimum, 'maximum' => $maximum, 'sanitize_callback' => 'rest_sanitize_request_arg', 'validate_callback' => 'rest_validate_request_arg', ]; if ( null !== $default_value ) { $arg['default'] = $default_value; } return $arg; } /** * A string argument schema restricted to a list of values. * * @param array $values Accepted values. * @param string $default_value Value used when the argument is absent. * @return array Argument schema. */ public static function enum_arg( array $values, string $default_value ): array { return [ 'type' => 'string', 'required' => false, 'default' => $default_value, 'enum' => $values, 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => 'rest_validate_request_arg', ]; } /** * A free-form object argument, sanitized later by `Options`. * * Settings payloads are nested and every leaf has its own rule, so they are * validated as objects here and sanitized against the schema by * `Options::sanitize()`, which is the only place that knows what each leaf * is allowed to be. * * @return array Argument schema. */ public static function object_arg(): array { return [ 'type' => 'object', 'required' => false, 'default' => [], 'validate_callback' => 'rest_validate_request_arg', ]; } /** * A short text argument. * * @param int $max_length Longest accepted value, in characters. * @return array Argument schema. */ public static function text_arg( int $max_length ): array { return [ 'type' => 'string', 'required' => false, 'default' => '', 'maxLength' => $max_length, 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => 'rest_validate_request_arg', ]; } /** * Every route of the namespace, keyed by path, ready to be registered. * * The handlers are built here rather than in the constructor so that a * front end request, which never reaches `rest_api_init`, does not pay for * seven objects it will not use. * * Public and free of side effects so the whole table can be walked and * asserted about — the capability check on every endpoint is an invariant, * and an invariant nothing can read is one nothing can defend. * * @return array>> Route => endpoints. */ public function routes(): array { $routes = []; foreach ( $this->route_table() as $route => $endpoints ) { $routes[ $route ] = array_map( [ $this, 'endpoint' ], $endpoints ); } return $routes; } /** * The route table as it is written, before the shared keys are filled in. * * @return array>> Route => endpoints. */ private function route_table(): array { $settings = new Handlers\Settings(); $preview = new Handlers\Preview(); $audit = new Handlers\Audit(); $wizard = new Handlers\Wizard(); $indexnow = new Handlers\IndexNow(); $citations = new Handlers\Citations(); $tools = new Handlers\Tools(); return [ '/settings' => [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $settings, 'index' ], ], [ 'methods' => 'PUT', 'callback' => [ $settings, 'update' ], 'args' => Handlers\Settings::update_args(), ], ], '/environment' => [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $tools, 'environment' ], ], ], '/preview/(?P[a-z_]+)' => [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $preview, 'show' ], 'args' => Handlers\Preview::show_args(), ], ], '/audit' => [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $audit, 'run' ], 'args' => Handlers\Audit::run_args(), ], ], '/audit/fix/(?P[a-z0-9_]+)' => [ [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $audit, 'fix' ], 'args' => Handlers\Audit::fix_args(), ], ], '/wizard/step' => [ [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $wizard, 'step' ], 'args' => Handlers\Wizard::step_args(), ], ], '/wizard/complete' => [ [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $wizard, 'complete' ], 'args' => Handlers\Wizard::complete_args(), ], ], '/indexnow/submit' => [ [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $indexnow, 'submit' ], 'args' => Handlers\IndexNow::submit_args(), ], ], '/indexnow/log' => [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $indexnow, 'log' ], ], ], '/citations/stats' => [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $citations, 'stats' ], 'args' => Handlers\Citations::stats_args(), ], ], '/citations/events' => [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $citations, 'events' ], 'args' => Handlers\Citations::events_args(), ], ], '/citations/export' => [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $citations, 'export' ], ], ], '/citations' => [ [ 'methods' => WP_REST_Server::DELETABLE, 'callback' => [ $citations, 'purge' ], 'args' => Handlers\Citations::purge_args(), ], ], '/static-files/write' => [ [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $tools, 'write_static_files' ], ], ], '/cache/flush' => [ [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $tools, 'flush_cache' ], ], ], '/export' => [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $tools, 'export' ], ], ], '/import' => [ [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $tools, 'import' ], 'args' => Handlers\Tools::import_args(), ], ], '/system-info' => [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $tools, 'system_info' ], ], ], ]; } /** * Completes one endpoint declaration. * * The capability check is assigned after the defaults are merged, not * merged in with them: `+` keeps the left operand, so an entry that * declared a `permission_callback` of its own would have kept it. Every * route in this namespace is administrative, and the one public route the * plugin has — the citation beacon — is registered by `Modules\Citations`, * not here. So this is an overwrite on purpose, and there is no way to * write an entry in the table that escapes it. * * @param array $endpoint Endpoint declaration. * @return array Endpoint with its permission callback and arguments. */ private function endpoint( array $endpoint ): array { $endpoint += [ 'args' => [] ]; $endpoint['permission_callback'] = [ $this, 'can_manage' ]; return $endpoint; } /** * Reads one boolean setting out of a settings array by dot path. * * Deliberately does not go through `Options::get()`: the point is to read a * value out of the array that was handed in, not out of the current * settings. * * @param array $settings Settings array. * @param string $path Dot path, e.g. `llms.enabled`. * @return bool The flag, false when the path does not exist. */ private static function flag( array $settings, string $path ): bool { $value = $settings; foreach ( explode( '.', $path ) as $segment ) { if ( ! is_array( $value ) || ! array_key_exists( $segment, $value ) ) { return false; } $value = $value[ $segment ]; } return (bool) $value; } }