array( 'type' => 'integer', 'required' => true, 'sanitize_callback' => 'absint', ), ); $this->route( '/summary', 'GET', 'summary', $use ); $this->route( '/pause', 'POST', 'pause', $use, array( 'paused' => array( 'type' => 'boolean', 'required' => true, ), ) ); $this->route( '/recheck-broken', 'POST', 'recheck_broken', $use ); $this->route( '/scan', 'POST', 'scan', $use ); $this->route( '/links', 'GET', 'links', $use, array( 'status' => array( 'type' => 'string', 'enum' => LinkQuery::STATUS_FILTERS, 'default' => 'problems', ), 'scope' => array( 'type' => 'string', 'enum' => array( '', 'internal', 'external' ), 'default' => '', ), 'source_type' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_key', ), 'post_type' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_key', ), 'search' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field', ), 'page' => array( 'type' => 'integer', 'default' => 1, 'minimum' => 1, ), 'orderby' => array( 'type' => 'string', 'default' => '', 'enum' => array( '', 'url', 'status', 'last_checked_at', 'fail_count', 'http_code' ), ), 'order' => array( 'type' => 'string', 'default' => 'desc', 'enum' => array( 'asc', 'desc' ), ), ) ); $this->route( '/links/(?P\d+)', 'GET', 'link', $use, $id_arg ); $this->route( '/links/(?P\d+)/recheck', 'POST', 'recheck_link', $use, $id_arg ); $this->route( '/actions/preview', 'POST', 'preview', $use, array( 'action' => array( 'type' => 'string', 'enum' => BulkActions::ACTIONS, 'required' => true, ), 'link_ids' => array( 'type' => 'array', 'items' => array( 'type' => 'integer' ), 'required' => true, ), 'new_url' => array( 'type' => 'string', 'default' => '', ), ) ); $this->route( '/actions/apply', 'POST', 'apply', $use, array( 'token' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field', ), ) ); $this->route( '/jobs/(?P[a-z0-9]+)', 'GET', 'job', $use, array( 'id' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_key', ), ) ); $this->route( '/actions/undo', 'POST', 'undo', $use ); $this->route( '/changes', 'GET', 'changes', $use ); $redirect_args = array( // Not sanitize_text_field(): it strips %XX sequences from URLs. RedirectRepository::validate() // normalizes the path and runs esc_url_raw() on the target. 'source_path' => array( 'type' => 'string', 'sanitize_callback' => array( self::class, 'sanitize_url_input' ), ), 'target_url' => array( 'type' => 'string', 'sanitize_callback' => array( self::class, 'sanitize_url_input' ), ), 'code' => array( 'type' => 'integer', 'enum' => RedirectRepository::CODES, ), 'match_mode' => array( 'type' => 'string', 'enum' => RedirectRepository::MODES, ), 'enabled' => array( 'type' => 'boolean' ), ); $this->route( '/redirects', 'GET', 'redirects', $manage, array( 'search' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field', ), 'page' => array( 'type' => 'integer', 'default' => 1, 'minimum' => 1, ), ) ); $this->route( '/redirects', 'POST', 'create_redirect', $manage, $redirect_args ); $this->route( '/redirects/(?P\d+)', 'PUT', 'update_redirect', $manage, array_merge( $id_arg, $redirect_args ) ); $this->route( '/redirects/(?P\d+)', 'DELETE', 'delete_redirect', $manage, $id_arg ); $this->route( '/redirects/export', 'GET', 'export_redirects', $manage ); $this->route( '/redirects/import', 'POST', 'import_redirects', $manage, array( 'csv' => array( 'type' => 'string', 'required' => true, ), ) ); $this->route( '/404s', 'GET', 'list_404s', $manage, array( 'search' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field', ), 'page' => array( 'type' => 'integer', 'default' => 1, 'minimum' => 1, ), ) ); $this->route( '/404s', 'DELETE', 'clear_not_found', $manage ); $this->route( '/404s/(?P\d+)', 'DELETE', 'delete_not_found', $manage, $id_arg ); $this->route( '/settings', 'GET', 'settings', $manage ); $this->route( '/settings', 'POST', 'update_settings', $manage, array( 'settings' => array( 'type' => 'object', 'required' => true, ), ) ); } /** * Register one route. * * @param string $path Path. * @param string $method HTTP method. * @param string $callback Method name on this class. * @param callable $permission Permission callback. * @param array $args Arguments schema. */ private function route( string $path, string $method, string $callback, callable $permission, array $args = array() ): void { register_rest_route( self::NAMESPACE, $path, array( 'methods' => $method, 'callback' => array( $this, $callback ), 'permission_callback' => $permission, 'args' => $args, ) ); } /** * Permission: plugin capability (filterable) + nonce. * * @param \WP_REST_Request $request Request. * @return true|\WP_Error */ public function can_use( \WP_REST_Request $request ) { if ( ! current_user_can( Settings::capability() ) ) { return new \WP_Error( 'rest_forbidden', __( 'You are not allowed to use Unbreak.', 'unbreak' ), array( 'status' => rest_authorization_required_code() ) ); } return $this->verify_nonce( $request ); } /** * Permission for settings: manage_options + nonce. * * @param \WP_REST_Request $request Request. * @return true|\WP_Error */ public function can_manage( \WP_REST_Request $request ) { if ( ! current_user_can( 'manage_options' ) ) { return new \WP_Error( 'rest_forbidden', __( 'This requires administrator access.', 'unbreak' ), array( 'status' => rest_authorization_required_code() ) ); } return $this->verify_nonce( $request ); } /** * Require the wp_rest nonce for cookie-authenticated requests. Application-password requests * carry no cookie and therefore no nonce. * * @param \WP_REST_Request $request Request. * @return true|\WP_Error */ private function verify_nonce( \WP_REST_Request $request ) { if ( function_exists( 'rest_get_authenticated_app_password' ) && rest_get_authenticated_app_password() ) { return true; } $nonce = $request->get_header( 'X-WP-Nonce' ); if ( ! is_string( $nonce ) || '' === $nonce ) { $nonce = (string) $request->get_param( '_wpnonce' ); } if ( '' === $nonce || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) { return new \WP_Error( 'unbreak_invalid_nonce', __( 'Your session has expired. Please reload the page.', 'unbreak' ), array( 'status' => 403 ) ); } return true; } /** * GET /summary. */ public function summary(): \WP_REST_Response { $summary = ( new LinkQuery() )->summary(); $summary['paused'] = CheckRunner::is_paused(); $summary['can_undo'] = '' !== ( new ChangeLog() )->last_batch(); $summary['rate'] = array( 'limit' => (int) Settings::get( 'rate_limit_per_hour' ), 'remaining' => ( new RateLimiter() )->remaining(), ); $summary['last_job_id'] = (string) get_option( 'unbreak_last_job', '' ); return rest_ensure_response( $summary ); } /** * POST /pause. * * @param \WP_REST_Request $request Request. */ public function pause( \WP_REST_Request $request ): \WP_REST_Response { CheckRunner::set_paused( (bool) $request->get_param( 'paused' ) ); return rest_ensure_response( array( 'paused' => CheckRunner::is_paused() ) ); } /** * POST /recheck-broken. */ public function recheck_broken(): \WP_REST_Response { $count = ( new LinkRepository() )->mark_due_by_status( BulkActions::recheckable_statuses() ); ( new CheckRunner() )->tick(); return rest_ensure_response( array( 'queued' => $count ) ); } /** * POST /scan: re-run full extraction. */ public function scan(): \WP_REST_Response { ( new FullScan() )->start(); return rest_ensure_response( FullScan::state() ); } /** * GET /links. * * @param \WP_REST_Request $request Request. */ public function links( \WP_REST_Request $request ): \WP_REST_Response { $result = ( new LinkQuery() )->query( $request->get_params() ); $response = rest_ensure_response( $result ); $response->header( 'X-WP-Total', (string) $result['total'] ); $response->header( 'X-WP-TotalPages', (string) $result['pages'] ); return $response; } /** * GET /links/{id}. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function link( \WP_REST_Request $request ) { $link = ( new LinkQuery() )->get( (int) $request->get_param( 'id' ) ); return null === $link ? self::missing() : rest_ensure_response( $link ); } /** * POST /links/{id}/recheck: check one link right now. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function recheck_link( \WP_REST_Request $request ) { $id = (int) $request->get_param( 'id' ); $row = ( new LinkRepository() )->find( $id ); if ( null === $row ) { return self::missing(); } $result = ( new CheckRunner() )->check_link( $row ); $link = ( new LinkQuery() )->get( $id ); if ( null === $link ) { return new \WP_Error( 'unbreak_link_removed', $result['reason'], array( 'status' => 410 ) ); } return rest_ensure_response( array( 'link' => $link, 'reason' => $result['reason'], ) ); } /** * POST /actions/preview (dry run). * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function preview( \WP_REST_Request $request ) { $result = ( new BulkActions() )->preview( (string) $request->get_param( 'action' ), (array) $request->get_param( 'link_ids' ), (string) $request->get_param( 'new_url' ) ); return is_wp_error( $result ) ? $result : rest_ensure_response( $result ); } /** * POST /actions/apply (confirm a preview). * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function apply( \WP_REST_Request $request ) { $result = ( new BulkActions() )->confirm( (string) $request->get_param( 'token' ) ); return is_wp_error( $result ) ? $result : rest_ensure_response( $result ); } /** * GET /jobs/{id}: progress (advances the job). * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function job( \WP_REST_Request $request ) { $job = ( new BulkActions() )->poll( (string) $request->get_param( 'id' ) ); return null === $job ? self::missing() : rest_ensure_response( $job ); } /** * POST /actions/undo. * * @return \WP_REST_Response|\WP_Error */ public function undo() { $result = ( new BulkActions() )->undo_last(); return is_wp_error( $result ) ? $result : rest_ensure_response( $result ); } /** * GET /changes. */ public function changes(): \WP_REST_Response { return rest_ensure_response( ( new ChangeLog() )->recent() ); } /** * GET /redirects. * * @param \WP_REST_Request $request Request. */ public function redirects( \WP_REST_Request $request ): \WP_REST_Response { return rest_ensure_response( ( new RedirectRepository() )->paginate( (string) $request->get_param( 'search' ), (int) $request->get_param( 'page' ) ) ); } /** * POST /redirects. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function create_redirect( \WP_REST_Request $request ) { $saved = ( new RedirectRepository() )->save( $request->get_params() ); return is_wp_error( $saved ) ? $saved : new \WP_REST_Response( $saved, 201 ); } /** * PUT /redirects/{id}. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function update_redirect( \WP_REST_Request $request ) { $repo = new RedirectRepository(); $id = (int) $request->get_param( 'id' ); $existing = $repo->find( $id ); if ( null === $existing ) { return self::missing(); } $input = array_merge( $existing, array_filter( $request->get_params(), static function ( $v ) { return null !== $v; } ) ); $saved = $repo->save( $input, $id ); return is_wp_error( $saved ) ? $saved : rest_ensure_response( $saved ); } /** * DELETE /redirects/{id}. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function delete_redirect( \WP_REST_Request $request ) { return ( new RedirectRepository() )->delete( (int) $request->get_param( 'id' ) ) ? rest_ensure_response( array( 'deleted' => true ) ) : self::missing(); } /** * GET /redirects/export. */ public function export_redirects(): \WP_REST_Response { return rest_ensure_response( array( 'csv' => Csv::export( new RedirectRepository() ) ) ); } /** * POST /redirects/import. * * @param \WP_REST_Request $request Request. */ public function import_redirects( \WP_REST_Request $request ): \WP_REST_Response { return rest_ensure_response( Csv::import( new RedirectRepository(), (string) $request->get_param( 'csv' ) ) ); } /** * GET /404s. * * @param \WP_REST_Request $request Request. */ public function list_404s( \WP_REST_Request $request ): \WP_REST_Response { $result = NotFoundLogger::paginate( (int) $request->get_param( 'page' ), 50, (string) $request->get_param( 'search' ) ); $result['enabled'] = (bool) Settings::get( 'log_404' ); return rest_ensure_response( $result ); } /** * DELETE /404s. */ public function clear_not_found(): \WP_REST_Response { NotFoundLogger::delete( 0 ); return rest_ensure_response( array( 'deleted' => true ) ); } /** * DELETE /404s/{id}. * * @param \WP_REST_Request $request Request. */ public function delete_not_found( \WP_REST_Request $request ): \WP_REST_Response { NotFoundLogger::delete( (int) $request->get_param( 'id' ) ); return rest_ensure_response( array( 'deleted' => true ) ); } /** * GET /settings. */ public function settings(): \WP_REST_Response { return rest_ensure_response( self::settings_payload() ); } /** * POST /settings. * * @param \WP_REST_Request $request Request. */ public function update_settings( \WP_REST_Request $request ): \WP_REST_Response { $before = Settings::all(); $input = (array) $request->get_param( 'settings' ); $after = Settings::update( $input ); // Re-scan when what is scanned changes; drop instances of sources that were switched off. if ( $before['sources'] !== $after['sources'] || $before['include_images'] !== $after['include_images'] ) { $indexer = new Indexer(); foreach ( $after['sources'] as $source => $enabled ) { if ( ! $enabled ) { $indexer->remove_source_type( (string) $source ); } } ( new FullScan() )->start(); } if ( $before['email_digest'] !== $after['email_digest'] ) { ( new Digest() )->sync_schedule(); } if ( $before['concurrency'] !== $after['concurrency'] ) { \Unbreak\Queue\Jobs::cancel( CheckRunner::HOOK_LANE ); ( new CheckRunner() )->tick(); } return rest_ensure_response( self::settings_payload() ); } /** * Settings plus read-only context for the UI. * * @return array */ private static function settings_payload(): array { return array( 'settings' => Settings::all(), 'defaults' => Settings::defaults(), 'allowlist' => \Unbreak\Check\Classifier::BUILTIN_ALLOWLIST, 'caps' => Settings::ALLOWED_CAPABILITIES, ); } /** * Light sanitizing for URL/path input: trim and strip control characters, keep percent-encoding. * * @param mixed $value Raw value. */ public static function sanitize_url_input( $value ): string { return trim( (string) preg_replace( '/[\x00-\x1F\x7F]/', '', wp_strip_all_tags( (string) $value ) ) ); } /** * 404 error. */ private static function missing(): \WP_Error { return new \WP_Error( 'unbreak_not_found', __( 'Not found.', 'unbreak' ), array( 'status' => 404 ) ); } }