__('Outside the web roots', 'wpbr-bulk-replace'), 'writable' => __('Writable by the web server', 'wpbr-bulk-replace'), 'persistent' => __('Survives restarts (verified)', 'wpbr-bulk-replace'), 'room' => __('Free space', 'wpbr-bulk-replace'), 'published' => __('Published by the web server?', 'wpbr-bulk-replace'), ); return isset($labels[$name]) ? $labels[$name] : (string) $name; } function wpbr_storage_status_label($status, $name = '', array $p = array()) { // Surviving a restart is only ever verified by the mount table; the label says what the verification rests on. if ($name === 'persistent' && $status === 'pass') { return $p && !empty($p['fstype']) ? sprintf( /* translators: %s is a filesystem type such as ext4. */ __('Verified by the mount table (%s)', 'wpbr-bulk-replace'), $p['fstype'] ) : __('Verified by the mount table', 'wpbr-bulk-replace'); } if ($name === 'persistent' && $status === 'unverified') return __('Not verified', 'wpbr-bulk-replace'); // Three results that must not be blurred: confirmed exposure, not exposed at the addresses tested (not a claim of privacy), // and could not be checked (neither exposed nor cleared). if ($name === 'published') { if ($status === 'exposed') return __('EXPOSED', 'wpbr-bulk-replace'); if ($status === 'tested') return __('Not exposed on the addresses tested', 'wpbr-bulk-replace'); return __('Could not be checked', 'wpbr-bulk-replace'); } switch ($status) { case 'pass': return __('Verified', 'wpbr-bulk-replace'); case 'warn': return __('Allowed, with a warning', 'wpbr-bulk-replace'); case 'fail': return __('Failed', 'wpbr-bulk-replace'); } return __('Could not be verified', 'wpbr-bulk-replace'); } function wpbr_storage_problem_sentence($problem) { switch ($problem) { case 'missing': return __('The folder is missing.', 'wpbr-bulk-replace'); case 'moved': return __('The folder now leads somewhere else than when it was chosen.', 'wpbr-bulk-replace'); case 'marker_missing': return __('The folder\'s identity marker is missing, so it cannot be told apart from a different folder at the same path.', 'wpbr-bulk-replace'); case 'marker_mismatch': return __('The folder belongs to a different install of this plugin.', 'wpbr-bulk-replace'); case 'not_writable': return __('The web server can no longer write to the folder.', 'wpbr-bulk-replace'); } return ''; } function wpbr_storage_result_sentence($code) { switch ($code) { case 'activated': return array('success', __('The private location is now where new backups go. Existing backups were not moved and stay available for rollback.', 'wpbr-bulk-replace')); case 'verification_failed': return array('error', __('That folder did not pass verification, so nothing was changed.', 'wpbr-bulk-replace')); case 'confirmation_required': return array('error', __('This site already has batches or backups. Tick the confirmation box to change where new backups go.', 'wpbr-bulk-replace')); case 'belongs_to_another_install': return array('error', __('That folder already belongs to a different install of this plugin, so nothing was changed.', 'wpbr-bulk-replace')); case 'pinned': return array('error', __('WPBR_BACKUPS_DIR in wp-config.php decides the backups folder; remove it to use a recorded location.', 'wpbr-bulk-replace')); case 'unknown_candidate': return array('error', __('That folder is not one the plugin offered, so nothing was changed. Re-check and try again.', 'wpbr-bulk-replace')); case 'rechecked': return array('info', __('Locations were checked again.', 'wpbr-bulk-replace')); } return array('error', __('The location could not be changed, and nothing was changed.', 'wpbr-bulk-replace')); } // -- the state --------------------------------------------------------------------------------------------------- /** * Where backups go for this site and why. * * @return array{dir:string,kind:string,problem:string} */ function wpbr_storage_current() { $dir = wpbr_backups_dir(); if (wpbr_pinned_dir('backups') !== '') { $kind = 'pinned'; } elseif (wpbr_storage_active_dir() !== '') { $kind = 'private'; } elseif (function_exists('wpbr_get_setting') && wpbr_validate_custom_dir(wpbr_get_setting('backups_dir', '')) !== '') { $kind = 'setting'; } else { $kind = 'default'; } return array('dir' => $dir, 'kind' => $kind, 'problem' => $kind === 'private' ? wpbr_storage_active_problem() : ''); } /** * The backup files in a folder (not markers, notes or half-written copies). * * @return string[] Absolute paths. */ function wpbr_backups_in_dir($dir) { $out = array(); foreach ((array) glob(rtrim((string) $dir, '/') . '/*') as $path) { $name = basename($path); if (!is_file($path) || is_link($path)) continue; if (in_array($name, array('index.php', '.htaccess', 'web.config', WPBR_PROTECTED_MANIFEST, WPBR_LOCATION_NOTE), true) || strpos($name, 'SITE-DELETED-') === 0) continue; if (substr($name, -strlen(WPBR_PROTECTED_SUFFIX)) === WPBR_PROTECTED_SUFFIX || false !== strpos($name, '.wpbr-tmp-')) continue; $out[] = $path; } return $out; } /** * Folders other than the current one that hold backups, with how readable each is from the web. * * Measured with the same probe as the current folder, with the backups' own file types; a remembered answer is used * unless $force. * * @return array */ function wpbr_storage_earlier_folders($force = false) { $current = wpbr_storage_norm(wpbr_backups_dir()); $out = array(); foreach (wpbr_storage_roots() as $root) { if ($root === $current || !is_dir($root)) continue; $files = wpbr_backups_in_dir($root); if (!$files) continue; $verdict = wpbr_check_dir_exposure('backups', $force, $root, $files); $out[] = array( 'dir' => $root, 'count' => count($files), 'status' => isset($verdict['status']) ? $verdict['status'] : 'unknown', 'detail' => isset($verdict['detail']) ? (string) $verdict['detail'] : '', ); } return $out; } /** A deny rule for nginx for a folder, from the address the plugin measured it at; '' when the address is unknown. */ function wpbr_storage_nginx_rule($dir) { $base = wpbr_dir_public_url_base($dir); $path = $base !== '' ? (string) wp_parse_url($base, PHP_URL_PATH) : ''; if ($path === '' || $path === '/') return ''; // Configuration syntax, not prose: built from its tokens so it is never offered for translation. return implode(' ', array('location', '^~', rtrim($path, '/') . '/', '{', 'deny', 'all;', 'return', '403;', '}')); } // -- the discovery summary ----------------------------------------------------------------------------------------- function wpbr_storage_candidate_id($path) { return substr(sha1(wpbr_storage_norm($path)), 0, 16); } /** Verify every candidate and keep a plain summary for the panel. Nothing is created or changed on disk. */ function wpbr_storage_recheck() { $found = wpbr_storage_discover(); $summary = array('checked_at' => time(), 'candidates' => array()); foreach (array_merge($found['eligible'], $found['rejected']) as $entry) { $props = array(); foreach ($entry['result']['properties'] as $name => $p) { $props[$name] = array( 'status' => (string) $p['status'], 'detail' => isset($p['detail']) ? (string) $p['detail'] : '', 'basis' => isset($p['basis']) ? (string) $p['basis'] : '', 'fstype' => isset($p['fstype']) ? (string) $p['fstype'] : '', 'colocated' => array_key_exists('colocated', $p) ? $p['colocated'] : null, 'result' => isset($p['result']) ? (string) $p['result'] : '', 'asked' => isset($p['asked']) ? (int) $p['asked'] : 0, ); } $summary['candidates'][] = array( 'id' => wpbr_storage_candidate_id($entry['path']), 'path' => $entry['path'], 'source' => $entry['source'], 'ok' => (bool) $entry['result']['ok'], 'properties' => $props, ); } set_transient(WPBR_STORAGE_DISCOVERY_TRANSIENT, $summary, HOUR_IN_SECONDS); return $summary; } // -- the panel ------------------------------------------------------------------------------------------------- function wpbr_render_storage_panel() { if (!current_user_can('manage_options')) return; $current = wpbr_storage_current(); $verdict = wpbr_check_dir_exposure('backups'); $discovery = get_transient(WPBR_STORAGE_DISCOVERY_TRANSIENT); $history = $current['kind'] !== 'pinned' && wpbr_storage_site_has_history(); echo '
'; // Its buttons post their own forms and act when pressed; the Save bar does not cover them. echo '

' . esc_html__('Backups location', 'wpbr-bulk-replace'); wpbr_immediate_tag(); echo '

'; // Where backups go now. echo '

' . esc_html__('Backups are kept in:', 'wpbr-bulk-replace') . ' ' . esc_html($current['dir']) . '

'; $kinds = array( 'pinned' => __('Set by WPBR_BACKUPS_DIR in wp-config.php. Change it there.', 'wpbr-bulk-replace'), 'private' => __('A private location chosen on this screen.', 'wpbr-bulk-replace'), 'setting' => __('The folder typed in the Backups folder field above.', 'wpbr-bulk-replace'), 'default' => __('The default folder inside your uploads folder.', 'wpbr-bulk-replace'), ); echo '

' . esc_html($kinds[$current['kind']]) . '

'; if ($current['problem'] !== '') { echo '

' . esc_html__('This private location needs attention.', 'wpbr-bulk-replace') . ' ' . esc_html(wpbr_storage_problem_sentence($current['problem'])) . ' ' . esc_html__('New replacements are paused until it is fixed. Existing backups are untouched.', 'wpbr-bulk-replace') . '

'; } $status = isset($verdict['status']) ? $verdict['status'] : 'unknown'; if ($current['kind'] === 'private' && $current['problem'] === '') { echo '

' . esc_html__('Outside the web roots this site can learn. That does not prove that no web server maps the folder; if you are unsure, ask your host.', 'wpbr-bulk-replace') . '

'; } elseif ($status === 'protected') { echo '

' . esc_html__('A test file in this folder could not be read over the web.', 'wpbr-bulk-replace') . '

'; } elseif ($status === 'exposed') { echo '

' . esc_html__('This folder can be read from the web.', 'wpbr-bulk-replace') . ' ' . esc_html__('New replacements are paused until backups are somewhere private.', 'wpbr-bulk-replace') . '

'; } elseif ($current['kind'] !== 'private') { echo '

' . esc_html__('Whether this folder can be read from the web could not be checked from here.', 'wpbr-bulk-replace') . '

'; } // Folders that held backups before. $earlier = wpbr_storage_earlier_folders(); if ($earlier) { echo '

' . esc_html__('Earlier backup folders', 'wpbr-bulk-replace') . '

'; echo '

' . esc_html__('These still hold backups. They stay available for rollback; nothing in them has been moved or deleted.', 'wpbr-bulk-replace') . '

'; } // Choosing a private location: only when wp-config is not deciding. if ($current['kind'] !== 'pinned') { echo '

' . esc_html__('Use a private location', 'wpbr-bulk-replace') . '

'; echo '

' . esc_html__('A private location is a folder outside your site\'s web roots, writable by the web server and on storage the plugin can verify survives restarts (it reads the mount table; sharing a disk with your uploads is not enough, and a container\'s own storage does not count). The plugin offers only folders it can verify; you cannot type a path here. Checking also asks the web server whether it publishes the folder: it places three small test files in it, asks for them at a bounded set of addresses an alias or another site on the host would use, and removes them. A folder that is served is refused; one that is not served at those addresses is still not proven private. Existing backups are not moved.', 'wpbr-bulk-replace') . '

