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
89.45
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
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
9.07
 send_private_file
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
72
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     * Used to get the log file filepath for the requested date.
24     *
25     * @var API_Interface
26     */
27    protected API_Interface $api;
28
29    /**
30     * Used to check the request is for a log file from this plugin.
31     *
32     * @var Logger_Settings_Interface
33     */
34    protected Logger_Settings_Interface $settings;
35
36    /**
37     * Constructor.
38     *
39     * @param API_Interface             $api The logger's main functions.
40     * @param Logger_Settings_Interface $settings The logger settings.
41     * @param LoggerInterface           $logger The logger itself for logging.
42     */
43    public function __construct( API_Interface $api, Logger_Settings_Interface $settings, LoggerInterface $logger ) {
44
45        $this->setLogger( $logger );
46        $this->settings = $settings;
47        $this->api      = $api;
48    }
49
50    /**
51     * Check is this a request to download a log.
52     * Return quietly if not.
53     * Fail hard if nonce is incorrect.
54     * Return if required parameters missing.
55     * Return if plugin slug does not match or if date is malformed.
56     * Invoke `send_private_file()` to download the file.
57     *
58     * This is really only needed when WooCommerce logger is being used because it store the log files in
59     * `/uploads/wc-logs` which has a `.htaccess` preventing downloads.
60     *
61     * @hooked init
62     */
63    public function maybe_download_log(): void {
64
65        if ( ! isset( $_GET['download-log'] ) ) {
66            return;
67        }
68
69        if ( false === check_admin_referer( 'bh-wp-logger-download' ) ) {
70            $this->logger->warning( 'Bad nonce when downloading log.' );
71            wp_die();
72            return; // Needed for tests. @phpstan-ignore-line.
73        }
74
75        if ( 'true' !== sanitize_text_field( wp_unslash( $_GET['download-log'] ) ) ) {
76            return;
77        }
78
79        if ( ! isset( $_GET['page'] ) || ! isset( $_GET['date'] ) ) {
80            return;
81        }
82
83        $page = sanitize_text_field( wp_unslash( $_GET['page'] ) );
84
85        if ( 0 !== strpos( $page, $this->settings->get_plugin_slug() ) ) {
86            return;
87        }
88
89        $date = sanitize_text_field( wp_unslash( $_GET['date'] ) );
90
91        if ( 1 !== preg_match( '/\d{4}-\d{2}-\d{2}/', $date ) ) {
92            return;
93        }
94
95        $files = $this->api->get_log_files( $date );
96
97        if ( ! isset( $files[ $date ] ) ) {
98            return;
99        }
100
101        $file = $files[ $date ];
102
103        $this->send_private_file( $file );
104    }
105
106    /**
107     * Set the correct headers, send the file, die.
108     *
109     * @param string $filepath The requested filename.
110     *
111     * @see Serve_Private_File::send_private_file()
112     *
113     * Nonce was checked above.
114     *
115     * phpcs:disable WordPress.Security.NonceVerification.Recommended
116     */
117    protected function send_private_file( string $filepath ): void {
118
119        // Add the mimetype header.
120        $mime     = wp_check_filetype( $filepath );  // This function just looks at the extension.
121        $mimetype = $mime['type'];
122        if ( ! $mimetype && function_exists( 'mime_content_type' ) ) {
123
124            $mimetype = mime_content_type( $filepath );  // Use ext-fileinfo to look inside the file.
125        }
126        if ( ! $mimetype ) {
127            $mimetype = 'application/octet-stream';
128        }
129
130        header( 'Content-type: ' . $mimetype ); // always send this.
131        header( 'Content-Disposition: attachment; filename="' . basename( $filepath ) . '"' );
132
133        // Add timing headers.
134        $date_format        = 'D, d M Y H:i:s T';  // RFC2616 date format for HTTP.
135        $last_modified_unix = (int) filemtime( $filepath );
136        $last_modified      = gmdate( $date_format, $last_modified_unix );
137        $etag               = md5( $last_modified );
138        header( "Last-Modified: $last_modified" );
139        header( 'ETag: "' . $etag . '"' );
140        header( 'Expires: ' . gmdate( $date_format, time() + HOUR_IN_SECONDS ) ); // an arbitrary hour from now.
141
142        // Support for caching.
143        $client_etag              = isset( $_REQUEST['HTTP_IF_NONE_MATCH'] ) ? trim( sanitize_text_field( wp_unslash( $_REQUEST['HTTP_IF_NONE_MATCH'] ) ) ) : '';
144        $client_if_mod_since      = isset( $_REQUEST['HTTP_IF_MODIFIED_SINCE'] ) ? trim( sanitize_text_field( wp_unslash( $_REQUEST['HTTP_IF_MODIFIED_SINCE'] ) ) ) : '';
145        $client_if_mod_since_unix = strtotime( $client_if_mod_since );
146
147        if ( $etag === $client_etag || $last_modified_unix <= $client_if_mod_since_unix ) {
148            // Return 'not modified' header.
149            status_header( 304 );
150            die();
151        }
152
153        // If we made it this far, just serve the file.
154        status_header( 200 );
155        // (WP_Filesystem is only loaded for admin requests, not applicable here).
156        // phpcs:disable WordPress.WP.AlternativeFunctions.file_system_read_readfile
157        readfile( $filepath );
158        die();
159    }
160}