Completed
Push — add/oauth-connection ( 084714...305e42 )
by
unknown
14:09 queued 06:55
created

Helper_Script_Manager::verify_file_header()   B

Complexity

Conditions 7
Paths 8

Size

Total Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
nc 8
nop 2
dl 0
loc 26
rs 8.5706
c 0
b 0
f 0
1
<?php
2
/**
3
 * The Jetpack Backup Helper Script Manager class.
4
 *
5
 * @package automattic/jetpack-backup
6
 */
7
8
namespace Automattic\Jetpack\Backup;
9
10
/**
11
 * Helper_Script_Manager manages installation, deletion and cleanup of Helper Scripts
12
 * to assist with backing up Jetpack Sites.
13
 */
14
class Helper_Script_Manager {
15
16
	const TEMP_DIRECTORY = 'jetpack-temp';
17
	const HELPER_HEADER  = "<?php /* Jetpack Backup Helper Script */\n";
18
	const EXPIRY_TIME    = 8 * 3600; // 8 hours
19
	const MAX_FILESIZE   = 1024 * 1024; // 1 MiB
20
21
	const README_LINES = array(
22
		'These files have been put on your server by Jetpack to assist with backups and restores of your site content. They are cleaned up automatically when we no longer need them.',
23
		'If you no longer have Jetpack connected to your site, you can delete them manually.',
24
		'If you have questions or need assistance, please contact Jetpack Support at https://jetpack.com/support/',
25
		'If you like to build amazing things with WordPress, you should visit automattic.com/jobs and apply to join the fun – mention this file when you apply!;',
26
	);
27
28
	const INDEX_FILE = '<?php // Silence is golden';
29
30
	/**
31
	 * Installs a Helper Script, and returns its filesystem path and access url.
32
	 *
33
	 * @access public
34
	 * @static
35
	 *
36
	 * @param string $script_body Helper Script file contents.
37
	 * @return array|WP_Error     Either an array containing the path and url of the helper script, or an error.
38
	 */
39
	public static function install_helper_script( $script_body ) {
40
		// Check that the script body contains the correct header.
41
		if ( strncmp( $script_body, self::HELPER_HEADER, strlen( self::HELPER_HEADER ) ) !== 0 ) {
42
			return new \WP_Error( 'invalid_helper', 'Invalid Helper Script header' );
0 ignored issues
show
Unused Code introduced by
The call to WP_Error::__construct() has too many arguments starting with 'invalid_helper'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
43
		}
44
45
		// Refuse to install a Helper Script that is too large.
46
		if ( strlen( $script_body ) > self::MAX_FILESIZE ) {
47
			return new \WP_Error( 'invalid_helper', 'Invalid Helper Script size' );
0 ignored issues
show
Unused Code introduced by
The call to WP_Error::__construct() has too many arguments starting with 'invalid_helper'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
48
		}
49
50
		// Replace '[wp_path]' in the Helper Script with the WordPress installation location. Allows the Helper Script to find WordPress.
51
		$script_body = str_replace( '[wp_path]', addslashes( ABSPATH ), $script_body );
52
53
		// Create a jetpack-temp directory for the Helper Script.
54
		$temp_directory = self::create_temp_directory();
55
		if ( \is_wp_error( $temp_directory ) ) {
56
			return $temp_directory;
57
		}
58
59
		// Generate a random filename, avoid clashes.
60
		$max_attempts = 5;
61
		for ( $attempt = 0; $attempt < $max_attempts; $attempt++ ) {
62
			$file_key  = wp_generate_password( 10, false );
63
			$file_name = 'jp-helper-' . $file_key . '.php';
64
			$file_path = trailingslashit( $temp_directory['path'] ) . $file_name;
65
66
			if ( ! file_exists( $file_path ) ) {
67
				// Attempt to write helper script.
68
				if ( ! self::put_contents( $file_path, $script_body ) ) {
69
					if ( file_exists( $file_path ) ) {
70
						unlink( $file_path );
71
					}
72
73
					continue;
74
				}
75
76
				// Always schedule a cleanup run shortly after EXPIRY_TIME.
77
				\wp_schedule_single_event( time() + self::EXPIRY_TIME + 60, 'jetpack_backup_cleanup_helper_scripts' );
78
79
				// Success! Figure out the URL and return the path and URL.
80
				return array(
81
					'path' => $file_path,
82
					'url'  => trailingslashit( $temp_directory['url'] ) . $file_name,
83
				);
84
			}
85
		}
86
87
		return new \WP_Error( 'install_faied', 'Failed to install Helper Script' );
0 ignored issues
show
Unused Code introduced by
The call to WP_Error::__construct() has too many arguments starting with 'install_faied'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
88
	}
89
90
	/**
91
	 * Given a path, verify it looks like a helper script and then delete it if so.
92
	 *
93
	 * @access public
94
	 * @static
95
	 *
96
	 * @param string $path Path to Helper Script to delete.
97
	 * @return boolean     True if the file is deleted (or does not exist).
98
	 */
99
	public static function delete_helper_script( $path ) {
100
		if ( ! file_exists( $path ) ) {
101
			return true;
102
		}
103
104
		// Check this file looks like a JPR helper script.
105
		if ( ! self::verify_file_header( $path, self::HELPER_HEADER ) ) {
106
			return false;
107
		}
108
109
		return unlink( $path );
110
	}
111
112
	/**
113
	 * Search for Helper Scripts that are suspiciously old, and clean them out.
114
	 *
115
	 * @access public
116
	 * @static
117
	 */
118
	public static function cleanup_expired_helper_scripts() {
119
		self::cleanup_helper_scripts( time() - self::EXPIRY_TIME );
120
	}
121
122
	/**
123
	 * Search for and delete all Helper Scripts. Used during uninstallation.
124
	 *
125
	 * @access public
126
	 * @static
127
	 */
128
	public static function delete_all_helper_scripts() {
129
		self::cleanup_helper_scripts( null );
130
	}
131
132
	/**
133
	 * Search for and delete Helper Scripts. If an $expiry_time is specified, only delete Helper Scripts
134
	 * with an mtime older than $expiry_time. Otherwise, delete them all.
135
	 *
136
	 * @access public
137
	 * @static
138
	 *
139
	 * @param int|null $expiry_time If specified, only delete scripts older than $expiry_time.
140
	 */
141
	public static function cleanup_helper_scripts( $expiry_time = null ) {
142
		foreach ( self::get_install_locations() as $directory => $url ) {
143
			$temp_dir = trailingslashit( $directory ) . self::TEMP_DIRECTORY;
144
145
			if ( is_dir( $temp_dir ) ) {
146
				// Find expired helper scripts and delete them.
147
				$helper_scripts = glob( trailingslashit( $temp_dir ) . 'jp-helper-*.php' );
148
				if ( is_array( $helper_scripts ) ) {
149
					foreach ( $helper_scripts as $filename ) {
150
						if ( null === $expiry_time || filemtime( $filename ) < $expiry_time ) {
151
							self::delete_helper_script( $filename );
152
						}
153
					}
154
				}
155
156
				// Delete the directory if it's empty now.
157
				self::delete_empty_helper_directory( $temp_dir );
158
			}
159
		}
160
	}
161
162
	/**
163
	 * Delete a helper script directory if it's empty
164
	 *
165
	 * @access public
166
	 * @static
167
	 *
168
	 * @param string $dir Path to Helper Script directory.
169
	 * @return boolean    True if the directory is deleted
170
	 */
171
	private static function delete_empty_helper_directory( $dir ) {
172
		if ( ! is_dir( $dir ) ) {
173
			return false;
174
		}
175
176
		// Tally the files in the target directory, and reject if there are too many.
177
		$glob_path    = trailingslashit( $dir ) . '*';
178
		$dir_contents = glob( $glob_path );
179
		if ( count( $dir_contents ) > 2 ) {
180
			return false;
181
		}
182
183
		// Check that the only remaining files are a README and index.php generated by this system.
184
		$allowed_files = array(
185
			'README'    => self::README_LINES[0],
186
			'index.php' => self::INDEX_FILE,
187
		);
188
189
		foreach ( $dir_contents as $path ) {
190
			$basename = basename( $path );
191
			if ( ! isset( $allowed_files[ $basename ] ) ) {
192
				return false;
193
			}
194
195
			// Verify the file starts with the expected contents.
196
			if ( ! self::verify_file_header( $path, $allowed_files[ $basename ] ) ) {
197
				return false;
198
			}
199
200
			if ( ! unlink( $path ) ) {
201
				return false;
202
			}
203
		}
204
205
		// If the directory is now empty, delete it.
206
		if ( count( glob( $glob_path ) ) === 0 ) {
207
			return rmdir( $dir );
208
		}
209
210
		return false;
211
	}
212
213
	/**
214
	 * Find an appropriate location for a jetpack-temp folder, and create one
215
	 *
216
	 * @access public
217
	 * @static
218
	 *
219
	 * @return WP_Error|array Array containing the url and path of the temp directory if successful, WP_Error if not.
220
	 */
221
	private static function create_temp_directory() {
222
		foreach ( self::get_install_locations() as $directory => $url ) {
223
			// Check if the install location is writeable.
224
			if ( ! is_writeable( $directory ) ) {
225
				continue;
226
			}
227
228
			// Create if one doesn't already exist.
229
			$temp_dir = trailingslashit( $directory ) . self::TEMP_DIRECTORY;
230
			if ( ! is_dir( $temp_dir ) ) {
231
				if ( ! mkdir( $temp_dir ) ) {
232
					continue;
233
				}
234
235
				// Temp directory created. Drop a README and index.php file in there.
236
				self::write_supplementary_temp_files( $temp_dir );
237
			}
238
239
			return array(
240
				'path' => trailingslashit( $directory ) . self::TEMP_DIRECTORY,
241
				'url'  => trailingslashit( $url ) . self::TEMP_DIRECTORY,
242
			);
243
		}
244
245
		return new \WP_Error( 'temp_directory', 'Failed to create jetpack-temp directory' );
0 ignored issues
show
Unused Code introduced by
The call to WP_Error::__construct() has too many arguments starting with 'temp_directory'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
246
	}
247
248
	/**
249
	 * Write out an index.php file and a README file for a new jetpack-temp directory.
250
	 *
251
	 * @access public
252
	 * @static
253
	 *
254
	 * @param string $dir Path to Helper Script directory.
255
	 */
256
	private static function write_supplementary_temp_files( $dir ) {
257
		$readme_path = trailingslashit( $dir ) . 'README';
258
		self::put_contents( $readme_path, implode( "\n\n", self::README_LINES ) );
259
260
		$index_path = trailingslashit( $dir ) . 'index.php';
261
		self::put_contents( $index_path, self::INDEX_FILE );
262
	}
263
264
	/**
265
	 * Write a file to the specified location with the specified contents.
266
	 *
267
	 * @access private
268
	 * @static
269
	 *
270
	 * @param string $file_path Path to write to.
271
	 * @param string $contents  File contents to write.
272
	 * @return boolean          True if successfully written.
273
	 */
274
	private static function put_contents( $file_path, $contents ) {
275
		global $wp_filesystem;
276
277
		if ( ! function_exists( '\\WP_Filesystem' ) ) {
278
			require_once ABSPATH . 'wp-admin/includes/file.php';
279
		}
280
281
		if ( ! \WP_Filesystem() ) {
282
			return false;
283
		}
284
285
		return $wp_filesystem->put_contents( $file_path, $contents );
286
	}
287
288
	/**
289
	 * Checks that a file exists, is readable, and has the expected header.
290
	 *
291
	 * @access private
292
	 * @static
293
	 *
294
	 * @param string $file_path       File to verify.
295
	 * @param string $expected_header Header that the file should have.
296
	 * @return boolean                True if the file exists, is readable, and the header matches.
297
	 */
298
	private static function verify_file_header( $file_path, $expected_header ) {
299
		global $wp_filesystem;
300
301
		if ( ! function_exists( '\\WP_Filesystem' ) ) {
302
			require_once ABSPATH . 'wp-admin/includes/file.php';
303
		}
304
305
		if ( ! \WP_Filesystem() ) {
306
			return false;
307
		}
308
309
		// Verify the file exists and is readable.
310
		if ( ! $wp_filesystem->exists( $file_path ) || ! $wp_filesystem->is_readable( $file_path ) ) {
311
			return false;
312
		}
313
314
		// Verify that the file isn't too big or small.
315
		$file_size = $wp_filesystem->size( $file_path );
316
		if ( $file_size < strlen( $expected_header ) || $file_size > self::MAX_FILESIZE ) {
317
			return false;
318
		}
319
320
		// Read the file and verify its header.
321
		$contents = $wp_filesystem->get_contents( $file_path );
322
		return ( strncmp( $contents, $expected_header, strlen( $expected_header ) ) === 0 );
323
	}
324
325
	/**
326
	 * Gets an associative array of possible places to install a jetpack-temp directory, along with the URL to access each.
327
	 *
328
	 * @access private
329
	 * @static
330
	 *
331
	 * @return array Array, with keys specifying the full path of install locations, and values with the equivalent URL.
332
	 */
333
	public static function get_install_locations() {
334
		// Include WordPress root and wp-content.
335
		$install_locations = array(
336
			\ABSPATH        => \get_site_url(),
337
			\WP_CONTENT_DIR => \WP_CONTENT_URL,
338
		);
339
340
		// Include uploads folder.
341
		$upload_dir_info                                  = \wp_upload_dir();
342
		$install_locations[ $upload_dir_info['basedir'] ] = $upload_dir_info['baseurl'];
343
344
		return $install_locations;
345
	}
346
347
}
348