container = $container; } /** * Hooks route registration. */ public function register_hooks(): void { add_action( 'rest_api_init', array( $this, 'register_routes' ) ); } /** * Registers the routes. */ public function register_routes(): void { $id = array( 'id' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_key', ), ); $this->route( '/overview', 'GET', 'overview', Capabilities::VIEW_FINDINGS ); $this->route( '/apps', 'GET', 'apps', Capabilities::MANAGE_APPS ); $this->route( '/apps/(?P[a-z0-9_-]+)/impact', 'GET', 'impact', Capabilities::MANAGE_APPS, $id ); $this->route( '/apps/(?P[a-z0-9_-]+)/activate', 'POST', 'activate', Capabilities::MANAGE_APPS, $id ); $this->route( '/apps/(?P[a-z0-9_-]+)/deactivate', 'POST', 'deactivate', Capabilities::MANAGE_APPS, $id + array( 'cascade' => array( 'type' => 'boolean', 'default' => false, ), ) ); $this->route( '/apps/(?P[a-z0-9_-]+)/delete-data', 'POST', 'delete_data', Capabilities::MANAGE_APPS, $id ); $paging = array( 'page' => array( 'type' => 'integer', 'default' => 1, 'minimum' => 1, ), 'per_page' => array( 'type' => 'integer', 'default' => 20, 'minimum' => 1, 'maximum' => 100, ), ); $text = array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_key', ); $this->route( '/findings', 'GET', 'findings', Capabilities::VIEW_FINDINGS, $paging + array( 'app' => $text, 'severity' => $text, 'type' => $text, 'search' => array( 'type' => 'string', 'default' => '', 'maxLength' => 100, 'sanitize_callback' => 'sanitize_text_field', ), 'status' => array( 'type' => 'string', 'default' => FindingStore::OPEN, 'enum' => array( FindingStore::OPEN, FindingStore::IGNORED, FindingStore::RESOLVED, FindingStore::FIXED, 'all' ), ), ) ); $finding_id = array( 'id' => array( 'type' => 'integer', 'required' => true, 'minimum' => 1, ), ); $this->route( '/findings/(?P\d+)/occurrences', 'GET', 'occurrences', Capabilities::VIEW_FINDINGS, $finding_id + $paging ); $this->route( '/findings/(?P\d+)/status', 'POST', 'finding_status', Capabilities::VIEW_FINDINGS, $finding_id + array( 'status' => array( 'type' => 'string', 'required' => true, 'enum' => array( FindingStore::OPEN, FindingStore::IGNORED ), ), 'note' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_textarea_field', ), ) ); $this->route( '/activity', 'GET', 'activity', Capabilities::VIEW_ACTIVITY, array( 'app' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_key', ), 'page' => array( 'type' => 'integer', 'default' => 1, 'minimum' => 1, ), 'per_page' => array( 'type' => 'integer', 'default' => 20, 'minimum' => 1, 'maximum' => 100, ), ) ); $this->route( '/activity/(?P\d+)/undo', 'POST', 'undo', Capabilities::UNDO_CHANGES, array( 'id' => array( 'type' => 'integer', 'required' => true, 'minimum' => 1, ), ) ); $this->route( '/settings', 'GET', 'get_settings', Capabilities::MANAGE_SETTINGS ); $this->route( '/settings', 'POST', 'save_settings', Capabilities::MANAGE_SETTINGS, array( 'values' => array( 'type' => 'object', 'required' => true, ), ) ); } /** * GET /overview. */ public function overview(): \WP_REST_Response { $registry = $this->registry(); $edition = $this->container->get( Entitlements::class )->edition(); $store = $this->container->get( FindingStore::class ); $counts = $store->counts(); $recent = array(); if ( current_user_can( Capabilities::VIEW_ACTIVITY ) ) { $recent = array_map( array( $this, 'present_record' ), $this->container->get( ActivityLog::class )->recent( array(), 5 ) ); } return new \WP_REST_Response( array( 'edition' => $edition, 'edition_label' => Edition::label( $edition ), 'apps_active' => count( $registry->active_ids() ), 'apps_total' => count( $registry->definitions() ), 'findings_total' => $counts[ FindingStore::OPEN ], 'by_severity' => $store->open_by_severity(), 'last_scan' => $store->latest_scan( 'scan' ), 'scan_active' => $registry->is_active( 'scan' ), 'recent' => $recent, 'notices' => array_values( $this->container->get( Notifier::class )->all() ), 'warnings' => $this->warnings( $registry ), ) ); } /** * Problems that need attention whether or not a scan has run. They come straight from site * settings, so they are always current. * * @param AppRegistry $registry App registry. * @return array */ private function warnings( AppRegistry $registry ): array { $out = array(); if ( $registry->is_active( SeoApp::ID ) && SearchVisibilityCheck::discouraged() ) { $out[] = array( 'code' => 'search_engines_discouraged', 'message' => __( 'Your site asks search engines not to index it, so it will not appear in search results.', 'forensix-digital' ), 'url' => admin_url( 'options-reading.php' ), 'link' => __( 'Open Reading settings', 'forensix-digital' ), ); } if ( $registry->is_active( FormsApp::ID ) ) { $failed = ( new EntryStore() )->failed_mail(); if ( $failed > 0 ) { $out[] = array( 'code' => 'form_mail_failed', /* translators: %d: number of form messages. */ 'message' => sprintf( _n( '%d form message could not be emailed to you. It is safe in the inbox.', '%d form messages could not be emailed to you. They are safe in the inbox.', $failed, 'forensix-digital' ), $failed ), 'url' => admin_url( 'admin.php?page=forensix-forms' ), 'link' => __( 'Open the inbox', 'forensix-digital' ), ); if ( ! $registry->is_active( MailApp::ID ) ) { $out[ count( $out ) - 1 ]['message'] .= ' ' . __( 'Turn on Email delivery in Apps to send your site\'s email through your own mail account.', 'forensix-digital' ); } } } return $out; } /** * GET /apps. */ public function apps(): \WP_REST_Response { $registry = $this->registry(); $have = $this->container->get( Entitlements::class )->edition(); $failed = $registry->failed(); $items = array(); $open = $this->container->get( FindingStore::class )->areas(); foreach ( $registry->definitions() as $definition ) { $item = $this->present_app( $definition, $registry, $have, $failed ); // Open findings of this app, so the Apps screen can link to the filtered Findings list. $item['findings'] = $open[ $definition->id ] ?? 0; $item['findings_url'] = admin_url( 'admin.php?page=forensix-findings&app=' . rawurlencode( $definition->id ) ); $items[] = $item; } // Paid apps whose add-on is not installed: listed greyed out with an upgrade link. foreach ( PaidCatalogue::apps() as $paid ) { if ( null !== $registry->get( $paid['id'] ) ) { continue; } $needs = '' !== $paid['needs'] ? $registry->get( $paid['needs'] ) : null; $items[] = array( 'id' => $paid['id'], 'name' => $paid['name'], 'description' => $paid['description'], 'category' => $paid['category'], 'edition' => $paid['edition'], 'edition_label' => Edition::label( $paid['edition'] ), 'available' => false, 'preview' => true, 'status' => 'inactive', 'dependencies' => null !== $needs ? array( array( 'id' => $needs->id, 'name' => $needs->name, 'active' => $registry->is_active( $needs->id ), ), ) : array(), ); } return new \WP_REST_Response( array( 'items' => $items ) ); } /** * GET /apps/{id}/impact: what deactivating would affect. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function impact( \WP_REST_Request $request ) { $registry = $this->registry(); $id = (string) $request['id']; if ( null === $registry->get( $id ) ) { return $this->unknown_app(); } return new \WP_REST_Response( $registry->deactivation_impact( $id ) ); } /** * POST /apps/{id}/activate. * * @param \WP_REST_Request $request Request. */ public function activate( \WP_REST_Request $request ): \WP_REST_Response { return $this->respond( $this->registry()->activate( (string) $request['id'] ) ); } /** * POST /apps/{id}/deactivate. * * @param \WP_REST_Request $request Request. */ public function deactivate( \WP_REST_Request $request ): \WP_REST_Response { return $this->respond( $this->registry()->deactivate( (string) $request['id'], (bool) $request['cascade'] ) ); } /** * POST /apps/{id}/delete-data. * * @param \WP_REST_Request $request Request. */ public function delete_data( \WP_REST_Request $request ): \WP_REST_Response { return $this->respond( $this->registry()->delete_data( (string) $request['id'] ) ); } /** * GET /findings, by priority. * * @param \WP_REST_Request $request Request. */ public function findings( \WP_REST_Request $request ): \WP_REST_Response { $status = (string) $request['status']; // The "app" of the screen is the app a check belongs to; the Site Scan stores the findings // of several apps as its own, so this looks at the check id (see FindingStore::AREA_SQL). $filters = array( 'area' => (string) $request['app'], 'severity' => (string) $request['severity'], 'type' => (string) $request['type'], 'search' => (string) $request['search'], 'status' => 'all' === $status ? '' : $status, ); $store = $this->container->get( FindingStore::class ); $result = $store->query( $filters, (int) $request['page'], (int) $request['per_page'] ); // The app filter lists every app with findings, so its choices do not change with the other filters. $registry = $this->registry(); $apps = array(); foreach ( $store->areas( '' ) as $id => $count ) { $definition = $registry->get( $id ); $apps[] = array( 'id' => $id, 'name' => null !== $definition ? $definition->name : $id, 'count' => $count, ); } usort( $apps, static fn( array $a, array $b ): int => strcasecmp( $a['name'], $b['name'] ) ); $result['items'] = array_map( array( Explanations::class, 'present' ), $result['items'] ); return new \WP_REST_Response( $result + array( 'counts' => $store->counts( '', $filters['area'] ), 'apps' => $apps, ) ); } /** * GET /findings/{id}/occurrences. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function occurrences( \WP_REST_Request $request ) { $store = $this->container->get( FindingStore::class ); $id = (int) $request['id']; if ( null === $store->find( $id ) ) { return $this->unknown_finding(); } return new \WP_REST_Response( $store->occurrences( $id, (int) $request['page'], (int) $request['per_page'] ) ); } /** * POST /findings/{id}/status: ignore a finding or open it again. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function finding_status( \WP_REST_Request $request ) { $store = $this->container->get( FindingStore::class ); $id = (int) $request['id']; if ( ! $store->set_status( $id, (string) $request['status'], (string) $request['note'] ) ) { return $this->unknown_finding(); } return new \WP_REST_Response( $store->find( $id ) ); } /** * GET /activity. * * @param \WP_REST_Request $request Request. */ public function activity( \WP_REST_Request $request ): \WP_REST_Response { $log = $this->container->get( ActivityLog::class ); $per_page = (int) $request['per_page']; $page = (int) $request['page']; $filters = '' !== (string) $request['app'] ? array( 'app' => (string) $request['app'] ) : array(); // One extra row tells whether another page exists, without a separate count query. $records = $log->recent( $filters, $per_page + 1, ( $page - 1 ) * $per_page ); $has_more = count( $records ) > $per_page; $records = array_slice( $records, 0, $per_page ); return new \WP_REST_Response( array( 'items' => array_map( array( $this, 'present_record' ), $records ), 'has_more' => $has_more, ) ); } /** * POST /activity/{id}/undo. * * @param \WP_REST_Request $request Request. */ public function undo( \WP_REST_Request $request ): \WP_REST_Response { return $this->respond( $this->container->get( ActivityLog::class )->undo( (int) $request['id'] ) ); } /** * GET /settings. */ public function get_settings(): \WP_REST_Response { return new \WP_REST_Response( array( 'values' => $this->container->get( Settings::class )->all( 'core' ) ) ); } /** * POST /settings. Only keys of the core scope that have a default are saved. * * @param \WP_REST_Request $request Request. */ public function save_settings( \WP_REST_Request $request ): \WP_REST_Response { $settings = $this->container->get( Settings::class ); $known = $settings->all( 'core' ); $values = array_intersect_key( (array) $request['values'], $known ); return new \WP_REST_Response( array( 'values' => $settings->update( 'core', $values ) ) ); } /** * Registers one route with a capability check. * * @param string $path Route pattern. * @param string $method HTTP method. * @param string $handler Method name on this class. * @param string $capability Capability needed. * @param array $args Argument definitions. */ private function route( string $path, string $method, string $handler, string $capability, array $args = array() ): void { register_rest_route( self::NAMESPACE, $path, array( 'methods' => $method, 'callback' => array( $this, $handler ), 'permission_callback' => static fn(): bool => current_user_can( $capability ), 'args' => $args, ) ); } /** * The app registry. */ private function registry(): AppRegistry { return $this->container->get( AppRegistry::class ); } /** * Turns a Result into a response: 200 on success, 409 for a conflict, 400 otherwise. * * @param Result $result Outcome. */ private function respond( Result $result ): \WP_REST_Response { $status = 200; if ( ! $result->ok ) { $status = 'conflict' === $result->code ? 409 : 400; } return new \WP_REST_Response( array( 'ok' => $result->ok, 'code' => $result->code, 'message' => $result->message, 'data' => $result->data, ), $status ); } /** * Error for an unknown finding id. */ private function unknown_finding(): \WP_Error { return new \WP_Error( 'unknown_finding', __( 'This finding no longer exists. It may have been removed with its app data.', 'forensix-digital' ), array( 'status' => 404 ) ); } /** * Error for an unknown app id. */ private function unknown_app(): \WP_Error { return new \WP_Error( 'unknown_app', __( 'This app is not available.', 'forensix-digital' ), array( 'status' => 404 ) ); } /** * One app for the Apps screen. * * @param AppDefinition $definition App definition. * @param AppRegistry $registry Registry. * @param string $have Edition of the site. * @param array $failed Apps that failed to start. * @return array */ private function present_app( AppDefinition $definition, AppRegistry $registry, string $have, array $failed ): array { $status = 'inactive'; if ( $registry->is_active( $definition->id ) ) { $status = isset( $failed[ $definition->id ] ) ? 'failed' : 'active'; } $dependencies = array(); foreach ( $definition->dependencies as $dependency ) { $other = $registry->get( $dependency ); $dependencies[] = array( 'id' => $dependency, 'name' => null !== $other ? $other->name : $dependency, 'active' => $registry->is_active( $dependency ), ); } return array( 'id' => $definition->id, 'name' => $definition->name, 'description' => $definition->description, 'category' => $definition->category, 'edition' => $definition->edition, 'edition_label' => Edition::label( $definition->edition ), 'available' => Edition::covers( $have, $definition->edition ), 'preview' => false, 'status' => $status, 'dependencies' => $dependencies, ); } /** * One activity entry for the screens. * * @param ActivityRecord $record Record. * @return array */ private function present_record( ActivityRecord $record ): array { $user = $record->user_id > 0 ? get_userdata( $record->user_id ) : false; return array( 'id' => $record->id, 'app' => $record->app, 'action' => $record->action, 'summary' => $record->summary, 'status' => $record->status, 'user' => false !== $user ? $user->display_name : '', 'created_at' => $record->created_at, 'undoable' => $this->container->get( ActivityLog::class )->is_undoable( $record ), ); } }