summaryrefslogtreecommitdiff
blob: 58c54eb41b52305f50260236fdaf81bc876fd1e5 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
<?php
/**
 * The Jetpack Backup Helper Script Manager class.
 *
 * @package automattic/jetpack-backup
 */

namespace Automattic\Jetpack\Backup;

/**
 * Helper_Script_Manager manages installation, deletion and cleanup of Helper Scripts
 * to assist with backing up Jetpack Sites.
 */
class Helper_Script_Manager {

	const TEMP_DIRECTORY = 'jetpack-temp';
	const HELPER_HEADER  = "<?php /* Jetpack Backup Helper Script */\n";
	const EXPIRY_TIME    = 8 * 3600; // 8 hours
	const MAX_FILESIZE   = 1024 * 1024; // 1 MiB

	const README_LINES = array(
		'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.',
		'If you no longer have Jetpack connected to your site, you can delete them manually.',
		'If you have questions or need assistance, please contact Jetpack Support at https://jetpack.com/support/',
		'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!;',
	);

	const INDEX_FILE = '<?php // Silence is golden';

	/**
	 * Installs a Helper Script, and returns its filesystem path and access url.
	 *
	 * @access public
	 * @static
	 *
	 * @param string $script_body Helper Script file contents.
	 * @return array|WP_Error     Either an array containing the path and url of the helper script, or an error.
	 */
	public static function install_helper_script( $script_body ) {
		// Check that the script body contains the correct header.
		if ( strncmp( $script_body, self::HELPER_HEADER, strlen( self::HELPER_HEADER ) ) !== 0 ) {
			return new \WP_Error( 'invalid_helper', 'Invalid Helper Script header' );
		}

		// Refuse to install a Helper Script that is too large.
		if ( strlen( $script_body ) > self::MAX_FILESIZE ) {
			return new \WP_Error( 'invalid_helper', 'Invalid Helper Script size' );
		}

		// Replace '[wp_path]' in the Helper Script with the WordPress installation location. Allows the Helper Script to find WordPress.
		$script_body = str_replace( '[wp_path]', addslashes( ABSPATH ), $script_body );

		// Create a jetpack-temp directory for the Helper Script.
		$temp_directory = self::create_temp_directory();
		if ( \is_wp_error( $temp_directory ) ) {
			return $temp_directory;
		}

		// Generate a random filename, avoid clashes.
		$max_attempts = 5;
		for ( $attempt = 0; $attempt < $max_attempts; $attempt++ ) {
			$file_key  = wp_generate_password( 10, false );
			$file_name = 'jp-helper-' . $file_key . '.php';
			$file_path = trailingslashit( $temp_directory['path'] ) . $file_name;

			if ( ! file_exists( $file_path ) ) {
				// Attempt to write helper script.
				if ( ! self::put_contents( $file_path, $script_body ) ) {
					if ( file_exists( $file_path ) ) {
						unlink( $file_path );
					}

					continue;
				}

				// Always schedule a cleanup run shortly after EXPIRY_TIME.
				\wp_schedule_single_event( time() + self::EXPIRY_TIME + 60, 'jetpack_backup_cleanup_helper_scripts' );

				// Success! Figure out the URL and return the path and URL.
				return array(
					'path' => $file_path,
					'url'  => trailingslashit( $temp_directory['url'] ) . $file_name,
				);
			}
		}

		return new \WP_Error( 'install_faied', 'Failed to install Helper Script' );
	}

	/**
	 * Given a path, verify it looks like a helper script and then delete it if so.
	 *
	 * @access public
	 * @static
	 *
	 * @param string $path Path to Helper Script to delete.
	 * @return boolean     True if the file is deleted (or does not exist).
	 */
	public static function delete_helper_script( $path ) {
		if ( ! file_exists( $path ) ) {
			return true;
		}

		// Check this file looks like a JPR helper script.
		if ( ! self::verify_file_header( $path, self::HELPER_HEADER ) ) {
			return false;
		}

		return unlink( $path );
	}

	/**
	 * Search for Helper Scripts that are suspiciously old, and clean them out.
	 *
	 * @access public
	 * @static
	 */
	public static function cleanup_expired_helper_scripts() {
		self::cleanup_helper_scripts( time() - self::EXPIRY_TIME );
	}

	/**
	 * Search for and delete all Helper Scripts. Used during uninstallation.
	 *
	 * @access public
	 * @static
	 */
	public static function delete_all_helper_scripts() {
		self::cleanup_helper_scripts( null );
	}

	/**
	 * Search for and delete Helper Scripts. If an $expiry_time is specified, only delete Helper Scripts
	 * with an mtime older than $expiry_time. Otherwise, delete them all.
	 *
	 * @access public
	 * @static
	 *
	 * @param int|null $expiry_time If specified, only delete scripts older than $expiry_time.
	 */
	public static function cleanup_helper_scripts( $expiry_time = null ) {
		foreach ( self::get_install_locations() as $directory => $url ) {
			$temp_dir = trailingslashit( $directory ) . self::TEMP_DIRECTORY;

			if ( is_dir( $temp_dir ) ) {
				// Find expired helper scripts and delete them.
				$helper_scripts = glob( trailingslashit( $temp_dir ) . 'jp-helper-*.php' );
				if ( is_array( $helper_scripts ) ) {
					foreach ( $helper_scripts as $filename ) {
						if ( null === $expiry_time || filemtime( $filename ) < $expiry_time ) {
							self::delete_helper_script( $filename );
						}
					}
				}

				// Delete the directory if it's empty now.
				self::delete_empty_helper_directory( $temp_dir );
			}
		}
	}