'; echo '
'; wp_nonce_field('wpbr_storage_recheck'); echo ' '; if (is_array($discovery) && !empty($discovery['checked_at'])) { echo '' . esc_html(sprintf( /* translators: %s is how long ago something happened, such as "5 minutes". */ __('Last checked %s ago.', 'wpbr-bulk-replace'), human_time_diff((int) $discovery['checked_at']) )) . ''; } echo '
'; if (is_array($discovery) && !empty($discovery['candidates'])) { $usable = array(); $failed = array(); $active = wpbr_storage_active_dir(); foreach ($discovery['candidates'] as $c) { // The root already in use is not offered again. if ($active !== '' && strpos($active . '/', wpbr_storage_norm($c['path']) . '/wpbr/') === 0) continue; if ($c['ok']) $usable[] = $c; else $failed[] = $c; } foreach ($usable as $c) wpbr_render_storage_candidate($c, $history); if (!$usable) { echo '

' . esc_html__('None of the folders checked can be used yet. Ask your host for a folder outside the web root, or set WPBR_PRIVATE_ROOT in wp-config.php to one, then check again.', 'wpbr-bulk-replace') . '

'; } if ($failed) { echo '
' . esc_html(sprintf( /* translators: %s is a number of folders. */ _n('%s folder cannot be used', '%s folders cannot be used', count($failed), 'wpbr-bulk-replace'), number_format_i18n(count($failed)) )) . ''; foreach ($failed as $c) wpbr_render_storage_candidate($c, $history); echo '
'; } } elseif (is_array($discovery)) { echo '

