t = array( 'urls' => Schema::table( 'forensix_scan_urls' ), 'targets' => Schema::table( 'forensix_scan_targets' ), ); } /** * Adds pages. A page added twice keeps its highest importance. * * @param int $scan_id Scan id. * @param array $rows Pages. * @phpstan-param array $rows */ public function add( int $scan_id, array $rows ): void { global $wpdb; foreach ( array_chunk( $rows, self::BATCH ) as $chunk ) { $values = array(); $params = array( $this->t['urls'] ); foreach ( $chunk as $row ) { $values[] = '(%d, %s, %s, %s, %d, %s, %s)'; array_push( $params, $scan_id, $row['url'], sha1( $row['url'] ), substr( $row['origin'], 0, 64 ), max( 1, min( 255, $row['importance'] ) ), $row['status'] ?? self::PENDING, substr( $row['reason'] ?? '', 0, 64 ) ); } // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Placeholders are built above, one group per row. $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (scan_id, url, url_hash, origin, importance, status, reason) VALUES ' . implode( ', ', $values ) . ' ON DUPLICATE KEY UPDATE importance = GREATEST(importance, VALUES(importance))', $params ) ); // phpcs:enable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared } } /** * Number of pages in the inventory. * * @param int $scan_id Scan id. */ public function count( int $scan_id ): int { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE scan_id = %d', $this->t['urls'], $scan_id ) ); } /** * Next page still to visit, in the order they were added. * * @param int $scan_id Scan id. * @return array{id: int, url: string, importance: int}|null */ public function next( int $scan_id ): ?array { global $wpdb; $row = $wpdb->get_row( $wpdb->prepare( 'SELECT id, url, importance FROM %i WHERE scan_id = %d AND status = %s ORDER BY id LIMIT 1', $this->t['urls'], $scan_id, self::PENDING ), ARRAY_A ); if ( ! is_array( $row ) ) { return null; } return array( 'id' => (int) $row['id'], 'url' => (string) $row['url'], 'importance' => (int) $row['importance'], ); } /** * Records the outcome of visiting a page. * * @param int $id Row id. * @param string $status SCANNED, SKIPPED or FAILED. * @param string $reason Why it was skipped or failed. * @param int $http_status HTTP status, 0 when none. */ public function mark( int $id, string $status, string $reason = '', int $http_status = 0 ): void { global $wpdb; $wpdb->update( $this->t['urls'], array( 'status' => $status, 'reason' => substr( $reason, 0, 64 ), 'http_status' => max( 0, min( 65535, $http_status ) ), ), array( 'id' => $id ), array( '%s', '%s', '%d' ), array( '%d' ) ); } /** * Coverage totals: discovered, scanned, skipped, failed, pending, and skip/fail reasons. * * @param int $scan_id Scan id. * @return array{discovered: int, scanned: int, skipped: int, failed: int, pending: int, reasons: array} */ public function coverage( int $scan_id ): array { global $wpdb; $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT status, reason, COUNT(*) AS n FROM %i WHERE scan_id = %d GROUP BY status, reason', $this->t['urls'], $scan_id ), ARRAY_A ); $counts = array( self::SCANNED => 0, self::SKIPPED => 0, self::FAILED => 0, self::PENDING => 0, ); $reasons = array(); $total = 0; foreach ( (array) $rows as $row ) { $n = (int) $row['n']; $status = (string) $row['status']; $reason = (string) $row['reason']; $total += $n; if ( isset( $counts[ $status ] ) ) { $counts[ $status ] += $n; } if ( '' !== $reason ) { $reasons[ $reason ] = ( $reasons[ $reason ] ?? 0 ) + $n; } } return array( 'discovered' => $total, 'scanned' => $counts[ self::SCANNED ], 'skipped' => $counts[ self::SKIPPED ], 'failed' => $counts[ self::FAILED ], 'pending' => $counts[ self::PENDING ], 'reasons' => $reasons, ); } /** * Pages of the inventory with one status. * * @param int $scan_id Scan id. * @param string $status Status, or '' for all. * @param int $page Page number, from 1. * @param int $per_page Rows per page. * @return array{items: array, total: int} */ public function urls( int $scan_id, string $status = '', int $page = 1, int $per_page = 50 ): array { global $wpdb; $per_page = max( 1, min( 200, $per_page ) ); $offset = ( max( 1, $page ) - 1 ) * $per_page; if ( '' === $status ) { $total = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE scan_id = %d', $this->t['urls'], $scan_id ) ); $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT url, status, reason, http_status, origin FROM %i WHERE scan_id = %d ORDER BY id LIMIT %d OFFSET %d', $this->t['urls'], $scan_id, $per_page, $offset ), ARRAY_A ); } else { $total = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE scan_id = %d AND status = %s', $this->t['urls'], $scan_id, $status ) ); $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT url, status, reason, http_status, origin FROM %i WHERE scan_id = %d AND status = %s ORDER BY id LIMIT %d OFFSET %d', $this->t['urls'], $scan_id, $status, $per_page, $offset ), ARRAY_A ); } $items = array(); foreach ( (array) $rows as $row ) { $items[] = array( 'url' => (string) $row['url'], 'status' => (string) $row['status'], 'reason' => (string) $row['reason'], 'http_status' => (int) $row['http_status'], 'origin' => (string) $row['origin'], ); } return array( 'items' => $items, 'total' => $total, ); } /** * A checked target of this scan. * * @param int $scan_id Scan id. * @param string $url Target URL. */ public function target( int $scan_id, string $url ): ?TargetResult { global $wpdb; $row = $wpdb->get_row( $wpdb->prepare( 'SELECT verdict, http_status, final_url, hops, error FROM %i WHERE scan_id = %d AND url_hash = %s', $this->t['targets'], $scan_id, sha1( $url ) ), ARRAY_A ); if ( ! is_array( $row ) ) { return null; } return new TargetResult( (string) $row['verdict'], (int) $row['http_status'], (string) $row['final_url'], (int) $row['hops'], (string) $row['error'] ); } /** * Stores a checked target. * * @param int $scan_id Scan id. * @param string $url Target URL. * @param TargetResult $result Result. */ public function save_target( int $scan_id, string $url, TargetResult $result ): void { global $wpdb; $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (scan_id, url, url_hash, verdict, http_status, final_url, hops, error) VALUES (%d, %s, %s, %s, %d, %s, %d, %s) ON DUPLICATE KEY UPDATE verdict = VALUES(verdict), http_status = VALUES(http_status), final_url = VALUES(final_url), hops = VALUES(hops), error = VALUES(error)', $this->t['targets'], $scan_id, $url, sha1( $url ), $result->verdict, max( 0, min( 65535, $result->status ) ), $result->final_url, max( 0, min( 255, $result->hops ) ), substr( $result->error, 0, 191 ) ) ); } /** * Removes the rows of every scan before this one. * * @param int $scan_id Scan id to keep, and newer. */ public function purge_before( int $scan_id ): void { global $wpdb; $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE scan_id < %d', $this->t['urls'], $scan_id ) ); $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE scan_id < %d', $this->t['targets'], $scan_id ) ); } /** * Removes all rows. Used when the Site Scan data is deleted. */ public function delete_all(): void { global $wpdb; $wpdb->query( $wpdb->prepare( 'DELETE FROM %i', $this->t['urls'] ) ); $wpdb->query( $wpdb->prepare( 'DELETE FROM %i', $this->t['targets'] ) ); } }