WP_REST_Server::READABLE, 'callback' => array( __CLASS__, 'list_events' ), 'permission_callback' => array( __CLASS__, 'permission' ), 'args' => self::args_schema(), ), 'schema' => array( __CLASS__, 'item_schema' ), ) ); // Registered on every multisite request, whichever site it is on: // whether it ANSWERS is network_permission()'s decision, made per // request, so the route cannot be reached by a path the check does // not cover. if ( is_multisite() ) { register_rest_route( self::NAMESPACE, self::NETWORK_ROUTE, array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( __CLASS__, 'list_network_events' ), 'permission_callback' => array( __CLASS__, 'network_permission' ), 'args' => self::args_schema( true ), ), 'schema' => array( __CLASS__, 'network_item_schema' ), ) ); } } /** * Administrators only: the line the screen and the export already draw. * * @return true|WP_Error */ public static function permission() { if ( current_user_can( 'manage_options' ) ) { return true; } return new WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to read the activity log.', '404ops-activity-log' ), array( 'status' => rest_authorization_required_code() ) ); } /** * Super admins only, and only where the network log exists: the line the * Network Admin screen and its export draw (`manage_network`). The * capability is checked first, so a site administrator learns nothing -- * not even whether the network log is in use. * * @return true|WP_Error */ public static function network_permission() { if ( ! current_user_can( 'manage_network' ) ) { return new WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to read the network activity log.', '404ops-activity-log' ), array( 'status' => rest_authorization_required_code() ) ); } if ( ! NetworkLog::is_active() ) { return new WP_Error( 'ops_activity_log_no_network_log', __( 'The network activity log exists only while the plugin is network-activated.', '404ops-activity-log' ), array( 'status' => 404 ) ); } return true; } /** * One page of events, newest first, with the total in the headers the * way core's own collections report it. * * @param WP_REST_Request> $request The request. * @return WP_REST_Response|WP_Error */ public static function list_events( WP_REST_Request $request ) { return self::respond( $request, false ); } /** * One page of the network log. Reached only past network_permission(). * * @param WP_REST_Request> $request The request. * @return WP_REST_Response|WP_Error */ public static function list_network_events( WP_REST_Request $request ) { return self::respond( $request, true ); } /** * @param WP_REST_Request> $request The request. * @param bool $network Which log; the route decided, never the request. * @return WP_REST_Response|WP_Error */ private static function respond( WP_REST_Request $request, bool $network ) { try { $args = EventQuery::args( $request->get_params(), $network ); } catch ( InvalidArgumentException $e ) { return new WP_Error( 'ops_activity_log_invalid_filter', $e->getMessage(), array( 'status' => 400 ) ); } $page = EventQuery::fetch( $args, $network ); $response = new WP_REST_Response( $page['events'] ); $response->header( 'X-WP-Total', (string) $page['total'] ); $response->header( 'X-WP-TotalPages', (string) $page['pages'] ); return $response; } /** * The parameters, for core to validate and to describe on OPTIONS. The * bounds are EventQuery's, so the two surfaces cannot drift apart. * * @param bool $network Whether this is the network route, which also takes `site`. * @return array> */ private static function args_schema( bool $network = false ): array { $args = array( 'user_id' => array( 'description' => __( 'Only events by this user account.', '404ops-activity-log' ), 'type' => 'integer', 'minimum' => 1, ), 'user_email' => array( 'description' => __( 'Only events logged under this email address.', '404ops-activity-log' ), 'type' => 'string', ), 'event_type' => array( 'description' => __( 'Only this event type, for example login_failed or plugin_activated.', '404ops-activity-log' ), 'type' => 'string', ), 'object_type' => array( 'description' => __( 'Only events about this kind of object, for example post, user or plugin.', '404ops-activity-log' ), 'type' => 'string', ), 'object_id' => array( 'description' => __( 'Only events about the object with this ID.', '404ops-activity-log' ), 'type' => 'integer', 'minimum' => 1, ), 'date_from' => array( 'description' => __( 'Events from this moment on: a date (YYYY-MM-DD) or a phrase such as "-7 days".', '404ops-activity-log' ), 'type' => 'string', ), 'date_to' => array( 'description' => __( 'Events up to the end of this day: a date (YYYY-MM-DD) or a phrase such as "yesterday".', '404ops-activity-log' ), 'type' => 'string', ), 'search' => array( 'description' => __( 'Matches the summary, email address, IP address and event details.', '404ops-activity-log' ), 'type' => 'string', ), 'country' => array( 'description' => __( 'Only events from this country: a two-letter ISO code such as NZ.', '404ops-activity-log' ), 'type' => 'string', ), 'device_class' => array( 'description' => __( 'Only events made from this kind of device: desktop, mobile, tablet or bot.', '404ops-activity-log' ), 'type' => 'string', ), 'marker' => array( 'description' => __( 'Only logins carrying this marker: new_country, new_device, impossible_travel, dormant_days or anonymous_network.', '404ops-activity-log' ), 'type' => 'string', ), 'per_page' => array( 'description' => __( 'Events per page.', '404ops-activity-log' ), 'type' => 'integer', 'default' => EventQuery::DEFAULT_PER_PAGE, 'minimum' => 1, 'maximum' => EventQuery::MAX_PER_PAGE, ), 'page' => array( 'description' => __( 'Which page of events.', '404ops-activity-log' ), 'type' => 'integer', 'default' => 1, 'minimum' => 1, ), ); if ( $network ) { $args = array( 'site' => array( 'description' => __( 'Only events of this site, by its ID; 0 for the network\'s own events.', '404ops-activity-log' ), 'type' => 'integer', 'minimum' => 0, ), ) + $args; } return $args; } /** * What one event looks like, for OPTIONS. * * @return array */ public static function item_schema(): array { return array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'activity-log-event', 'type' => 'object', 'properties' => array( 'id' => array( 'type' => 'integer', 'readonly' => true, ), 'timestamp' => array( 'description' => __( 'When the event was recorded, as the database wrote it: YYYY-MM-DD HH:MM:SS.', '404ops-activity-log' ), 'type' => 'string', ), 'user_id' => array( 'description' => __( 'The acting account, or null for a visitor or an unattributed action such as WP-CLI or cron.', '404ops-activity-log' ), 'type' => array( 'integer', 'null' ), ), 'user_email' => array( 'type' => array( 'string', 'null' ), ), 'ip_address' => array( 'description' => __( 'Truncated unless the plugin is set to store full IP addresses.', '404ops-activity-log' ), 'type' => array( 'string', 'null' ), ), 'event_type' => array( 'type' => 'string', ), 'object_type' => array( 'type' => array( 'string', 'null' ), ), 'object_id' => array( 'type' => array( 'integer', 'null' ), ), 'summary' => array( 'type' => 'string', ), 'details' => array( 'description' => __( 'What the event recorded about itself, when it recorded anything.', '404ops-activity-log' ), 'type' => array( 'object', 'null' ), ), 'country' => array( 'description' => __( 'Where the request came from: a two-letter ISO code, looked up before the address was truncated. Null when unknown.', '404ops-activity-log' ), 'type' => array( 'string', 'null' ), ), 'city' => array( 'description' => __( 'The city, when the site uses a City database. Null when unknown.', '404ops-activity-log' ), 'type' => array( 'string', 'null' ), ), 'device_class' => array( 'description' => __( 'desktop, mobile, tablet or bot. Null when unknown.', '404ops-activity-log' ), 'type' => array( 'string', 'null' ), ), 'browser' => array( 'type' => array( 'string', 'null' ), ), 'os' => array( 'type' => array( 'string', 'null' ), ), 'markers' => array( 'description' => __( 'A login\'s markers: new_country, new_device, impossible_travel and anonymous_network are true when present; dormant_days is the days since the previous login. Empty for every other event.', '404ops-activity-log' ), 'type' => 'object', ), ), ); } /** * What one network event looks like, for OPTIONS: a site event plus the * site it belongs to. * * @return array */ public static function network_item_schema(): array { $schema = self::item_schema(); $schema['title'] = 'activity-log-network-event'; $schema['properties']['site_id'] = array( 'description' => __( 'The site the event belongs to, or 0 for the network itself.', '404ops-activity-log' ), 'type' => 'integer', ); $schema['properties']['site'] = array( 'description' => __( 'The site\'s domain and path, "Network", or "Site N (deleted)".', '404ops-activity-log' ), 'type' => 'string', ); return $schema; } }