Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
39.58% covered (danger)
39.58%
19 / 48
0.00% covered (danger)
0.00%
0 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
Init
39.58% covered (danger)
39.58%
19 / 48
0.00% covered (danger)
0.00%
0 / 3
139.66
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 maybe_download_log
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
12.12
 send_private_file
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
110
1<?php
2/**
3 * Before any UI loads, check if the request is for a log file download.
4 *
5 * @package brianhenryie/bh-wp-logger
6 */
7
8namespace BrianHenryIE\WP_Logger\WP_Includes;
9
10use BrianHenryIE\WP_Logger\API_Interface;
11use BrianHenryIE\WP_Logger\Logger_Settings_Interface;
12use BrianHenryIE\WP_Private_Uploads\Frontend\Serve_Private_File;
13use Psr\Log\LoggerAwareTrait;
14use Psr\Log\LoggerInterface;
15
16/**
17 * Hook into init, return gracefully if this is not a log download request.
18 */
19class Init {
20    use LoggerAwareTrait;
21
22    /**
23     * Constructor.
24     *
25     * @param API_Interface             $api The logger's main functions, used to get the log file filepath for the requested date.
26     * @param Logger_Settings_Interface $settings The logger settings. Used to check the request is for a log file from this plugin.
27     * @param LoggerInterface           $logger The logger itself for logging.
28     */
29    public function __construct(
30        protected API_Interface $api,
31        protected Logger_Settings_Interface $settings,
32        LoggerInterface $logger
33    ) {
34        $this->setLogger( $logger );
35    }
36
37    /**
38     * Check is this a request to download a log.
39     * Return quietly if not.
40     * Fail hard if nonce is incorrect.
41     * Return if required parameters missing.
42     * Return if plugin slug does not match or if date is malformed.
43     * Invoke `send_private_file()` to download the file.
44     *
45     * This is really only needed when WooCommerce logger is being used because it stores the log files in
46     * `/uploads/wc-logs` which has a `.htaccess` preventing downloads.
47     *
48     * @hooked init
49     */
50    public function maybe_download_log(): void {
51
52        if ( ! isset( $_GET['download-log'] ) || ! is_string( $_GET['download-log'] ) ) {
53            return;
54        }
55
56        if ( false === check_admin_referer( 'bh-wp-logger-download' ) ) {
57            $this->logger->warning( 'Bad nonce when downloading log.' );
58            wp_die();
59        }
60
61        if ( 'true' !== sanitize_text_field( wp_unslash( $_GET['download-log'] ) ) ) {
62            return;
63        }
64
65        if (
66            ! isset( $_GET['page'] ) || ! isset( $_GET['date'] )
67            || ! is_string( $_GET['page'] ) || ! is_string( $_GET['date'] )
68        ) {
69            return;
70        }
71
72        $page = sanitize_text_field( wp_unslash( $_GET['page'] ) );
73
74        if ( ! str_starts_with( $page, $this->settings->get_plugin_slug() ) ) {
75            return;
76        }
77
78        $date = sanitize_text_field( wp_unslash( $_GET['date'] ) );
79
80        if ( 1 !== preg_match( '/\d{4}-\d{2}-\d{2}/', $date ) ) {
81            return;
82        }
83
84        $files = $this->api->get_log_files( $date );
85
86        if ( ! isset( $files[ $date ] ) ) {
87            return;
88        }
89
90        $file = $files[ $date ];
91
92        $this->send_private_file( $file );
93    }
94
95    /**
96     * Set the correct headers, send the file, die.
97     *
98     * @param string $filepath The requested filename.
99     *
100     * @see Serve_Private_File::send_private_file()
101     *
102     * Nonce was checked above.
103     *
104     * phpcs:disable WordPress.Security.NonceVerification.Recommended
105     */
106    protected function send_private_file( string $filepath ): void {
107
108        // Add the mimetype header.
109        $mime     = wp_check_filetype( $filepath );  // This function just looks at the extension.
110        $mimetype = $mime['type'];
111        if ( ! $mimetype && function_exists( 'mime_content_type' ) ) {
112
113            $mimetype = mime_content_type( $filepath );  // Use `ext-fileinfo` to look inside the file.
114        }
115        if ( ! $mimetype ) {
116            $mimetype = 'application/octet-stream';
117        }
118
119        header( 'Content-type: ' . $mimetype ); // always send this.
120        header( 'Content-Disposition: attachment; filename="' . basename( $filepath ) . '"' );
121
122        // Add timing headers.
123        $date_format        = 'D, d M Y H:i:s T';  // RFC2616 date format for HTTP.
124        $last_modified_unix = (int) filemtime( $filepath );
125        $last_modified      = gmdate( $date_format, $last_modified_unix );
126        $etag               = md5( $last_modified );
127        header( "Last-Modified: $last_modified" );
128        header( 'ETag: "' . $etag . '"' );
129        header( 'Expires: ' . gmdate( $date_format, time() + HOUR_IN_SECONDS ) ); // an arbitrary hour from now.
130
131        // Support for caching.
132        $client_etag              = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) && is_string( $_SERVER['HTTP_IF_NONE_MATCH'] )
133            ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) ) : '';
134        $client_if_mod_since      = isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) && is_string( $_SERVER['HTTP_IF_MODIFIED_SINCE'] )
135            ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) ) : '';
136        $client_if_mod_since_unix = strtotime( $client_if_mod_since );
137
138        if ( $etag === $client_etag || $last_modified_unix <= $client_if_mod_since_unix ) {
139            // Return 'not modified' header.
140            status_header( 304 );
141            die();
142        }
143
144        // If we made it this far, just serve the file.
145        status_header( 200 );
146
147        /**
148         * We could use {@see \WP_Filesystem_Direct::get_contents()} but streaming the file prevents memory issues.
149         *
150         * phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_readfile
151         */
152        readfile( $filepath );
153
154        die();
155    }
156}