' . esc_html__('No folder was found that could be offered. Ask your host for a folder outside the web root, or set WPBR_PRIVATE_ROOT in wp-config.php to one.', 'wpbr-bulk-replace') . '

'; } } wpbr_render_storage_remedies($current, $status, $earlier); echo '
'; } /** One candidate folder: its path, where it came from, each check, and (only if it passed) the button to use it. */ function wpbr_render_storage_candidate(array $c, $history) { echo '
'; echo '

' . esc_html($c['path']) . ' (' . esc_html(wpbr_storage_source_label($c['source'])) . ')

'; echo ''; if ($c['ok']) { echo '
'; echo ''; wp_nonce_field('wpbr_storage_activate'); if ($history) { echo '

'; } echo ''; echo '
'; } else { echo '

' . esc_html__('This folder cannot be used until the failed checks are fixed.', 'wpbr-bulk-replace') . '

'; } echo '
'; } /** What to do about a folder that can be read from the web. Instructions only: nothing here changes a file. */ function wpbr_render_storage_remedies(array $current, $status, array $earlier) { $exposed = array(); if ($status === 'exposed') $exposed[] = $current['dir']; foreach ($earlier as $folder) { if ($folder['status'] === 'exposed') $exposed[] = $folder['dir']; } if (!$exposed) return; echo '

