add_action( 'rest_api_init', $this, 'register_routes' ); } /** * Registers every REST route this controller owns. */ public function register_routes(): void { register_rest_route( self::NAMESPACE_V1, '/sitemap/status', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_sitemap_status' ), 'permission_callback' => array( $this, 'can_manage_plugin' ), ) ); register_rest_route( self::NAMESPACE_V1, '/sitemap/regenerate', array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'regenerate_sitemap' ), 'permission_callback' => array( $this, 'can_manage_plugin' ), ) ); // 1.6: the explicit "Check again" action — a fresh, longer-timeout // re-probe of the loopback verification (distinct from // /sitemap/regenerate above, which only rebuilds the XML content and // never touches the loopback-verified cache). register_rest_route( self::NAMESPACE_V1, '/sitemap/check-again', array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'check_sitemap_again' ), 'permission_callback' => array( $this, 'can_manage_plugin' ), ) ); register_rest_route( self::NAMESPACE_V1, '/robots-txt', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_robots_txt' ), 'permission_callback' => array( $this, 'can_manage_plugin' ), ) ); // The editor's write actions are gated on manage_options, not this // plugin's own manage_abds_seo capability (which the seo_manager // role also holds) — a mistaken save here can deindex the whole // site, a higher blast radius than the plugin's other settings. register_rest_route( self::NAMESPACE_V1, '/robots-txt', array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'save_robots_txt' ), 'permission_callback' => array( $this, 'can_manage_options' ), 'args' => array( 'content' => array( 'required' => true, 'type' => 'string', ), 'confirm_disallow_all' => array( 'required' => false, 'type' => 'boolean', 'default' => false, ), ), ) ); register_rest_route( self::NAMESPACE_V1, '/robots-txt/restore-default', array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'restore_robots_txt' ), 'permission_callback' => array( $this, 'can_manage_options' ), ) ); // M7 free: same permission level as /sitemap/regenerate above -- // this toggle only starts/stops telling a third party (indexnow.org) // about publishes, not a site-wide crawl-blocking action the way // robots.txt writes are, so it stays on can_manage_plugin() rather // than the stricter manage_options robots.txt uses. register_rest_route( self::NAMESPACE_V1, '/indexnow/status', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_indexnow_status' ), 'permission_callback' => array( $this, 'can_manage_plugin' ), ) ); register_rest_route( self::NAMESPACE_V1, '/indexnow/settings', array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'save_indexnow_settings' ), 'permission_callback' => array( $this, 'can_manage_plugin' ), 'args' => array( 'enabled' => array( 'required' => true, 'type' => 'boolean', ), ), ) ); } /** * Permission callback for the robots.txt editor's write actions. * * @return bool */ public function can_manage_options() { return current_user_can( 'manage_options' ); } /** * GET /sitemap/status. * * @return WP_REST_Response */ public function get_sitemap_status(): WP_REST_Response { return new WP_REST_Response( ( new ABDS_SEO_Sitemap_Controller() )->status() ); } /** * POST /sitemap/regenerate — forces a fresh build, returns the fresh * status snapshot. * * @return WP_REST_Response */ public function regenerate_sitemap(): WP_REST_Response { $controller = new ABDS_SEO_Sitemap_Controller(); $controller->regenerate(); return new WP_REST_Response( $controller->status() ); } /** * POST /sitemap/check-again (1.6) — the explicit, user-initiated * re-probe: bypasses the cache and forces a fresh loopback check using * the longer retry timeout. See * ABDS_SEO_Sitemap_Controller::check_again()'s own docblock for why * this REST action specifically is one of the two contexts allowed to * run that longer timeout synchronously. * * @return WP_REST_Response */ public function check_sitemap_again(): WP_REST_Response { $controller = new ABDS_SEO_Sitemap_Controller(); $controller->check_again(); return new WP_REST_Response( $controller->status() ); } /** * GET /robots-txt — the live file's content plus this checker's three * pure checks (sitemap declared, /wp-admin/ blocked, no site-wide blanket * disallow), the same competitor flag the Sitemap tab shows (F2.7: the * conflict warning appears on both tabs), and the editor's own state: * whether a physical file shadows the virtual robots.txt (editing is * impossible while it does), whether a custom value is saved, the * editor's field content, and hints on that content. * * @return WP_REST_Response */ public function get_robots_txt(): WP_REST_Response { $fetched = ABDS_SEO_Robots_Txt_Checker::fetch(); $sitemap_url = home_url( '/sitemap.xml' ); return new WP_REST_Response( $this->robots_txt_payload( $fetched, $sitemap_url ) ); } /** * POST /robots-txt — sanitizes and saves the editor's draft. Refuses to * save while a physical robots.txt file is present (the `robots_txt` * filter would never fire, so a "successful" save here would silently * do nothing real) rather than pretending it worked. A blanket * `Disallow: /` under `User-agent: *` is never rejected — the site * owner has the right to do this deliberately — but isn't saved without * `confirm_disallow_all: true` on the request first, so the client can * show that as its own explicit confirmation step, not fine print. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function save_robots_txt( WP_REST_Request $request ) { if ( ABDS_SEO_Robots_Editor::has_physical_file() ) { return new WP_Error( 'abds_seo_robots_txt_physical_file', __( 'A physical robots.txt file exists in the site root, so changes here cannot take effect. Remove or rename that file first.', 'abds-seo' ), array( 'status' => 400 ) ); } $editor = new ABDS_SEO_Robots_Editor(); $raw = (string) $request->get_param( 'content' ); $sanitized = ABDS_SEO_Robots_Editor::sanitize( $raw ); $confirmed = (bool) $request->get_param( 'confirm_disallow_all' ); $critical = ABDS_SEO_Robots_Txt_Checker::critical_disallow_all( $sanitized ); if ( $critical && ! $confirmed ) { return new WP_REST_Response( array( 'saved' => false, 'needs_confirmation' => true, 'critical_disallow_all' => true, 'hints' => ABDS_SEO_Robots_Editor::find_hints( $sanitized ), ) ); } $editor->save( $raw ); ABDS_SEO_Robots_Txt_Checker::invalidate_cache(); $fetched = ABDS_SEO_Robots_Txt_Checker::fetch(); $sitemap_url = home_url( '/sitemap.xml' ); return new WP_REST_Response( array_merge( array( 'saved' => true ), $this->robots_txt_payload( $fetched, $sitemap_url ) ) ); } /** * POST /robots-txt/restore-default — clears the custom option, reverting * output to WordPress's own virtual default. * * @return WP_REST_Response */ public function restore_robots_txt(): WP_REST_Response { ( new ABDS_SEO_Robots_Editor() )->restore_default(); ABDS_SEO_Robots_Txt_Checker::invalidate_cache(); $fetched = ABDS_SEO_Robots_Txt_Checker::fetch(); $sitemap_url = home_url( '/sitemap.xml' ); return new WP_REST_Response( array_merge( array( 'saved' => true ), $this->robots_txt_payload( $fetched, $sitemap_url ) ) ); } /** * GET /indexnow/status — the toggle state, the key file's public URL, * and the recent-submissions log (ABDS_SEO_Indexnow_Submitter::LOG_OPTION, * already newest-first and capped). * * @return WP_REST_Response */ public function get_indexnow_status(): WP_REST_Response { return new WP_REST_Response( $this->indexnow_payload() ); } /** * POST /indexnow/settings — flips the submission toggle. The key * itself is never editable here (no rotation in this build, see * ABDS_SEO_Indexnow_Key's own docblock). * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function save_indexnow_settings( WP_REST_Request $request ): WP_REST_Response { $settings = get_option( 'abds_seo_settings', array() ); $settings['indexnow']['enabled'] = (bool) $request->get_param( 'enabled' ); update_option( 'abds_seo_settings', $settings ); return new WP_REST_Response( $this->indexnow_payload() ); } /** * Builds the shared GET/POST IndexNow response payload. * * @return array */ protected function indexnow_payload(): array { return array( 'enabled' => ABDS_SEO_Indexnow_Submitter::is_enabled(), 'key_url' => ABDS_SEO_Indexnow_Key::key_url(), 'log' => get_option( ABDS_SEO_Indexnow_Submitter::LOG_OPTION, array() ), ); } /** * Builds the shared GET/POST robots.txt response payload. * * @param array{content:string,fetch_error:string} $fetched The live-fetch result. * @param string $sitemap_url This site's sitemap URL. * @return array */ protected function robots_txt_payload( array $fetched, string $sitemap_url ): array { $editor = new ABDS_SEO_Robots_Editor(); $editor_content = $editor->get_content(); return array( 'content' => $fetched['content'], 'fetch_error' => $fetched['fetch_error'], 'sitemap_url' => $sitemap_url, 'has_active_competitor' => ( new ABDS_SEO_Compat_Detector() )->has_active_competitor(), 'checks' => array( 'sitemap_declared' => ABDS_SEO_Robots_Txt_Checker::sitemap_declared( $fetched['content'], $sitemap_url ), 'wp_admin_blocked_appropriately' => ABDS_SEO_Robots_Txt_Checker::wp_admin_blocked_appropriately( $fetched['content'] ), 'critical_disallow_all' => ABDS_SEO_Robots_Txt_Checker::critical_disallow_all( $fetched['content'] ), ), 'editor' => array( 'has_physical_file' => ABDS_SEO_Robots_Editor::has_physical_file(), 'physical_file_path' => ABDS_SEO_Robots_Editor::has_physical_file() ? ABDS_SEO_Robots_Editor::physical_file_path() : '', 'has_custom_content' => $editor->has_custom_content(), 'content' => $editor_content, 'hints' => ABDS_SEO_Robots_Editor::find_hints( $editor_content ), ), ); } }