	/**
	 * Delete a helper script directory if it's empty
	 *
	 * @access public
	 * @static
	 *
	 * @param string $dir Path to Helper Script directory.
	 * @return boolean    True if the directory is deleted
	 */
	private static function delete_empty_helper_directory( $dir ) {
		if ( ! is_dir( $dir ) ) {
			return false;
		}

		// Tally the files in the target directory, and reject if there are too many.
		$glob_path    = trailingslashit( $dir ) . '*';
		$dir_contents = glob( $glob_path );
		if ( count( $dir_contents ) > 2 ) {
			return false;
		}

		// Check that the only remaining files are a README and index.php generated by this system.
		$allowed_files = array(
			'README'    => self::README_LINES[0],
			'index.php' => self::INDEX_FILE,
		);

		foreach ( $dir_contents as $path ) {
			$basename = basename( $path );
			if ( ! isset( $allowed_files[ $basename ] ) ) {
				return false;
			}

			// Verify the file starts with the expected contents.
			if ( ! self::verify_file_header( $path, $allowed_files[ $basename ] ) ) {
				return false;
			}

			if ( ! unlink( $path ) ) {
				return false;
			}
		}

		// If the directory is now empty, delete it.
		if ( count( glob( $glob_path ) ) === 0 ) {
			return rmdir( $dir );
		}

		return false;
	}

	/**
	 * Find an appropriate location for a jetpack-temp folder, and create one
	 *
	 * @access public
	 * @static
	 *
	 * @return WP_Error|array Array containing the url and path of the temp directory if successful, WP_Error if not.
	 */
	private static function create_temp_directory() {
		foreach ( self::get_install_locations() as $directory => $url ) {
			// Check if the install location is writeable.
			if ( ! is_writeable( $directory ) ) {
				continue;
			}

			// Create if one doesn't already exist.
			$temp_dir = trailingslashit( $directory ) . self::TEMP_DIRECTORY;
			if ( ! is_dir( $temp_dir ) ) {
				if ( ! mkdir( $temp_dir ) ) {
					continue;
				}

				// Temp directory created. Drop a README and index.php file in there.
				self::write_supplementary_temp_files( $temp_dir );
			}

			return array(
				'path' => trailingslashit( $directory ) . self::TEMP_DIRECTORY,
				'url'  => trailingslashit( $url ) . self::TEMP_DIRECTORY,
			);
		}

		return new \WP_Error( 'temp_directory', 'Failed to create jetpack-temp directory' );
	}

	/**
	 * Write out an index.php file and a README file for a new jetpack-temp directory.
	 *
	 * @access public
	 * @static
	 *
	 * @param string $dir Path to Helper Script directory.
	 */
	private static function write_supplementary_temp_files( $dir ) {
		$readme_path = trailingslashit( $dir ) . 'README';
		self::put_contents( $readme_path, implode( "\n\n", self::README_LINES ) );

		$index_path = trailingslashit( $dir ) . 'index.php';
		self::put_contents( $index_path, self::INDEX_FILE );
	}

	/**
	 * Write a file to the specified location with the specified contents.
	 *
	 * @access private
	 * @static
	 *
	 * @param string $file_path Path to write to.
	 * @param string $contents  File contents to write.
	 * @return boolean          True if successfully written.
	 */
	private static function put_contents( $file_path, $contents ) {
		global $wp_filesystem;

		if ( ! function_exists( '\\WP_Filesystem' ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
		}

		if ( ! \WP_Filesystem() ) {
			return false;
		}

		return $wp_filesystem->put_contents( $file_path, $contents );
	}

	/**
	 * Checks that a file exists, is readable, and has the expected header.
	 *
	 * @access private
	 * @static
	 *
	 * @param string $file_path       File to verify.
	 * @param string $expected_header Header that the file should have.
	 * @return boolean                True if the file exists, is readable, and the header matches.
	 */
	private static function verify_file_header( $file_path, $expected_header ) {
		global $wp_filesystem;

		if ( ! function_exists( '\\WP_Filesystem' ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
		}

		if ( ! \WP_Filesystem() ) {
			return false;
		}

		// Verify the file exists and is readable.
		if ( ! $wp_filesystem->exists( $file_path ) || ! $wp_filesystem->is_readable( $file_path ) ) {
			return false;
		}

		// Verify that the file isn't too big or small.
		$file_size = $wp_filesystem->size( $file_path );
		if ( $file_size < strlen( $expected_header ) || $file_size > self::MAX_FILESIZE ) {
			return false;
		}

		// Read the file and verify its header.
		$contents = $wp_filesystem->get_contents( $file_path );
		return ( strncmp( $contents, $expected_header, strlen( $expected_header ) ) === 0 );
	}

	/**
	 * Gets an associative array of possible places to install a jetpack-temp directory, along with the URL to access each.
	 *
	 * @access private
	 * @static
	 *
	 * @return array Array, with keys specifying the full path of install locations, and values with the equivalent URL.
	 */
	public static function get_install_locations() {
		// Include WordPress root and wp-content.
		$install_locations = array(
			\ABSPATH        => \get_site_url(),
			\WP_CONTENT_DIR => \WP_CONTENT_URL,
		);

		// Include uploads folder.
		$upload_dir_info                                  = \wp_upload_dir();
		$install_locations[ $upload_dir_info['basedir'] ] = $upload_dir_info['baseurl'];

		return $install_locations;
	}

}