' . esc_html__('A folder that holds copies of your documents can be read from the web.', 'wpbr-bulk-replace') . ' ' . esc_html__('Do one of these. The plugin will not move or delete anything for you.', 'wpbr-bulk-replace') . '

    '; echo '
  1. ' . esc_html__('Ask your host to deny web access to the folder. On a server that puts nginx in front of Apache, the .htaccess file in the folder is not used for documents and images, so this rule has to be added to the nginx configuration:', 'wpbr-bulk-replace'); foreach ($exposed as $dir) { $rule = wpbr_storage_nginx_rule($dir); if ($rule !== '') echo '
    ' . esc_html($rule) . ''; } echo '
  2. '; echo '
  3. ' . esc_html__('Or use a private location above, then copy the files from the readable folder into it by hand and check them against the sha256 recorded beside each protected backup. Remove them from the readable folder only once you have confirmed the copies.', 'wpbr-bulk-replace') . '
  4. '; echo '
  5. ' . esc_html__('After either change, choose Check locations again. The plugin re-measures the folder itself and shows the result here.', 'wpbr-bulk-replace') . '
  6. '; echo '
'; } // -- actions ----------------------------------------------------------------------------------------------------- function wpbr_storage_back($code) { // `tab=backups` so the page opens on the tab that holds the panel; the hash then scrolls to it. wp_safe_redirect(add_query_arg(array('wpbr_storage' => $code, 'tab' => 'backups'), admin_url('admin.php?page=wpbr-settings')) . '#wpbr-storage'); exit; } add_action('admin_post_wpbr_storage_recheck', 'wpbr_handle_storage_recheck'); function wpbr_handle_storage_recheck() { check_admin_referer('wpbr_storage_recheck'); if (!current_user_can('manage_options')) { wp_die(esc_html__('You are not allowed to do that.', 'wpbr-bulk-replace'), '', array('response' => 403)); } wpbr_storage_recheck(); // The remembered exposure answers are measured again, so the panel and notices reflect a fix made since. delete_transient('wpbr_exposure_check'); wpbr_storage_back('rechecked'); } add_action('admin_post_wpbr_storage_activate', 'wpbr_handle_storage_activate'); function wpbr_handle_storage_activate() { check_admin_referer('wpbr_storage_activate'); if (!current_user_can('manage_options')) { wp_die(esc_html__('You are not allowed to do that.', 'wpbr-bulk-replace'), '', array('response' => 403)); } $id = isset($_POST['candidate']) ? sanitize_key(wp_unslash($_POST['candidate'])) : ''; // The folder comes from the plugin's own candidate list, never from the request. $path = ''; foreach (wpbr_storage_candidates() as $candidate) { if ($id !== '' && wpbr_storage_candidate_id($candidate['path']) === $id) { $path = $candidate['path']; $source = $candidate['source']; break; } } if ($path === '') wpbr_storage_back('unknown_candidate'); $result = wpbr_storage_activate($path, array('confirmed' => !empty($_POST['wpbr_confirm']), 'source' => $source)); // After a change the list is stale and the folder is measured afresh; after a refusal the list stays, so the person can // correct what was missing (the confirmation) without checking again. if (!empty($result['ok'])) { delete_transient(WPBR_STORAGE_DISCOVERY_TRANSIENT); delete_transient('wpbr_exposure_check'); } wpbr_storage_back($result['code']); } // -- notices ----------------------------------------------------------------------------------------------------- /** Called by the privacy notices on every plugin screen. */ function wpbr_render_storage_notices() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the result code to display after a redirect; nothing is changed by it. if (isset($_GET['wpbr_storage'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the result code to display after a redirect; nothing is changed by it. list($type, $text) = wpbr_storage_result_sentence(sanitize_key(wp_unslash($_GET['wpbr_storage']))); echo '

