Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 92 additions & 39 deletions src/class-convertkit-log.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class ConvertKit_Log {
*
* @var string
*/
private $path;
private $path = '';

/**
* The path and filename of the log file.
Expand All @@ -29,7 +29,7 @@ class ConvertKit_Log {
*
* @var string
*/
private $log_file;
private $log_file = '';

/**
* Constructor. Defines the log file location.
Expand All @@ -40,58 +40,87 @@ class ConvertKit_Log {
*/
public function __construct( $path ) {

// Define location of log file.
$this->path = trailingslashit( $path . '/log' );
$this->log_file = $this->path . 'log.txt';
// If legacy log files exist in the Plugin's directory, delete them now.
$this->maybe_delete_legacy_log_files( $path );

// Initialize WP_Filesystem.
require_once ABSPATH . 'wp-admin/includes/file.php';
WP_Filesystem();
// Fetch the uploads directory.
$upload_dir = wp_upload_dir();

// If a historic log file exists, delete it now.
$this->maybe_delete_historic_log_file( $path );
// Bail if the uploads directory is unavailable.
if ( ! empty( $upload_dir['error'] ) || empty( $upload_dir['basedir'] ) ) {
return;
}

// Define location of log file.
$this->path = trailingslashit( $upload_dir['basedir'] ) . 'kit-logs/';
$this->log_file = $this->path . $this->get_log_file_name( $path );

// If the secure log directory does not exist, create it now.
$this->maybe_create_secure_log_directory();

}

/**
* Deletes a log.txt file for the given 'old' log file path location,
* which does not have .htaccess or index.html protection.
* Deletes log files stored in the Plugin's directory by earlier versions of
* this class.
*
* Deletes:
* - `log.txt`, used prior to 1.4.2, which has no .htaccess or index.html protection,
* - `log` directory and its contents, used from 1.4.2 to 2.6.0.
*
* @since 1.4.2
*
* @param string $old_path Path to possible historic log file.
* @param string $path Path to the Plugin.
*/
private function maybe_delete_historic_log_file( $old_path ) {
private function maybe_delete_legacy_log_files( $path ) {

// Bail if file doesn't exist.
if ( ! file_exists( trailingslashit( $old_path ) . 'log.txt' ) ) {
return;
// If a log.txt file exists in the Plugin's directory (i.e. from 1.4.2 or earlier), delete it.
$legacy_file = trailingslashit( $path ) . 'log.txt';
if ( file_exists( $legacy_file ) ) {
wp_delete_file( $legacy_file );
}

wp_delete_file( trailingslashit( $old_path ) . 'log.txt' );
// If a log directory exists in the Plugin's directory (i.e. from 1.4.2 to 2.6.0), delete it and its contents.
$legacy_path = trailingslashit( $path ) . 'log';
if ( is_dir( $legacy_path ) ) {
// Delete the files this class created in the log directory.
foreach ( array( 'log.txt', '.htaccess', 'index.html' ) as $file ) {
if ( file_exists( trailingslashit( $legacy_path ) . $file ) ) {
wp_delete_file( trailingslashit( $legacy_path ) . $file );
}
}

// Delete the log directory.
rmdir( trailingslashit( $path ) . 'log' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
}

}

/**
* Creates a directory to store the log file, with .htaccess and index.html
* files to protect the log file, as WooCommerce does.
*
* Disables logging if the directory could not be created, or isn't writable.
*
* @since 1.4.2
*/
private function maybe_create_secure_log_directory() {

// Initialize WordPress file system.
global $wp_filesystem;

// Create directory.
wp_mkdir_p( $this->path );

// Disable logging if the directory doesn't exist or isn't writable.
if ( ! is_dir( $this->path ) || ! wp_is_writable( $this->path ) ) {
$this->path = '';
$this->log_file = '';
return;
}

// Define files to protect the directory.
$wp_filesystem->put_contents( $this->path . '.htaccess', 'deny from all' );
$wp_filesystem->put_contents( $this->path . 'index.html', '' );
// phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
file_put_contents( $this->path . '.htaccess', 'deny from all' );
file_put_contents( $this->path . 'index.html', '' );
// phpcs:enable WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents

}

Expand All @@ -117,6 +146,11 @@ public function get_filename() {
*/
public function exists() {

// Bail if logging is disabled.
if ( ! $this->log_file ) {
return false;
}

return file_exists( $this->get_filename() );

}
Expand All @@ -130,15 +164,14 @@ public function exists() {
*/
public function add( $entry ) {

// Initialize WordPress file system.
global $wp_filesystem;
// Bail if logging is disabled.
if ( ! $this->log_file ) {
return;
}

// Prefix the entry with a date and time.
$entry = '(' . gmdate( 'Y-m-d H:i:s' ) . ') ' . $entry . "\n";

// Get any existing log file contents.
$contents = $wp_filesystem->get_contents( $this->get_filename() );

// Mask email addresses that may be contained within the entry.
$entry = preg_replace_callback(
'^[_a-z0-9-]+(\.[_a-z0-9-]+)*@[a-z0-9-]+(\.[a-z0-9-]+)*(\.[a-z]{2,3})^',
Expand All @@ -149,10 +182,8 @@ function ( $matches ) {
);

// Append entry.
$contents .= $entry;

// Write contents.
$wp_filesystem->put_contents( $this->get_filename(), $contents );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
file_put_contents( $this->get_filename(), $entry, FILE_APPEND );

}

Expand All @@ -166,16 +197,14 @@ function ( $matches ) {
*/
public function read( $number_of_lines = 500 ) {

// Initialize WordPress file system.
global $wp_filesystem;

// Bail if the log file does not exist.
if ( ! $this->exists() ) {
return '';
}

// Open log file.
$log = $wp_filesystem->get_contents_array( $this->get_filename() );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file
$log = file( $this->get_filename() );

// Bail if the log file is empty.
if ( ! is_array( $log ) || ! count( $log ) ) {
Expand All @@ -194,10 +223,13 @@ public function read( $number_of_lines = 500 ) {
*/
public function clear() {

// Initialize WordPress file system.
global $wp_filesystem;
// Bail if logging is disabled.
if ( ! $this->log_file ) {
return;
}

$wp_filesystem->put_contents( $this->get_filename(), '' );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
file_put_contents( $this->get_filename(), '' );

}

Expand All @@ -208,8 +240,29 @@ public function clear() {
*/
public function delete() {

// Bail if logging is disabled.
if ( ! $this->log_file ) {
return;
}

wp_delete_file( $this->get_filename() );

}

/**
* Returns the log file's name for the Plugin at the given path.
*
* @since 2.6.1
*
* @param string $path Path to the Plugin.
* @return string
*/
private function get_log_file_name( $path ) {

$slug = sanitize_key( basename( untrailingslashit( $path ) ) );

return $slug . '-' . wp_hash( $slug ) . '.log';

}

}
42 changes: 29 additions & 13 deletions tests/Integration/APITest.php
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,10 @@ protected function assertLastResponseStatusCode(int $expected): void
}

/**
* Test that a log directory and file are created in the expected location, with .htaccess
* and index.html protection, and that the name and email addresses are masked.
* Test that a log directory and file are created in the uploads directory, with .htaccess
* and index.html protection, that log files stored in the Plugin's directory by earlier
* versions of the ConvertKit_Log class are deleted, and that the name and email addresses
* are masked.
*
* @since 1.4.2
*/
Expand All @@ -227,8 +229,14 @@ public function testLog()
// Define location for log file.
define( 'CONVERTKIT_PLUGIN_PATH', $_ENV['WORDPRESS_ROOT_DIR'] . '/wp-content/uploads' );

// Create a log.txt file.
$this->tester->writeToFile(CONVERTKIT_PLUGIN_PATH . '/log.txt', 'historical log file');
// Create a log.txt file in the Plugin's directory, as versions prior to 1.4.2 did.
$this->tester->writeToFile(CONVERTKIT_PLUGIN_PATH . '/log.txt', 'legacy log file');

// Create a log directory in the Plugin's directory, as versions 1.4.2 to 2.6.0 did.
wp_mkdir_p(CONVERTKIT_PLUGIN_PATH . '/log');
$this->tester->writeToFile(CONVERTKIT_PLUGIN_PATH . '/log/log.txt', 'legacy log file');
$this->tester->writeToFile(CONVERTKIT_PLUGIN_PATH . '/log/.htaccess', 'deny from all');
$this->tester->writeToFile(CONVERTKIT_PLUGIN_PATH . '/log/index.html', '');

// Initialize API with logging enabled.
$api = new \ConvertKit_API_V4(
Expand All @@ -250,26 +258,34 @@ public function testLog()
);
$api->profile($_ENV['CONVERTKIT_API_SIGNED_SUBSCRIBER_ID']);

// Confirm the historical log.txt file has been deleted.
// Confirm the legacy log.txt file (Libraries 1.4.2 and older) and
// log directory (Libraries 1.4.2 to 2.6.0) have been deleted from the
// Plugin's directory.
$this->assertFileDoesNotExist(CONVERTKIT_PLUGIN_PATH . '/log.txt');
$this->assertDirectoryDoesNotExist(CONVERTKIT_PLUGIN_PATH . '/log');

// Fetch the log file's location in the uploads directory.
$log = new \ConvertKit_Log(CONVERTKIT_PLUGIN_PATH);
$logFile = $log->get_filename();
$logPath = dirname($logFile);

// Confirm the .htaccess and index.html files exist.
$this->assertDirectoryExists(CONVERTKIT_PLUGIN_PATH . '/log');
$this->assertFileExists(CONVERTKIT_PLUGIN_PATH . '/log/.htaccess');
$this->assertFileExists(CONVERTKIT_PLUGIN_PATH . '/log/index.html');
$this->assertFileExists(CONVERTKIT_PLUGIN_PATH . '/log/log.txt');
// Confirm the log directory, its .htaccess and index.html files, and the log file exist.
$this->assertDirectoryExists($logPath);
$this->assertFileExists($logPath . '/.htaccess');
$this->assertFileExists($logPath . '/index.html');
$this->assertFileExists($logFile);

// Confirm the contents of the log file have masked the email address, name and signed subscriber ID.
$this->tester->openFile(CONVERTKIT_PLUGIN_PATH . '/log/log.txt');
$this->tester->openFile($logFile);
$this->tester->seeInThisFile('API: POST subscribers: {"email_address":"o****@n********.c**","first_name":"******Name","state":"active","fields":{"last_name":"Last"}}');
$this->tester->seeInThisFile('API: GET profile/*****************************************');
$this->tester->dontSeeInThisFile($_ENV['CONVERTKIT_API_SUBSCRIBER_EMAIL']);
$this->tester->dontSeeInThisFile('First Name');
$this->tester->dontSeeInThisFile($_ENV['CONVERTKIT_API_SIGNED_SUBSCRIBER_ID']);

// Cleanup test.
$this->tester->cleanDir(CONVERTKIT_PLUGIN_PATH . '/log');
$this->tester->deleteDir(CONVERTKIT_PLUGIN_PATH . '/log');
$this->tester->cleanDir($logPath);
$this->tester->deleteDir($logPath);
}

/**
Expand Down
Loading