Logging: Enable downloading log files singularly and in bulk (#41801)

Add functionality and UI for downloading log files directly from WC Admin.

Fixes #40645
This commit is contained in:
Corey McKrill 2023-12-01 13:49:46 -08:00 committed by GitHub
parent 0f3c4ea4bd
commit aaff5a0f43
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 252 additions and 14 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add functionality and UI for downloading log files directly from WC Admin

View File

@ -4115,7 +4115,7 @@ table.wc_shipping {
.wc-backbone-modal-main article {
padding: 0 32px 32px 32px;
}
.wc-backbone-modal-main header{
@ -4124,7 +4124,7 @@ table.wc_shipping {
.wc-backbone-modal-main footer {
padding: 20px 32px 12px 32px;
}
.wc-backbone-modal-main .wc-backbone-modal-header h1 {
@ -4168,7 +4168,7 @@ table.wc_shipping {
display: none;
}
}
}
.wc-shipping-method-add-class-costs {
@ -4186,7 +4186,7 @@ table.wc_shipping {
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
white-space: nowrap;
width: 1px;
&:checked + label {
@ -4237,7 +4237,7 @@ table.wc_shipping {
font-size: 24px;
}
}
.wc-shipping-zone-method-fields {
& > label {
@ -4258,7 +4258,7 @@ table.wc_shipping {
margin-bottom: 24px;
position: relative;
input,
input,
select {
margin: 6px 0;
padding: 12px;
@ -4362,7 +4362,7 @@ table {
.edit {
margin: 5px 0;
}
.edit > input,
.edit > select {
width: 100%;
@ -4390,7 +4390,7 @@ table {
}
}
.wc-shipping-class-hide-sibling-view + .view {
.wc-shipping-class-hide-sibling-view + .view {
display: none;
}
@ -4402,7 +4402,7 @@ table {
display: inline-block;
margin: 0 12px;
}
}
}
.wc-shipping-zone-heading-help-text {
max-width: 800px;

View File

@ -179,6 +179,15 @@ class File {
return fclose( $this->stream );
}
/**
* Get the full absolute path of the file.
*
* @return string
*/
public function get_path(): string {
return $this->path;
}
/**
* Get the name of the file, with extension, but without full path.
*

View File

@ -4,6 +4,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\Admin\Logging\FileV2;
use Automattic\Jetpack\Constants;
use PclZip;
use WC_Cache_Helper;
use WP_Error;
@ -345,6 +346,68 @@ class FileController {
return $deleted;
}
/**
* Stream a single file to the browser without zipping it first.
*
* @param string $file_id A file ID (file basename without the hash).
*
* @return WP_Error|void Only returns something if there is an error.
*/
public function export_single_file( $file_id ) {
$file = $this->get_file_by_id( $file_id );
if ( is_wp_error( $file ) ) {
return $file;
}
$file_name = $file->get_file_id() . '.log';
$exporter = new FileExporter( $file->get_path(), $file_name );
return $exporter->emit_file();
}
/**
* Create a zip archive of log files and stream it to the browser.
*
* @param array $file_ids An array of file IDs (file basename without the hash).
*
* @return WP_Error|void Only returns something if there is an error.
*/
public function export_multiple_files( array $file_ids ) {
$files = $this->get_files_by_id( $file_ids );
if ( count( $files ) < 1 ) {
return new WP_Error(
'wc_logs_invalid_file',
__( 'Could not access the specified files.', 'woocommerce' )
);
}
$temp_dir = get_temp_dir();
if ( ! is_dir( $temp_dir ) || ! wp_is_writable( $temp_dir ) ) {
return new WP_Error(
'wc_logs_invalid_directory',
__( 'Could not write to the temp directory. Try downloading files one at a time instead.', 'woocommerce' )
);
}
require_once ABSPATH . 'wp-admin/includes/class-pclzip.php';
$path = trailingslashit( $temp_dir ) . 'woocommerce_logs_' . gmdate( 'Y-m-d_H-i-s' ) . '.zip';
$file_paths = array_map(
fn( $file ) => $file->get_path(),
$files
);
$archive = new PclZip( $path );
$archive->create( $file_paths, PCLZIP_OPT_REMOVE_ALL_PATH );
$exporter = new FileExporter( $path );
return $exporter->emit_file();
}
/**
* Search within a set of log files for a particular string.
*

View File

@ -0,0 +1,136 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Admin\Logging\FileV2;
use WP_Error;
use WP_Filesystem_Direct;
/**
* FileExport class.
*/
class FileExporter {
/**
* The number of bytes per read while streaming the file.
*
* @const int
*/
private const CHUNK_SIZE = 4 * KB_IN_BYTES;
/**
* The absolute path of the file.
*
* @var string
*/
private $path;
/**
* A name of the file to send to the browser rather than the filename part of the path.
*
* @var string
*/
private $alternate_filename;
/**
* Class FileExporter.
*
* @param string $path The absolute path of the file.
* @param string $alternate_filename Optional. The name of the file to send to the browser rather than the filename
* part of the path.
*/
public function __construct( string $path, string $alternate_filename = '' ) {
global $wp_filesystem;
if ( ! $wp_filesystem instanceof WP_Filesystem_Direct ) {
WP_Filesystem();
}
$this->path = $path;
$this->alternate_filename = $alternate_filename;
}
/**
* Configure PHP and stream the file to the browser.
*
* @return WP_Error|void Only returns something if there is an error.
*/
public function emit_file() {
global $wp_filesystem;
if ( ! $wp_filesystem->is_file( $this->path ) || ! $wp_filesystem->is_readable( $this->path ) ) {
return new WP_Error(
'wc_logs_invalid_file',
__( 'Could not access file.', 'woocommerce' )
);
}
// These configuration tweaks are copied from WC_CSV_Exporter::send_headers().
// phpcs:disable WordPress.PHP.NoSilencedErrors.Discouraged
if ( function_exists( 'gc_enable' ) ) {
gc_enable(); // phpcs:ignore PHPCompatibility.FunctionUse.NewFunctions.gc_enableFound
}
if ( function_exists( 'apache_setenv' ) ) {
@apache_setenv( 'no-gzip', '1' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_apache_setenv
}
@ini_set( 'zlib.output_compression', 'Off' ); // phpcs:ignore WordPress.PHP.IniSet.Risky
@ini_set( 'output_buffering', 'Off' ); // phpcs:ignore WordPress.PHP.IniSet.Risky
@ini_set( 'output_handler', '' ); // phpcs:ignore WordPress.PHP.IniSet.Risky
ignore_user_abort( true );
wc_set_time_limit();
wc_nocache_headers();
// phpcs:enable WordPress.PHP.NoSilencedErrors.Discouraged
$this->send_headers();
$this->send_contents();
die;
}
/**
* Send HTTP headers at the beginning of a file.
*
* Modeled on WC_CSV_Exporter::send_headers().
*
* @return void
*/
private function send_headers(): void {
header( 'Content-Type: text/plain; charset=utf-8' );
header( 'Content-Disposition: attachment; filename=' . $this->get_filename() );
header( 'Pragma: no-cache' );
header( 'Expires: 0' );
}
/**
* Send the contents of the file.
*
* @return void
*/
private function send_contents(): void {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fopen -- No suitable alternative.
$stream = fopen( $this->path, 'rb' );
while ( is_resource( $stream ) && ! feof( $stream ) ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fread -- No suitable alternative.
$chunk = fread( $stream, self::CHUNK_SIZE );
if ( is_string( $chunk ) ) {
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Outputting to file.
echo $chunk;
}
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fclose -- No suitable alternative.
fclose( $stream );
}
/**
* Get the name of the file that will be sent to the browser.
*
* @return string
*/
private function get_filename(): string {
if ( $this->alternate_filename ) {
return $this->alternate_filename;
}
return basename( $this->path );
}
}

View File

@ -67,6 +67,7 @@ class FileListTable extends WP_List_Table {
*/
protected function get_bulk_actions(): array {
return array(
'export' => esc_html__( 'Download', 'woocommerce' ),
'delete' => esc_html__( 'Delete permanently', 'woocommerce' ),
);
}

View File

@ -196,22 +196,28 @@ class PageController {
$rotations = $this->file_controller->get_file_rotations( $file->get_file_id() );
$rotation_url_base = add_query_arg( 'view', 'single_file', $this->get_logs_tab_url() );
$delete_url = add_query_arg(
$download_url = add_query_arg(
array(
'action' => 'export',
'file_id' => array( $file->get_file_id() ),
),
wp_nonce_url( $this->get_logs_tab_url(), 'bulk-log-files' )
);
$delete_url = add_query_arg(
array(
'action' => 'delete',
'file_id' => array( $file->get_file_id() ),
),
wp_nonce_url( $this->get_logs_tab_url(), 'bulk-log-files' )
);
$stream = $file->get_stream();
$line_number = 1;
$delete_confirmation_js = sprintf(
"return window.confirm( '%s' )",
esc_js( __( 'Delete this log file permanently?', 'woocommerce' ) )
);
$stream = $file->get_stream();
$line_number = 1;
?>
<header id="logs-header" class="wc-logs-header">
<h2>
@ -255,6 +261,14 @@ class PageController {
</nav>
<?php endif; ?>
<div class="wc-logs-single-file-actions">
<?php
// Download button.
printf(
'<a href="%1$s" class="button button-secondary">%2$s</a>',
esc_url( $download_url ),
esc_html__( 'Download', 'woocommerce' )
);
?>
<?php
// Delete button.
printf(
@ -464,6 +478,17 @@ class PageController {
}
switch ( $action ) {
case 'export':
if ( 1 === count( $file_ids ) ) {
$export_error = $this->file_controller->export_single_file( reset( $file_ids ) );
} else {
$export_error = $this->file_controller->export_multiple_files( $file_ids );
}
if ( is_wp_error( $export_error ) ) {
wp_die( wp_kses_post( $export_error ) );
}
break;
case 'delete':
$deleted = $this->file_controller->delete_files( $file_ids );
$sendback = add_query_arg( 'deleted', $deleted, $sendback );