' . esc_html($text) . '

'; } // A temporary diagnostic left in the WordPress folder: it reports details about this server to any administrator. $left = wpbr_host_probe_files(); if ($left) { echo '

' . esc_html__('A temporary diagnostic file is still installed.', 'wpbr-bulk-replace') . ' ' . esc_html__('It reports details about this server to anyone who is logged in as an administrator, and it was only meant for acceptance testing. Delete it:', 'wpbr-bulk-replace') . ' ' . esc_html(implode(', ', array_map('wp_normalize_path', $left))) . '

'; } $current = wpbr_storage_current(); if ($current['problem'] !== '') { echo '

' . esc_html__('The private backups location needs attention.', 'wpbr-bulk-replace') . ' ' . esc_html(wpbr_storage_problem_sentence($current['problem'])) . ' ' . esc_html__('New replacements are paused until it is fixed. Existing backups are untouched.', 'wpbr-bulk-replace') . ' ' . '' . esc_html__('Open Backups location', 'wpbr-bulk-replace') . '

'; } // Backups left in an earlier folder that can be read from the web. Nothing was moved or deleted. foreach (wpbr_storage_earlier_folders() as $folder) { if ($folder['status'] !== 'exposed') continue; echo '

' . esc_html(sprintf( /* translators: %s is a number of backup files. */ _n('%s earlier backup can be read from the web.', '%s earlier backups can be read from the web.', $folder['count'], 'wpbr-bulk-replace'), number_format_i18n($folder['count']) )) . ' ' . esc_html__('They are in', 'wpbr-bulk-replace') . ' ' . esc_html($folder['dir']) . ' ' . esc_html__('and anyone who guesses a name can download a copy of a document. The plugin has not moved or deleted them.', 'wpbr-bulk-replace') . ' ' . '' . esc_html__('See how to fix this', 'wpbr-bulk-replace') . '

'; } }