WPBR_ENABLE_DIAGNOSTICS in wp-config.php.', 'wpbr-bulk-replace')));
}
}
function wpbr_diagnostic_url($action, $args = array()) {
$args['action'] = $action;
$url = add_query_arg($args, admin_url('admin-post.php'));
return wp_nonce_url($url, $action, 'wpbr_diag_nonce');
}
add_action('admin_post_wpbr_debug_row', 'wpbr_render_debug_row_page');
/*
* The two diagnostic endpoints answer text/plain, and say so in one place.
*
* A diagnostic report is a dump: an admin reads it beside PHP's and the
* server's own output and pastes it into a support thread, so escaping it would
* corrupt the one thing it exists to be. Both endpoints used to switch the
* escaping sniffs off for their whole bodies. They now build the report as a
* string, and this is the only function that sends one: it sets text/plain with
* nosniff itself, so what makes the unescaped echo safe - no browser reads the
* response as HTML - is true by construction, and there is exactly one line to
* hold to account. tests/integration/DiagnosticReportTest.php.
*/
function wpbr_send_plain_report($text) {
header('Content-Type: text/plain; charset=utf-8');
header('X-Content-Type-Options: nosniff');
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Sent as text/plain with nosniff: no browser reads it as HTML, and escaping would corrupt a dump meant to be copied exactly.
echo $text;
exit;
}
/** A stored value as PHP holds it, quotes and all, for the dump. */
function wpbr_plain_dump_value($value) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export -- The dump shows a stored value exactly as PHP holds it, which is what var_export() prints; nothing is logged.
return var_export($value, true);
}
/** The text of the row-search dump for a filename fragment and the rows it found. */
function wpbr_debug_row_report($filename, $rows) {
$out = "Query: staged_filename LIKE '%" . $filename . "%'\n\n";
if (!$rows) {
$out .= "No rows found.\n";
}
foreach ($rows as $r) {
foreach ($r as $key => $val) {
$out .= str_pad($key, 22) . ": " . wpbr_plain_dump_value($val) . "\n";
}
$out .= str_repeat('-', 40) . "\n";
}
return $out;
}
function wpbr_render_debug_row_page() {
wpbr_guard_diagnostic();
$link_nonce = isset($_GET['wpbr_diag_nonce']) ? sanitize_text_field(wp_unslash($_GET['wpbr_diag_nonce'])) : '';
if (!wp_verify_nonce($link_nonce, 'wpbr_debug_row')) {
wp_die(esc_html__('Invalid or missing security token. Open diagnostics from the Settings page so the link is signed.', 'wpbr-bulk-replace'));
}
global $wpdb;
$filename = isset($_GET['filename']) ? sanitize_text_field(wp_unslash($_GET['filename'])) : '';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a diagnostic that reports the table as it is right now, so it is never cached.
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT id, batch_id, staged_filename, matched_post_id, matched_post_title, confidence_score, rename_to, status, notes FROM {$wpdb->prefix}wpbr_batch_items WHERE staged_filename LIKE %s",
'%' . $wpdb->esc_like($filename) . '%'
));
wpbr_send_plain_report(wpbr_debug_row_report($filename, $rows));
}
/**
* Append one line to the activity log.
* $result is one of: success, skipped, failed, info
*/
function wpbr_log($batch_id, $item_id, $filename, $action, $result, $message) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Plugin-owned table with no WordPress API; an insert, so there is no cached read to invalidate.
$wpdb->insert(wpbr_table_log(), array(
'batch_id' => intval($batch_id),
'item_id' => $item_id ? intval($item_id) : null,
'filename' => $filename ? substr($filename, 0, 255) : null,
'action' => $action,
'result' => $result,
'message' => $message,
'created_at' => current_time('mysql'),
'created_by' => get_current_user_id(),
) + (wpbr_actor_available() && wpbr_acting_as() !== '' ? array('actor' => wpbr_acting_as()) : array()));
}
/** Work out success/skipped/failed from one of our result strings. */
add_action('admin_post_wpbr_preview', 'wpbr_render_preview_page');
function wpbr_render_preview_page() {
if (!current_user_can('manage_options')) wp_die(esc_html__('Not allowed', 'wpbr-bulk-replace'));
$item_id = isset($_GET['item_id']) ? intval($_GET['item_id']) : 0;
if (!$item_id || !isset($_GET['wpbr_preview_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['wpbr_preview_nonce'])), 'wpbr_preview_' . $item_id)) {
wp_die(esc_html__('Invalid or missing security token.', 'wpbr-bulk-replace'));
}
global $wpdb;
$item = wpbr_get_item($item_id);
if (!$item) {
wp_die(esc_html__('File not found.', 'wpbr-bulk-replace'));
}
$which = (isset($_GET['which']) && sanitize_key(wp_unslash($_GET['which'])) === 'backup') ? 'backup' : 'staged';
if ($which === 'backup') {
$path = $item->backup_path;
if (!$path || !file_exists($path)) {
wp_die(esc_html__('No backup on record for this item, or it has already been deleted.', 'wpbr-bulk-replace'));
}
// never serve anything outside the backups folder
if (!wpbr_path_in_storage_roots($path)) {
wp_die(esc_html__('Refused: that path is outside the backups folder.', 'wpbr-bulk-replace'));
}
$path = realpath($path);
$download_name = 'BACKUP-' . basename($path);
} else {
$path = $item->staged_path;
if (!$path || !file_exists($path)) {
wp_die(esc_html__('File not found.', 'wpbr-bulk-replace'));
}
$download_name = basename($item->staged_filename);
}
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$mime_types = array(
'pdf' => 'application/pdf',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
);
$mime = isset($mime_types[$ext]) ? $mime_types[$ext] : 'application/octet-stream';
$disposition = ($ext === 'pdf') ? 'inline' : 'attachment';
nocache_headers();
header('Content-Type: ' . $mime);
header('X-Content-Type-Options: nosniff');
header('Content-Disposition: ' . $disposition . '; filename="' . $download_name . '"');
header('Content-Length: ' . filesize($path));
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Streams a backup to the browser; WP_Filesystem reads whole files into memory and cannot stream.
readfile($path);
exit;
}
function wpbr_preview_url($item_id, $which = 'staged') {
$url = admin_url('admin-post.php?action=wpbr_preview&item_id=' . $item_id);
if ($which === 'backup') $url .= '&which=backup';
return wp_nonce_url($url, 'wpbr_preview_' . $item_id, 'wpbr_preview_nonce');
}
add_action('wp_ajax_wpbr_meta_field_values', 'wpbr_ajax_meta_field_values');
function wpbr_ajax_meta_field_values() {
if (!current_user_can('manage_options')) {
wp_send_json_error(__('Not allowed', 'wpbr-bulk-replace'));
}
check_ajax_referer('wpbr_meta_field_values', 'nonce');
global $wpdb;
$batch_id = isset($_POST['batch_id']) ? intval($_POST['batch_id']) : 0;
$meta_key = isset($_POST['field']) ? sanitize_text_field(wp_unslash($_POST['field'])) : '';
if (!$batch_id || !$meta_key) {
wp_send_json_error(__('Missing batch or field', 'wpbr-bulk-replace'));
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Core's postmeta has no API for one field's values across a set of posts; the panel shows values the person may have just edited, so it is not cached.
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT pm.post_id, pm.meta_value
FROM ".$wpdb->postmeta." pm
WHERE pm.meta_key = %s
AND pm.post_id IN (
SELECT DISTINCT matched_post_id FROM {$wpdb->prefix}wpbr_batch_items
WHERE batch_id = %d AND matched_post_id > 0
)",
$meta_key, $batch_id
));
$map = array();
$values = array();
foreach ($rows as $r) {
$val = wpbr_normalize_meta_value($r->meta_value);
if ($val === '') continue;
$map[$r->post_id] = $val;
$values[$val] = true;
}
$values = array_keys($values);
sort($values);
$labelled = array();
foreach ($values as $v) {
$labelled[] = array('value' => $v, 'label' => wpbr_humanize_meta_value($v));
}
usort($labelled, function($a, $b) { return strcasecmp($a['label'], $b['label']); });
wp_send_json_success(array(
'values' => $labelled,
'map' => $map,
));
}
function wpbr_humanize_meta_value($value) {
$value = (string) $value;
if ($value === '') return '';
// Already looks like a human label (spaces, capitals, punctuation) - leave alone.
if (preg_match('/[A-Z]/', $value) || strpos($value, ' ') !== false) {
return $value;
}
// Slug that matches a real taxonomy term? Use the term's name.
$taxonomies = get_taxonomies(array(), 'names');
foreach ($taxonomies as $tax) {
$term = get_term_by('slug', $value, $tax);
if ($term && !is_wp_error($term) && $term->name) {
return $term->name;
}
}
// Otherwise prettify the slug.
$words = preg_split('/[-_]+/', $value);
$small = array('of','and','the','for','in','on','at','to','a','an','or','with');
$out = array();
foreach ($words as $i => $w) {
if ($w === '') continue;
$lw = strtolower($w);
if (strlen($w) <= 3 && strtoupper($w) === $w) { $out[] = strtoupper($w); continue; }
if ($i > 0 && in_array($lw, $small, true)) { $out[] = $lw; continue; }
$out[] = ucfirst($lw);
}
return implode(' ', $out);
}
function wpbr_normalize_meta_value($raw) {
$val = (string) $raw;
if (strlen($val) > 120) return '';
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- This is the guard, not a use: a length-capped probe whose only purpose is to notice serialized input and refuse it.
$unserialized = @unserialize($val);
if ($unserialized !== false || $val === 'b:0;') {
if (is_array($unserialized)) return '';
if (is_scalar($unserialized)) $val = (string) $unserialized;
}
$val = trim($val);
$val = trim($val, '"\'');
if (strpos($val, ',') !== false) return '';
return trim($val);
}
add_action('wp_ajax_wpbr_confirm_new_attach', 'wpbr_ajax_confirm_new_attach');
function wpbr_ajax_confirm_new_attach() {
if (!current_user_can('manage_options')) {
wp_send_json_error(__('Not allowed', 'wpbr-bulk-replace'));
}
check_ajax_referer('wpbr_confirm_new_attach', 'nonce');
$item_id = isset($_POST['item_id']) ? intval($_POST['item_id']) : 0;
if (!$item_id) {
wp_send_json_error(__('Missing item ID', 'wpbr-bulk-replace'));
}
$confirmed = wpbr_confirm_new_attach($item_id);
if (is_wp_error($confirmed)) {
wp_send_json_error($confirmed->get_error_message());
}
wp_send_json_success();
}
add_action('wp_ajax_wpbr_compute_rename', 'wpbr_ajax_compute_rename');
function wpbr_ajax_compute_rename() {
if (!current_user_can('manage_options')) {
wp_send_json_error(__('Not allowed', 'wpbr-bulk-replace'));
}
check_ajax_referer('wpbr_compute_rename', 'nonce');
$batch_id = isset($_POST['batch_id']) ? intval($_POST['batch_id']) : 0;
$post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
$original_filename = isset($_POST['original_filename']) ? sanitize_file_name(wp_unslash($_POST['original_filename'])) : '';
$template = isset($_POST['template']) ? trim(sanitize_text_field(wp_unslash($_POST['template']))) : '';
if (!$post_id || !$original_filename) {
wp_send_json_success(array('rename_to' => ''));
}
$ext = strtolower(pathinfo($original_filename, PATHINFO_EXTENSION));
global $wpdb;
$batch = wpbr_get_batch($batch_id);
$old_filename = '';
$has_existing_file = false;
if ($batch && $batch->meta_key_name) {
$current = wpbr_reference_current($batch, $post_id);
if ($current['found']) {
$old_filename = basename(wp_parse_url($current['value'], PHP_URL_PATH));
$has_existing_file = true;
}
}
if ($template !== '') {
$rename_to = wpbr_apply_rename_template($template, $post_id, $original_filename, $ext);
} else {
$rename_to = wpbr_suggested_filename($post_id, $original_filename, $old_filename);
}
wp_send_json_success(array(
'rename_to' => $rename_to,
'has_existing_file' => $has_existing_file,
));
}
/**
* Judge one uploaded file and move it into a folder, under a name nobody is using.
*
* The whole of what "accepting a file" means in this plugin, in one place: the file must be
* one whole upload that PHP received without error, its extension must be on the allowed
* list, its CONTENTS must agree with that extension, and it is then moved by WordPress's
* own handler, which confirms it arrived over HTTP, refuses an empty file and never reuses
* a name. A batch's staging folder and a network job's staging folder both use it, so a
* file is judged the same way wherever it is uploaded.
*
* @param mixed $file One entry of $_FILES: name, tmp_name, error, size, each a single value.
* @param string $dir Destination folder. Created and protected here.
* @param bool $sideload True for a file that did not arrive over HTTP (a vetted temporary copy): WordPress's sideload handler
* copies it, where the upload handler would refuse anything PHP did not itself receive.
* @return array `ok` true with `file` (the stored basename), `original` (the sanitised name
* that was sent) and `path`; or `ok` false with an `error` already worded.
*/
function wpbr_receive_upload($file, $dir, $sideload = false) {
// One file, whole: all three entries present, as strings (a "file[]" upload
// would make them arrays), and PHP reporting no error.
if (!is_array($file) || !isset($file['name'], $file['tmp_name'], $file['error'])
|| !is_string($file['name']) || !is_string($file['tmp_name'])
|| $file['error'] !== UPLOAD_ERR_OK) {
return array('ok' => false, 'error' => __('Upload failed for this file.', 'wpbr-bulk-replace'));
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- tmp_name is the path PHP itself chose for this upload, not text the browser sent; it is only handed to wp_check_filetype_and_ext() and wp_handle_upload(), and the latter confirms it arrived over HTTP.
$tmp_name = $file['tmp_name'];
$original_name = sanitize_file_name(wp_unslash($file['name']));
$ext = strtolower(pathinfo($original_name, PATHINFO_EXTENSION));
$allowed = apply_filters('wpbr_allowed_extensions', array('pdf', 'doc', 'docx'));
if (!in_array($ext, $allowed, true)) {
return array('ok' => false, 'error' => sprintf(
/* translators: 1: a filename, 2: a list of allowed file extensions, e.g. "PDF/DOC/DOCX". */
__('%1$s — rejected: only %2$s files are accepted', 'wpbr-bulk-replace'),
$original_name,
strtoupper(implode('/', $allowed))
),);
}
// Extension alone is not evidence of type - check the actual contents.
$checked = wp_check_filetype_and_ext($tmp_name, $original_name);
if (empty($checked['ext']) || empty($checked['type'])
|| !in_array(strtolower($checked['ext']), $allowed, true)) {
return array('ok' => false, 'error' => sprintf(
/* translators: %s is a filename. */
__('%s — rejected: the file contents do not match its extension', 'wpbr-bulk-replace'),
$original_name
),);
}
wp_mkdir_p($dir);
wpbr_protect_dir($dir);
// A replacement that has not been published yet is a copy of a document too. No upload is accepted into a
// staging folder that is readable from the web, or that cannot be shown not to be. A network job's folder
// is judged by the main site's staging folder that holds it.
$gate = wpbr_privacy_gate('staging', array('max_age' => 600, 'dir' => wpbr_privacy_root_for($dir), 'files' => array($original_name)));
if (!$gate['allow']) {
return array('ok' => false, 'error' => $gate['message']);
}
// The move itself is WordPress's: wp_handle_upload() confirms the file
// really arrived over HTTP, refuses an empty one, never reuses a name
// already on disk - two rows sharing one staged_path would mean the wrong
// file could be written over a live document - and sets the permissions.
// The type and extension were judged above, against this plugin's own
// list, so core's second opinion on the type is switched off rather than
// allowed to disagree with it.
if (!function_exists('wp_handle_upload')) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
$to_staging = static function ($dirs) use ($dir) {
$dirs['path'] = $dir;
$dirs['subdir'] = '';
$dirs['url'] = $dirs['baseurl'];
$dirs['error'] = false;
return $dirs;
};
// Last in line, so no other plugin's idea of where uploads go wins, and
// removed again straight away so nothing else this request uploads is
// redirected into staging.
add_filter('upload_dir', $to_staging, PHP_INT_MAX);
// Taken by reference, so it has to be a variable.
$upload = array(
'name' => $original_name,
'type' => $checked['type'],
'tmp_name' => $tmp_name,
'error' => UPLOAD_ERR_OK,
'size' => isset($file['size']) ? (int) $file['size'] : 0,
);
try {
$moved = $sideload
? wp_handle_sideload($upload, array('test_form' => false, 'test_type' => false))
: wp_handle_upload($upload, array('test_form' => false, 'test_type' => false));
} finally {
remove_filter('upload_dir', $to_staging, PHP_INT_MAX);
}
if (!is_array($moved) || !empty($moved['error']) || empty($moved['file'])) {
return array('ok' => false, 'error' => sprintf(
/* translators: %s is a filename. */
__('%s — could not be written to the staging folder', 'wpbr-bulk-replace'),
$original_name
),);
}
$unique_name = basename($moved['file']);
return array('ok' => true, 'file' => $unique_name, 'original' => $original_name, 'path' => rtrim($dir, '/') . '/' . $unique_name);
}
add_action('wp_ajax_wpbr_upload_file', 'wpbr_ajax_upload_file');
function wpbr_ajax_upload_file() {
if (!current_user_can('manage_options')) {
wp_send_json_error(__('Not allowed', 'wpbr-bulk-replace'));
}
check_ajax_referer('wpbr_upload_file', 'nonce');
$batch_id = isset($_POST['batch_id']) ? intval($_POST['batch_id']) : 0;
if (!$batch_id) {
wp_send_json_error(__('Missing batch ID', 'wpbr-bulk-replace'));
}
// The batch is checked before anything is written (inside wpbr_stage_file): batch_id is a foreign key read straight off the
// request, and a row against a batch that does not exist is invisible on every screen.
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- The whole entry goes to wpbr_receive_upload(), which validates every field it reads and sanitises the name.
$staged = wpbr_stage_file($batch_id, isset($_FILES['file']) ? $_FILES['file'] : null);
if (!$staged['ok']) {
wp_send_json_error($staged['error']);
}
$original_name = $staged['original'];
$unique_name = $staged['file'];
$response = array('filename' => $unique_name);
if ($unique_name !== $original_name) {
$response['renamed_from'] = $original_name;
$response['notice'] = sprintf(
/* translators: 1: the uploaded filename, 2: the name it was stored under. */
__('%1$s already existed in staging, so it was stored as %2$s', 'wpbr-bulk-replace'),
$original_name,
$unique_name
);
}
wp_send_json_success($response);
}
add_action('wp_ajax_wpbr_process_chunk', 'wpbr_ajax_process_chunk');
function wpbr_ajax_process_chunk() {
if (!current_user_can('manage_options')) {
wp_send_json_error(__('Not allowed', 'wpbr-bulk-replace'));
}
$batch_id = isset($_POST['batch_id']) ? intval($_POST['batch_id']) : 0;
check_ajax_referer('wpbr_process_chunk_' . $batch_id, 'nonce');
$ids = isset($_POST['ids']) ? array_map('intval', explode(',', sanitize_text_field(wp_unslash($_POST['ids'])))) : array();
$ids = array_filter($ids);
$result = wpbr_process_backup_replace_chunk($batch_id, wpbr_chunk_size(), $ids);
wp_send_json_success($result);
}
add_action('wp_ajax_wpbr_rollback_chunk', 'wpbr_ajax_rollback_chunk');
function wpbr_ajax_rollback_chunk() {
if (!current_user_can('manage_options')) {
wp_send_json_error(__('Not allowed', 'wpbr-bulk-replace'));
}
$batch_id = isset($_POST['batch_id']) ? intval($_POST['batch_id']) : 0;
check_ajax_referer('wpbr_process_chunk_' . $batch_id, 'nonce');
$ids = isset($_POST['ids']) ? array_map('intval', explode(',', sanitize_text_field(wp_unslash($_POST['ids'])))) : array();
$ids = array_filter($ids);
wp_send_json_success(wpbr_rollback_chunk($batch_id, $ids));
}
function wpbr_rollback_chunk($batch_id, $ids) {
wpbr_ensure_schema();
$results = array();
$outcomes = array();
global $wpdb;
$items_table = wpbr_table_items();
$batch = wpbr_get_batch($batch_id);
if (!$batch || empty($ids)) {
return array('log' => array(__('Nothing to roll back.', 'wpbr-bulk-replace')), 'processed' => 0, 'outcomes' => array(), 'chunk_outcome' => 'nothing_to_roll_back');
}
$items = wpbr_get_batch_items_in($batch_id, $ids);
$log = array();
$done = 0;
$meta_keys = wpbr_reference_locations($batch);
$target_meta_key = isset($meta_keys[0]) ? $meta_keys[0] : '';
$halted = '';
foreach ($items as $item) {
if ($item->status === 'needs_attention') {
$log[] = wpbr_run_log_line($item->staged_filename, sprintf(
/* translators: %s is the path of the backup. */
__('skipped: this row is waiting for attention because its file could not be put back. Its original is kept at %s. Nothing was changed.', 'wpbr-bulk-replace'),
(string) $item->backup_path
));
$outcomes[] = wpbr_outcome_entry($item, 'skipped_needs_attention');
wpbr_record_outcome($item->id, 'skipped_needs_attention');
continue;
}
if (!in_array($item->status, array('replaced', 'verified'), true)) {
$log[] = wpbr_run_log_line($item->staged_filename, __('skipped, not in a replaced state', 'wpbr-bulk-replace'));
$outcomes[] = wpbr_outcome_entry($item, 'skipped_not_replaced');
wpbr_record_outcome($item->id, 'skipped_not_replaced');
continue;
}
if ($item->created_attachment_id) {
wp_delete_attachment($item->created_attachment_id, true);
if ($item->previous_meta_value) {
// A rename-on-replace or an unshare: either way the plugin
// created a file and repointed the post at it, and the post
// goes back on the file it referenced before.
// THE LOCATION IS RE-DERIVED, NOT REMEMBERED, which is what this
// has always done: rollback asks the same question a write asks
// - the first configured location with a value, else the first
// configured - and writes there.
$restore_key = wpbr_reference_restore(
$batch, $item->matched_post_id, $item->previous_meta_value
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate.
$wpdb->update($items_table, array(
'status' => 'matched',
'created_attachment_id' => null,
'previous_meta_value' => null,
'processed_at' => null,
'notes' => trim(($item->notes ? $item->notes.' | ' : '') . sprintf(
/* translators: 1: a meta field name, 2: the file URL it was pointed back at. */
__('Rolled back: the file this plugin created was removed and “%1$s” pointed back at %2$s', 'wpbr-bulk-replace'),
$restore_key, $item->previous_meta_value
)),
), array('id' => $item->id));
$log[] = wpbr_run_log_line($item->staged_filename, __('rolled back: the created file was removed and the post points at its original file again', 'wpbr-bulk-replace'));
$outcomes[] = wpbr_outcome_entry($item, 'rolled_back_repointed');
wpbr_record_outcome($item->id, 'rolled_back_repointed');
$results[] = array('id' => (int) $item->id, 'filename' => $item->staged_filename, 'result' => 'success', 'message' => __('rolled back', 'wpbr-bulk-replace'));
wpbr_log($batch_id, $item->id, $item->staged_filename, 'rollback', 'success', sprintf(
/* translators: %s is a file URL. */
__('Created file removed, meta restored to %s', 'wpbr-bulk-replace'),
$item->previous_meta_value
));
$done++;
continue;
}
if ($target_meta_key && $item->matched_post_id) {
wpbr_reference_forget($batch, $item->matched_post_id);
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate.
$wpdb->update($items_table, array(
'status' => 'confirmed_new_attach',
'created_attachment_id' => null,
'processed_at' => null,
'notes' => trim(($item->notes ? $item->notes.' | ' : '') . sprintf(
/* translators: %s is a Media Library attachment ID. */
__('Rolled back: attachment #%s deleted and meta cleared', 'wpbr-bulk-replace'),
$item->created_attachment_id
)),
), array('id' => $item->id));
$log[] = wpbr_run_log_line($item->staged_filename, __('rolled back: new attachment removed and field cleared', 'wpbr-bulk-replace'));
$outcomes[] = wpbr_outcome_entry($item, 'rolled_back_attachment_removed');
wpbr_record_outcome($item->id, 'rolled_back_attachment_removed');
$results[] = array('id' => (int) $item->id, 'filename' => $item->staged_filename, 'result' => 'success', 'message' => __('rolled back', 'wpbr-bulk-replace'));
wpbr_log($batch_id, $item->id, $item->staged_filename, 'rollback', 'success', __('Created attachment deleted and field cleared', 'wpbr-bulk-replace'));
$done++;
continue;
}
if (!$item->backup_path || !wpbr_backup_available($item)) {
$log[] = wpbr_run_log_line($item->staged_filename, __('cannot be rolled back: backup file is missing', 'wpbr-bulk-replace'));
$outcomes[] = wpbr_outcome_entry($item, 'unavailable_backup_missing');
wpbr_record_outcome($item->id, 'unavailable_backup_missing');
continue;
}
// The same check wpbr_ajax_delete_backups() makes before unlinking this
// value, for the same reason: backup_path is a stored string, and this
// is the one place it is read and its contents written somewhere the
// web can reach. Restoring is a copy out of the backups folder into
// uploads, so a path that is not in the backups folder is not a backup
// and its contents are not ours to publish.
if (!wpbr_path_in_storage_roots($item->backup_path)) {
$log[] = wpbr_run_log_line($item->staged_filename, __('REFUSED, the recorded backup is outside the backups folder', 'wpbr-bulk-replace'));
$outcomes[] = wpbr_outcome_entry($item, 'refused_backup_outside_folder');
wpbr_record_outcome($item->id, 'refused_backup_outside_folder');
continue;
}
$file_url = wpbr_reference_current($batch, $item->matched_post_id)['value'];
if (!$file_url) {
$log[] = wpbr_run_log_line($item->staged_filename, __('cannot be rolled back: no live file recorded on the post', 'wpbr-bulk-replace'));
$outcomes[] = wpbr_outcome_entry($item, 'unavailable_no_live_reference');
wpbr_record_outcome($item->id, 'unavailable_no_live_reference');
continue;
}
$live_path = wpbr_url_to_upload_path($file_url);
if ($live_path && wpbr_ref_is_stream_uri($live_path)) {
// Host-managed storage: the adapter that owns the reference, with the guards the local path does not need.
$remote = wpbr_rollback_remote_item($item, $batch, $live_path, $file_url);
$log[] = wpbr_run_log_line($item->staged_filename, $remote['message']);
$outcomes[] = wpbr_outcome_entry($item, $remote['outcome']);
wpbr_record_outcome($item->id, $remote['outcome']);
if ($remote['outcome'] === 'rolled_back') {
$results[] = array('id' => (int) $item->id, 'filename' => $item->staged_filename, 'result' => 'success', 'message' => __('rolled back', 'wpbr-bulk-replace'));
wpbr_log($batch_id, $item->id, $item->staged_filename, 'rollback', 'success', __('Previous file restored from backup', 'wpbr-bulk-replace'));
$done++;
} elseif (in_array($remote['outcome'], wpbr_halting_outcomes(), true)) {
$halted = $remote['outcome'];
break;
}
continue;
}
if (!$live_path || !wpbr_path_is_in_uploads($live_path)) {
$log[] = wpbr_run_log_line($item->staged_filename, sprintf(
/* translators: %s is a stored file URL. */
__('cannot be rolled back: could not resolve “%s” to a file inside the uploads folder', 'wpbr-bulk-replace'),
$file_url
));
$outcomes[] = wpbr_outcome_entry($item, 'unavailable_path_unresolvable');
wpbr_record_outcome($item->id, 'unavailable_path_unresolvable');
continue;
}
if (!empty($item->original_sha256) && wpbr_file_sha256($item->backup_path) !== $item->original_sha256) {
$log[] = wpbr_run_log_line($item->staged_filename, __('cannot be rolled back: the backup no longer matches the file it was taken from, so it was not put back. Nothing was changed.', 'wpbr-bulk-replace'));
$outcomes[] = wpbr_outcome_entry($item, 'unavailable_backup_changed');
wpbr_record_outcome($item->id, 'unavailable_backup_changed');
continue;
}
$restored = wpbr_local_adapter_write($item->backup_path, $live_path);
if ($restored['stage'] === 'busy') {
$log[] = wpbr_run_log_line($item->staged_filename, __('skipped: another request is writing this file right now. Nothing was changed; roll this row back again in a moment.', 'wpbr-bulk-replace'));
$outcomes[] = wpbr_outcome_entry($item, 'skipped_file_busy');
wpbr_record_outcome($item->id, 'skipped_file_busy');
continue;
}
if ($restored['stage'] === 'lock_unavailable') {
$log[] = wpbr_run_log_line($item->staged_filename, wpbr_lock_unavailable_message());
$outcomes[] = wpbr_outcome_entry($item, 'blocked_lock_unavailable');
wpbr_record_outcome($item->id, 'blocked_lock_unavailable');
continue;
}
if ($restored['stage'] === 'dir_not_writable') {
$log[] = wpbr_run_log_line($item->staged_filename, sprintf(
/* translators: %s is a folder path. */
__('blocked: WordPress cannot write to the folder %s, so the backup cannot be put back safely. Nothing was changed and the backup is kept. Make that folder writable for the web server, or ask your host to, then roll this row back again.', 'wpbr-bulk-replace'),
dirname($live_path)
));
$outcomes[] = wpbr_outcome_entry($item, 'blocked_directory_not_writable');
wpbr_record_outcome($item->id, 'blocked_directory_not_writable');
continue;
}
if ($restored['stage'] === 'temp_failed') {
$log[] = wpbr_run_log_line($item->staged_filename, __('failed: restoring backup', 'wpbr-bulk-replace'));
$outcomes[] = wpbr_outcome_entry($item, 'failed_restore_copy');
wpbr_record_outcome($item->id, 'failed_restore_copy');
continue;
}
if ($restored['stage'] === 'unverified') {
// The restored file did not read back as the backup, so the live file may be damaged.
// One more verified attempt from the same backup; if that does not give a live file that
// hashes to the original, the backup is protected and the row waits for attention.
$retry = wpbr_restore_original($item, $live_path, $item->backup_path);
if ($retry['state'] !== 'restored') {
$backup_ok = $retry['state'] === 'restore_failed';
$held = wpbr_mark_needs_attention($item, $live_path, $item->backup_path, $backup_ok ? 'rollback_restore_failed' : 'backup_unverified', $retry['backup_sha256']);
$code = $backup_ok ? 'needs_attention_restore_failed' : 'needs_attention_backup_unverified';
$message = wpbr_needs_attention_message($item, $live_path, $item->backup_path, $backup_ok, $held['marker'], $held['checked_at']);
$log[] = wpbr_run_log_line($item->staged_filename, $message);
$outcomes[] = wpbr_outcome_entry($item, $code);
wpbr_record_outcome($item->id, $code);
wpbr_log($batch_id, $item->id, $item->staged_filename, 'rollback', 'failed', $message);
$halted = $code;
break;
}
}
// The live file is now proved to be the backup, which is the original: whatever protected that backup
// (a failed restore that was later finished here) is no longer needed.
if (!empty($item->original_sha256) && is_file(wpbr_protection_sidecar_path($item->backup_path))) {
@unlink(wpbr_protection_sidecar_path($item->backup_path)); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Releasing the marker after the live file is proved to be the original.
}
// The file at this URL just changed back. A browser that was shown the
// replacement must be given a link it has not seen.
wpbr_asset_version_register($live_path);
$attach_id = attachment_url_to_postid(wpbr_url_without_decoration($file_url));
if ($attach_id) {
require_once ABSPATH . 'wp-admin/includes/image.php';
$meta = wp_generate_attachment_metadata($attach_id, $live_path);
if ($meta) wp_update_attachment_metadata($attach_id, $meta);
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate.
$wpdb->update($items_table, array(
'status' => 'matched',
'processed_at' => null,
'notes' => trim(($item->notes ? $item->notes.' | ' : '') . __('Rolled back: previous file restored from backup', 'wpbr-bulk-replace')),
) + wpbr_intent_cleared(), array('id' => $item->id));
$log[] = wpbr_run_log_line($item->staged_filename, __('rolled back: previous file restored', 'wpbr-bulk-replace'));
$outcomes[] = wpbr_outcome_entry($item, 'rolled_back');
wpbr_record_outcome($item->id, 'rolled_back');
$results[] = array('id' => (int) $item->id, 'filename' => $item->staged_filename, 'result' => 'success', 'message' => __('rolled back', 'wpbr-bulk-replace'));
wpbr_log($batch_id, $item->id, $item->staged_filename, 'rollback', 'success', __('Previous file restored from backup', 'wpbr-bulk-replace'));
$done++;
}
$return = array('log' => $log, 'processed' => $done, 'results' => $results, 'outcomes' => $outcomes);
if ($halted !== '') {
$return['halted'] = $halted;
}
return $return;
}
function wpbr_process_backup_replace_chunk($batch_id, $limit, $ids = array()) {
global $wpdb;
wpbr_ensure_schema();
$items_table = wpbr_table_items();
$batch = wpbr_get_batch($batch_id);
$log = array();
if (!$batch) {
return array('log' => array(__('Batch not found.', 'wpbr-bulk-replace')), 'processed' => 0, 'remaining' => 0, 'outcomes' => array(), 'chunk_outcome' => 'batch_not_found');
}
// A batch with a row waiting for attention does not run on: that failure is usually the disk or
// the mount, and the next row would meet the same thing. remaining is 0 so the browser's loop ends
// here, as it does for unresolved conflicts, instead of asking again.
if (wpbr_batch_needs_attention_count($batch_id)) {
return array(
'log' => array(__('Stopped: a file in this batch could not be put back after a failed replacement and is waiting for attention. Its original is safe in the backups folder. Nothing further will run in this batch until that is dealt with.', 'wpbr-bulk-replace')),
'processed' => 0,
'remaining' => 0,
'outcomes' => array(),
'chunk_outcome' => 'blocked_needs_attention',
);
}
$all_items = wpbr_get_batch_items($batch_id);
// Every chunk, not only the one the browser labelled first. The check used
// to run only when the request carried first=1, which made the guard on the
// most destructive thing this plugin does something the caller decided to
// have applied: a request without the flag went straight past it. It costs
// one pass over rows already in memory, and that pass produces the
// shared-destination set the loop below needs anyway.
$collisions = wpbr_detect_collisions($batch_id, $all_items, $batch);
$shared_paths = $collisions['shared_paths'];
if ($collisions['blocking']) {
return array(
'log' => array(sprintf(
/* translators: %s is a number of conflicts. */
_n('Blocked: %s unresolved conflict where more than one file is matched to the same post. Resolve it on the Review & Resolve step first.',
'Blocked: %s unresolved conflicts where more than one file is matched to the same post. Resolve them on the Review & Resolve step first.',
count($collisions['blocking']), 'wpbr-bulk-replace'),
number_format_i18n(count($collisions['blocking']))
),),
'processed' => 0,
'remaining' => 0,
'outcomes' => array(),
'chunk_outcome' => 'blocked_unresolved_conflicts',
);
}
if (!empty($ids)) {
$ids = array_slice($ids, 0, $limit);
// Cleaned here, after the slice, and not before the `if`: a list of only
// unusable ids is still a list somebody sent, so it selects nothing; it
// must not fall through to the branch below that takes the next chunk.
$ids = wpbr_clean_ids($ids);
$items = array();
if ($ids) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; this read decides what the next write does, so it is never cached (docs/PLUGIN-CHECK.md).
$items = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}wpbr_batch_items WHERE batch_id = %d AND id IN (" . implode(',', array_fill(0, count($ids), '%d')) . ")
AND status IN ('matched','confirmed_new_attach') ORDER BY id ASC",
array_merge(array($batch_id), $ids)
));
}
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; this read decides what the next write does, so it is never cached (docs/PLUGIN-CHECK.md).
$items = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}wpbr_batch_items WHERE batch_id = %d AND status IN ('matched','confirmed_new_attach') ORDER BY id ASC LIMIT %d",
$batch_id, $limit
));
}
$processed = 0;
$results = array();
$outcomes = array();
$halted = '';
// A private location is checked for room for EVERY backup still to make in the batch (the remaining files, the temporary
// copy and a margin), before each row, from the free space as it is now. A site without one is unchanged.
$remaining_sizes = function_exists('wpbr_storage_active_dir') && wpbr_storage_active_dir() !== '' ? wpbr_storage_remaining_backup_sizes($batch, $all_items) : array();
foreach ($items as $item) {
if ($remaining_sizes) {
$refusal = wpbr_storage_batch_space_refusal($remaining_sizes);
if ($refusal) {
$log[] = sprintf(
/* translators: %s is how much space is needed and how much is free. */
__('Stopped: the backups folder does not have room for the backups this batch still has to make (%s). Nothing further was written; free some space or move the folder, then run the remaining rows.', 'wpbr-bulk-replace'),
$refusal['detail']
);
return array('log' => $log, 'processed' => $processed, 'remaining' => 0, 'results' => $results, 'outcomes' => $outcomes, 'chunk_outcome' => 'blocked_insufficient_space');
}
}
$outcome = wpbr_process_one_item($item, $batch, $all_items, $shared_paths);
unset($remaining_sizes[(int) $item->id]);
$line = $outcome['line'];
$log[] = $line;
$processed++;
// Record anything that didn't succeed against the row itself, so a
// failure is still discoverable after the log pane is gone.
$result = $outcome['code'];
$reason = $outcome['reason'];
$results[] = array(
'id' => (int) $item->id,
'filename' => $item->staged_filename,
'result' => $result,
'message' => $reason,
'outcome' => $outcome['outcome'],
);
$outcomes[] = wpbr_outcome_entry($item, $outcome['outcome']);
wpbr_record_outcome($item->id, $outcome['outcome']);
wpbr_log($batch_id, $item->id, $item->staged_filename, 'backup_replace', $result, $reason);
if ($result !== 'success') {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate.
$wpdb->update($items_table, array(
'notes' => trim(($item->notes ? $item->notes.' | ' : '').'['.current_time('mysql').'] '.$reason),
), array('id' => $item->id));
}
// Circuit breaker: a failed overwrite is usually systemic, so no further row starts in this
// request. The rows not reached stay `matched`.
if (in_array($outcome['outcome'], wpbr_halting_outcomes(), true)) {
$halted = $outcome['outcome'];
break;
}
}
if (empty($items)) {
$log[] = __('Nothing matched the selection — the rows may have already been processed, or their status changed.', 'wpbr-bulk-replace');
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; this read decides what the next write does, so it is never cached (docs/PLUGIN-CHECK.md).
$remaining = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->prefix}wpbr_batch_items WHERE batch_id = %d AND status IN ('matched','confirmed_new_attach')",
$batch_id
));
$return = array('log' => $log, 'processed' => $processed, 'remaining' => $remaining, 'results' => $results, 'outcomes' => $outcomes);
if ($halted !== '') {
$return['halted'] = $halted;
}
return $return;
}
/**
* One line of the run log: which file, then what happened to it.
*
* Every call site used to build this by hand as $filename . " - REASON", which
* hard-codes the order of the two parts. Composing it here gives a translator
* one sentence shape plus short reasons they can place freely.
*/
function wpbr_run_log_line($filename, $outcome) {
return sprintf(
/* translators: 1: a filename, 2: what happened to that file. */
__('%1$s — %2$s', 'wpbr-bulk-replace'),
$filename,
$outcome
);
}
/**
* Every way one row can end, as a stable machine code.
*
* `result` (success, skipped, failed) says how it went; `outcome` says exactly
* what happened, and is what a caller that has to decide something branches on.
* The sentence shown to the reader is translated and can be reworded in any
* release, so no decision may be taken from it. The Multisite coordinator used
* to: it searched the engine's English prose for "cannot be rolled back".
*
* THE SET IS CLOSED. A code is added here, with the result it belongs to, in
* the same change that makes the engine produce it; a code is never renamed or
* reused for a different meaning, because an add-on may have stored it.
* tests/OutcomeCodesTest.php fails if a call site passes a code that is not
* listed, or one whose result differs from the call's.
*
* @return array outcome code => result (success|skipped|failed)
*/
function wpbr_outcome_codes() {
return array(
// Replace: a row that went through wpbr_process_one_item().
'replaced' => 'success',
'replaced_renamed' => 'success',
'replaced_already_in_place' => 'success',
'repaired_missing_file' => 'success',
'attached_new' => 'success',
'attached_unshared' => 'success',
'skipped_no_match' => 'skipped',
'skipped_no_existing_file' => 'skipped',
'skipped_path_unresolvable' => 'skipped',
'skipped_outside_uploads' => 'skipped',
'skipped_staged_missing' => 'skipped',
'skipped_no_reference_field' => 'skipped',
'refused_shared_with_outside_page' => 'failed',
'blocked_offloaded' => 'failed',
'failed_create_directory' => 'failed',
'failed_write_missing_file' => 'failed',
'failed_backup_copy' => 'failed',
'failed_backup_not_recorded' => 'failed',
'blocked_directory_not_writable' => 'failed',
'blocked_backup_changed' => 'failed',
'skipped_file_busy' => 'skipped',
'skipped_storage_conflict' => 'skipped',
'skipped_storage_unsupported' => 'skipped',
'skipped_storage_unavailable' => 'skipped',
'blocked_lock_unavailable' => 'failed',
'failed_backup_unverified' => 'failed',
'failed_write_renamed_file' => 'failed',
'failed_create_attachment_for_renamed' => 'failed',
'failed_undo_not_recorded' => 'failed',
'failed_reference_update' => 'failed',
'failed_overwrite_copy' => 'failed',
'failed_attach_copy' => 'failed',
'failed_attach_record' => 'failed',
'failed_restored' => 'failed',
// The privacy gate (includes/privacy-gate.php): nothing was written, and the person can fix it.
'blocked_exposed' => 'failed',
'blocked_unmeasured' => 'failed',
'blocked_unproven' => 'failed',
'blocked_unsupported_storage' => 'failed',
'needs_attention_restore_failed' => 'failed',
'needs_attention_backup_unverified' => 'failed',
'blocked_needs_attention' => 'failed',
// Rollback: a row that went through wpbr_rollback_chunk(). Rows it declines are 'skipped':
// nothing was changed, and 'unavailable_*' / 'refused_*' say why.
'rolled_back' => 'success',
'rolled_back_repointed' => 'success',
'rolled_back_attachment_removed' => 'success',
'skipped_not_replaced' => 'skipped',
'unavailable_backup_missing' => 'skipped',
'unavailable_no_live_reference' => 'skipped',
'unavailable_path_unresolvable' => 'skipped',
'refused_backup_outside_folder' => 'skipped',
'unavailable_backup_changed' => 'skipped',
'failed_restore_copy' => 'failed',
'skipped_needs_attention' => 'skipped',
// Retry restore (includes/retry-restore.php): the interactive way out of needs_attention.
'retry_restored' => 'success',
'retry_already_original' => 'success',
'retry_replacement_kept' => 'success',
'refused_not_waiting' => 'skipped',
'refused_no_recorded_hash' => 'skipped',
'refused_backup_unverified' => 'skipped',
'refused_choice_required' => 'skipped',
'refused_confirmation_required' => 'skipped',
'refused_confirmation_stale' => 'skipped',
'refused_could_not_keep_current_contents' => 'skipped',
'refused_not_interactive' => 'skipped',
'needs_attention_retry_failed' => 'failed',
);
}
/**
* The outcome code a caller should show when it was given something it cannot
* place, so a malformed value never leaks into a response as if it were real.
*/
if (!defined('WPBR_OUTCOME_UNCLASSIFIED')) define('WPBR_OUTCOME_UNCLASSIFIED', 'unclassified');
/**
* Did the engine decline this row because it cannot be done, as opposed to
* trying and failing? True for the codes that mean "nothing was changed, and
* this is not something a retry fixes": what a coordinator reports as
* unavailable rather than failed.
*/
function wpbr_outcome_is_unavailable($outcome) {
return 0 === strpos((string) $outcome, 'unavailable_') || 0 === strpos((string) $outcome, 'refused_');
}
/**
* Remember how a row last ended, on the row, so it is still there when the log pane is gone and
* when a different request has to decide what to do next.
*/
function wpbr_record_outcome($item_id, $outcome) {
global $wpdb;
$codes = wpbr_outcome_codes();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate.
$wpdb->update(wpbr_table_items(), array('outcome_code' => isset($codes[$outcome]) ? $outcome : WPBR_OUTCOME_UNCLASSIFIED), array('id' => (int) $item_id));
}
/**
* One row of a chunk's `outcomes` list.
*
* @param object $item The batch item.
* @param string $outcome A code from wpbr_outcome_codes().
* @return array{id:int,filename:string,outcome:string,result:string}
*/
function wpbr_outcome_entry($item, $outcome) {
$codes = wpbr_outcome_codes();
return array(
'id' => (int) $item->id,
'filename' => $item->staged_filename,
'outcome' => isset($codes[$outcome]) ? $outcome : WPBR_OUTCOME_UNCLASSIFIED,
'result' => isset($codes[$outcome]) ? $codes[$outcome] : 'failed',
);
}
/**
* The SHA-256 of a file, or '' when it cannot be read.
*/
function wpbr_file_sha256($path) {
$hash = is_readable($path) ? @hash_file('sha256', $path) : false;
return $hash === false ? '' : $hash;
}
/**
* Is $copy byte-for-byte the file $original? False when either cannot be read:
* an unknown answer is not a match.
*/
function wpbr_copy_matches($original, $copy) {
$a = wpbr_file_sha256($original);
return $a !== '' && $a === wpbr_file_sha256($copy);
}
/**
* Can an existing file be replaced SAFELY here? True when the directory it lives in is writable.
*
* Replacing a file without ever leaving it half old and half new needs a sibling written beside
* it and renamed over it, and creating and renaming a file is a right over the DIRECTORY, not over
* the file. Where the file is writable but its directory is not (an upload owned by another user,
* as on some shared hosts) the only thing that works is writing the file itself - and that, on a
* full disk or a dropped connection, leaves it truncated with the original gone. Every alternative
* was tried and refused by the filesystem: a temp file in another directory cannot be renamed in,
* links and symlinks need the directory, and neither can the file be unlinked nor the directory's
* permissions changed by a user who does not own it (the storage-compatibility research
* records the experiment). So there is no safe way, and none is attempted: the caller
* refuses, names the folder, and changes nothing.
*
* @param string $live Absolute path of the file that would be overwritten.
*/
function wpbr_can_overwrite_safely($live) {
$dest = is_link($live) ? (string) realpath($live) : $live;
return $dest !== '' && wp_is_writable(dirname($dest));
}
/**
* The few filesystem primitives wpbr_safe_overwrite() relies on, so a test can make one misbehave.
*
* Production never reads the override: it is honoured only when WPBR_RUNNING_TESTS is defined, which
* the test bootstraps do and nothing else does. That keeps the fault-injection seam (a rename that
* reports success and stores the wrong bytes, a copy that dies halfway) out of reach of any plugin
* or request on a live site, where a filter that could make a verification lie would be a hazard.
*
* @return array{copy:callable,rename:callable,sha256:callable}
*/
function wpbr_fs_ops() {
$ops = array(
'copy' => 'copy',
'rename' => 'rename',
'sha256' => 'wpbr_file_sha256',
);
if (wpbr_test_seams_active() && !empty($GLOBALS['wpbr_test_fs_ops']) && is_array($GLOBALS['wpbr_test_fs_ops'])) {
$ops = array_merge($ops, $GLOBALS['wpbr_test_fs_ops']);
}
return $ops;
}
/**
* What a person is told when no storage adapter owns the file a row would write.
*/
function wpbr_unclaimed_file_message() {
return __('blocked: this file is stored somewhere WPBR Bulk Replace cannot safely write to, so nothing was changed.', 'wpbr-bulk-replace');
}
/**
* Write $source over $live through the adapter that owns $live; the engine's stage array comes back unchanged.
* A file no adapter owns is reported as `temp_failed` (nothing written) so the caller's existing handling applies.
*/
function wpbr_local_adapter_write($source, $live, $expected_sha = '') {
$adapter = wpbr_storage_adapter_for($live);
if (!$adapter) {
return array('stage' => 'temp_failed');
}
$base = $expected_sha !== '' ? new WPBR_Storage_Fingerprint($expected_sha, null) : null;
return $adapter->write($live, $source, $base)->to_stage_array();
}
/**
* What a person is told when the database cannot provide the lock that keeps two requests off one file.
*/
function wpbr_lock_unavailable_message() {
return __('blocked: this site\'s database could not provide the lock that keeps two requests from writing the same file at once, so nothing was changed. Try again; if it keeps happening, ask your host whether the database allows named locks (GET_LOCK).', 'wpbr-bulk-replace');
}
/**
* Take the advisory lock that serialises writes to one file; '' when the database cannot give one.
*
* Two requests overwriting the same file (a double click, two workers of a network job, a rollback
* racing a replacement) would each rename their own sibling into place and then each read the file
* back - and the first one's read-back would see the second one's bytes and report a good write as
* damaged. A named MySQL lock is held by the connection, so it works across PHP processes and across
* web nodes that share the database, and it is released by the server when the connection ends - a
* request that is killed cannot leave the file locked.
*
* FAIL-CLOSED ON BOTH. Busy (returns false: someone else is writing, nothing is done) and unavailable
* (returns null: the database has no lock service, GET_LOCK answered NULL or an error, or there is no
* database object at all) stop the write. The single-writer guarantee is exactly what protects a file on the
* day the database misbehaves, so "cannot tell" must not mean "carry on without it". The caller reports
* the two differently: busy is a moment, unavailable is something to fix.
*
* @return string|false|null The lock name to release, false when busy, null when no lock can be had.
*/
function wpbr_overwrite_lock($dest) {
global $wpdb;
if (!isset($wpdb) || !is_object($wpdb) || !method_exists($wpdb, 'get_var')) {
return null;
}
$name = 'wpbr_ow_' . md5($dest);
$quiet = method_exists($wpdb, 'suppress_errors') ? $wpdb->suppress_errors(true) : null;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- A named server lock, not table data; there is nothing to cache.
$got = $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 0)', $name));
if ($quiet !== null) $wpdb->suppress_errors($quiet);
if ($got === null || $got === false) return null; // the server could not say: no lock, so no write
return ((string) $got === '1') ? $name : false;
}
function wpbr_overwrite_unlock($name) {
global $wpdb;
if ($name === '' || $name === false || !isset($wpdb) || !is_object($wpdb)) return;
$quiet = method_exists($wpdb, 'suppress_errors') ? $wpdb->suppress_errors(true) : null;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Releases the named server lock taken above.
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $name));
if ($quiet !== null) $wpdb->suppress_errors($quiet);
}
/**
* The temporary siblings this plugin has created and not yet seen finish, as [normalised path => created at].
*
* A file name proves nothing about who made the file: `.wpbr-tmp-` plus twelve hex digits is a pattern, and a
* sweep of a customer's uploads folder may not delete a document on the strength of a pattern and an age.
* The plugin therefore writes down every sibling it is about to create, before creating it, and the sweep
* removes only files this record names.
*
* @return array
*/
function wpbr_owned_temps() {
if (!function_exists('get_option')) return array();
$record = get_option('wpbr_owned_temps', array());
return is_array($record) ? $record : array();
}
/** One comparable spelling of a sibling's path. */
function wpbr_owned_temp_key($path) {
$path = str_replace('\\', '/', (string) $path);
$dir = @realpath(dirname($path));
return ($dir !== false ? rtrim(str_replace('\\', '/', $dir), '/') : rtrim(dirname($path), '/')) . '/' . basename($path);
}
/**
* Record a sibling before it exists. False when the record cannot be kept: then the sibling is not created.
*/
function wpbr_owned_temp_register($path) {
if (!function_exists('update_option')) return false;
$record = wpbr_owned_temps();
$record[wpbr_owned_temp_key($path)] = time();
return false !== update_option('wpbr_owned_temps', $record, false);
}
/** Forget a sibling that no longer exists. */
function wpbr_owned_temp_forget($path) {
if (!function_exists('update_option')) return;
$record = wpbr_owned_temps();
$key = wpbr_owned_temp_key($path);
if (isset($record[$key])) {
unset($record[$key]);
update_option('wpbr_owned_temps', $record, false);
}
}
/**
* Remove temporary siblings that a request which died left behind.
*
* A sibling is the replacement (or the original, for a rollback) sitting beside a live file in a
* public folder. A request that is killed between writing it and renaming it - out of memory, a
* process kill, a server restart - cannot clean up after itself, and the file would stay for good.
* So every folder the plugin writes into is swept once per request.
*
* OWNERSHIP IS RECORDED, NOT INFERRED. Only a file that wpbr_owned_temp_register() wrote down before
* creating it, whose name still has the form `.wpbr-tmp-` and twelve lower-case hex digits, which is a
* regular file and not a link, and which has not been written to for an hour. A look-alike with no
* record - a file somebody named `report.wpbr-tmp-0123456789ab`, however old - is theirs and is never
* touched. Names are compared as text, not as glob patterns, so a live file called `report[1]*.pdf`
* is no problem. Records whose file is gone are dropped, so the list does not grow.
*
* @param string $dir Folder to sweep.
* @param bool $force Sweep even if this folder was swept earlier in the request.
* @return int How many files were removed.
*/
function wpbr_sweep_stale_temps($dir, $force = false) {
static $done = array();
$dir = rtrim($dir, '/');
if ($dir === '' || (!$force && isset($done[$dir]))) return 0;
$done[$dir] = true;
$owned = wpbr_owned_temps();
if (!$owned) return 0;
$handle = @opendir($dir);
if (!$handle) return 0;
$removed = 0;
$seen = 0;
$forget = array();
// phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition -- The documented way to walk a directory handle; readdir() returns false at the end and a name that is falsy ('0') is still a name.
while (false !== ($name = readdir($handle))) {
if (++$seen > 50000) break; // a folder this large is swept over several requests rather than stalling one
if (!preg_match('/\.wpbr-tmp-[0-9a-f]{12}$/', $name)) continue;
$path = $dir . '/' . $name;
$key = wpbr_owned_temp_key($path);
if (!isset($owned[$key])) continue; // no record: not ours, whatever it is called
if (is_link($path) || !is_file($path)) continue;
if ((time() - (int) filemtime($path)) > HOUR_IN_SECONDS) {
wp_delete_file($path);
$removed++;
$forget[] = $key;
}
}
closedir($handle);
// Keep the record honest: drop what was just removed and anything whose file is gone.
$changed = false;
foreach ($owned as $key => $at) {
if (in_array($key, $forget, true) || (!file_exists($key) && !is_link($key))) {
unset($owned[$key]);
$changed = true;
}
}
if ($changed && function_exists('update_option')) {
update_option('wpbr_owned_temps', $owned, false);
}
return $removed;
}
/**
* Put the bytes of $source at $live, and know that they arrived.
*
* This is the one place an existing file is overwritten - by a replacement and by a rollback -
* and the rules are the same for both:
*
* 0. One writer per file: a named lock is taken for the destination. If another request holds it,
* nothing is done ('busy') and the caller says so; it is not an error and not a retry loop. If no
* lock can be had at all (the database cannot provide one) nothing is done either
* ('lock_unavailable'): a write with no single-writer guarantee is not made.
* 1. The new bytes are written to a sibling file in the same directory and checked against the
* source BEFORE the live file is touched. A disk that fills, a timeout or a dropped mount
* therefore ends with the live file exactly as it was ('temp_failed').
* 2. A rename puts them in place: atomic on a local filesystem, so there is never a moment when
* the live file is half old and half new.
* 3. The live file is read back and compared. Only a match is 'written'. A mismatch is reported
* as 'unverified' - the live file may be damaged, and the caller must say so - never as success.
*
* THE SIBLING'S LIFE. It is created readable by its owner only (the live file's own mode is applied
* just before the rename, never earlier), so a more private file is not briefly more public. It is
* removed on every path that does not end in the rename, including a request that runs out of time
* (a shutdown function), and one that is killed outright is swept by wpbr_sweep_stale_temps() on the
* next write into that folder. Its name is random, so concurrent writers never share one.
*
* WHEN THE DIRECTORY CANNOT BE WRITTEN there is no sibling and so no safe replacement, and the live
* file is not touched: 'dir_not_writable'. See wpbr_can_overwrite_safely().
*
* A symlinked live file is written through to its target, as copy() always did; the link stays.
* The live file's permissions and, where this user may, its group are kept.
*
* Does not take, check or restore a backup: that belongs to the caller.
*
* @param string $source Absolute path of the file to put in place.
* @param string $live Absolute path of the existing file to overwrite.
* @return array{stage:string} stage: written | temp_failed | dir_not_writable | busy | unverified
*/
function wpbr_safe_overwrite($source, $live, $expected_sha = '') {
$ops = wpbr_fs_ops();
$want = call_user_func($ops['sha256'], $source);
if ($want === '') {
return array('stage' => 'temp_failed'); // nothing to write: the live file was not touched
}
$dest = is_link($live) ? (string) realpath($live) : $live;
if ($dest === '') {
return array('stage' => 'temp_failed');
}
if (!wpbr_can_overwrite_safely($live)) {
return array('stage' => 'dir_not_writable');
}
$lock = wpbr_overwrite_lock($dest);
if ($lock === false) {
return array('stage' => 'busy');
}
if ($lock === null) {
return array('stage' => 'lock_unavailable');
}
// Everything from here releases the lock and leaves no sibling unless the rename happened.
$state = array('tmp' => $dest . '.wpbr-tmp-' . bin2hex(random_bytes(6)), 'done' => false);
register_shutdown_function(function () use (&$state) {
// A request that ran out of time stops mid-copy; its shutdown functions still run.
if (!$state['done'] && is_file($state['tmp'])) {
wp_delete_file($state['tmp']);
}
});
try {
wpbr_sweep_stale_temps(dirname($dest));
$tmp = $state['tmp'];
// Written down BEFORE the sibling exists, so that a request killed at any later point leaves a file the
// sweep can prove is ours. If the record cannot be kept the sibling is not made.
if (!wpbr_owned_temp_register($tmp)) {
return array('stage' => 'temp_failed');
}
$old = umask(0177); // the sibling is created owner-only; the live file's mode is applied at the end
try {
$copied = @call_user_func($ops['copy'], $source, $tmp);
} finally {
umask($old);
}
if (!$copied || call_user_func($ops['sha256'], $tmp) !== $want) {
wp_delete_file($tmp);
return array('stage' => 'temp_failed');
}
$mode = @fileperms($dest);
if ($mode !== false) {
@chmod($tmp, $mode & 0777); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- The replacement takes the replaced file's own permissions; WP_Filesystem has no equivalent that preserves them.
}
$group = @filegroup($dest);
if ($group !== false) {
@chgrp($tmp, $group); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chgrp -- Best effort, and only a group this user belongs to can be applied; it keeps the replaced file's group on the replacement.
}
// The last look before the rename (ADR case b): when the caller says what the live file should be, something else
// having changed it since is not overwritten. This catches a change that is VISIBLE here; one that lands between this
// check and the rename cannot be seen (docs/engineering/GAP-LEDGER.md G6) and is not claimed to be excluded.
if ($expected_sha !== '' && call_user_func($ops['sha256'], $dest) !== $expected_sha) {
wp_delete_file($tmp);
return array('stage' => 'conflict');
}
if (!@call_user_func($ops['rename'], $tmp, $dest)) {
wp_delete_file($tmp);
return array('stage' => 'temp_failed'); // a failed rename leaves the destination as it was
}
$state['done'] = true;
$seen = call_user_func($ops['sha256'], $dest);
return $seen === $want ? array('stage' => 'written') : array('stage' => 'unverified', 'observed' => $seen);
} finally {
$state['done'] = $state['done'] || !is_file($state['tmp']);
if (!is_file($state['tmp'])) {
wpbr_owned_temp_forget($state['tmp']);
}
wpbr_overwrite_unlock($lock);
}
}
/**
* The outcome of processing one row: a machine code, and prose for the reader.
*
* The code is what every caller branches on. It is carried rather than
* recovered from the sentence because a sentence is translated and a decision
* is not - the classifier this replaces read the prose with a regular
* expression, matched neither its separator nor its case, and answered
* "success" for every outcome including outright failures.
*
* `outcome` is the finer code: see wpbr_outcome_codes(). It is additive - `code`,
* `line` and `reason` are exactly what they were.
*
* @param string $code One of success, skipped, failed.
* @param string $filename The staged filename, for the reader.
* @param string $outcome Translated prose describing what happened.
* @param string $outcome_code One of wpbr_outcome_codes(), belonging to $code.
* @return array{code:string,line:string,reason:string,outcome:string}
*/
function wpbr_run_result($code, $filename, $outcome, $outcome_code = WPBR_OUTCOME_UNCLASSIFIED) {
$codes = wpbr_outcome_codes();
if (!isset($codes[$outcome_code]) || $codes[$outcome_code] !== $code) {
$outcome_code = WPBR_OUTCOME_UNCLASSIFIED;
}
return array(
'code' => $code,
'line' => wpbr_run_log_line($filename, $outcome),
'reason' => $outcome,
'outcome' => $outcome_code,
);
}
/**
* The Media Library entry that says a file lives at this path, if there is one.
*
* Three ways to say so, tried in order of how much each can be trusted: core's own
* lookup of the URL, the entry whose `_wp_attached_file` is this path relative to the
* uploads folder (which is what WordPress and every offload plugin keep even after the
* local file is gone), and the entry whose guid ends in this address (what
* wpbr_find_attachment_id_by_url() has always used).
*
* @param string $live_path Absolute path inside the uploads folder.
* @param string $file_url The URL the post stores.
* @return int Attachment ID, or 0 when nothing in the Media Library claims the file.
*/
function wpbr_attachment_id_for_path($live_path, $file_url) {
global $wpdb;
$id = (int) attachment_url_to_postid(wpbr_url_without_decoration($file_url));
if ($id) return $id;
$base = rtrim(str_replace('\\', '/', wp_upload_dir()['basedir']), '/') . '/';
$path = str_replace('\\', '/', $live_path);
if (strpos($path, $base) === 0) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- A one-row lookup of which attachment owns a path, read immediately before deciding whether to write; a cached answer could be the wrong one.
$id = (int) $wpdb->get_var($wpdb->prepare(
"SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_wp_attached_file' AND meta_value = %s LIMIT 1",
substr($path, strlen($base))
));
if ($id) return $id;
}
return (int) wpbr_find_attachment_id_by_url($file_url);
}
function wpbr_process_one_item($item, $batch, $all_items = array(), $shared_paths = array()) {
global $wpdb;
$items_table = wpbr_table_items();
if (!$item->matched_post_id) {
return wpbr_run_result('skipped', $item->staged_filename, __('skipped: no matched post', 'wpbr-bulk-replace'), 'skipped_no_match');
}
$meta_keys = wpbr_reference_locations($batch);
$file_url = wpbr_reference_current($batch, $item->matched_post_id)['value'];
if (!$file_url) {
if ($item->status !== 'confirmed_new_attach') {
return wpbr_run_result('skipped', $item->staged_filename, __('skipped: no existing file on record, and not yet confirmed for a new attachment. Go to Review & Resolve and click “Confirm new attachment”.', 'wpbr-bulk-replace'), 'skipped_no_existing_file');
}
return wpbr_process_first_time_attach($item, $batch);
}
// Asked before anything is written, and before the unshare branch below,
// because the two cases are not the same. Sharing inside the batch is
// resolved by giving each selected post its own file - every page that
// changes is one the reader chose. A post with no row here was not chosen,
// and overwriting the file it points at edits that page without telling
// anybody, so this row is refused instead. Unsharing is not offered
// as an alternative: it would repoint the selected post to a new URL, and
// the reader asked to replace a file, not to move it.
$shared_target = wpbr_url_to_upload_path($file_url);
if ($shared_target) {
$outside = wpbr_outside_batch_sharers($meta_keys, $shared_target, $item->matched_post_id, $all_items);
if ($outside) {
return wpbr_run_result('failed', $item->staged_filename, sprintf(
/* translators: 1: a filename, 2: a list of posts such as "Prospectus" (ID 12, field pdf_url). */
_n(
'REFUSED: %1$s is also used by a page that is not in this batch, so replacing it would change that page too. Sharing it with: %2$s. Add it to this batch, or point one of them at its own file first.',
'REFUSED: %1$s is also used by pages that are not in this batch, so replacing it would change them too. Sharing it with: %2$s. Add them to this batch, or point them at their own files first.',
count($outside),
'wpbr-bulk-replace'
),
basename($shared_target),
wpbr_describe_sharers($outside)
), 'refused_shared_with_outside_page');
}
}
if ($all_items && $shared_paths && wpbr_destination_shared_with_another_post($item, $batch, $shared_paths)) {
return wpbr_process_first_time_attach($item, $batch, true);
}
$live_path = wpbr_url_to_upload_path($file_url);
if (!$live_path) {
return wpbr_run_result('skipped', $item->staged_filename, sprintf(
/* translators: %s is a stored file URL. */
__('skipped: could not work out a file path from “%s”', 'wpbr-bulk-replace'),
$file_url
), 'skipped_path_unresolvable');
}
// A reference on a stream wrapper (vip://, s3://) is not a path on this server: it goes through the adapter that owns it, end to
// end, and nothing below - which assumes a local disk - is consulted (includes/storage-remote.php).
if (wpbr_ref_is_stream_uri($live_path)) {
return wpbr_process_remote_item($item, $batch, $live_path, $file_url);
}
if (!wpbr_path_is_in_uploads($live_path)) {
return wpbr_run_result('skipped', $item->staged_filename, sprintf(
/* translators: %s is a stored file URL. */
__('skipped: “%s” resolves outside the uploads directory', 'wpbr-bulk-replace'),
$file_url
), 'skipped_outside_uploads');
}
// A file whose last replacement could not be undone is not written again, by any row in any
// batch, until a person has dealt with it: a second write on top of unknown bytes is exactly what
// the protected backup exists to prevent.
if (wpbr_live_path_is_held($live_path)) {
return wpbr_run_result('failed', $item->staged_filename, sprintf(
/* translators: %s is a filename. */
__('blocked: an earlier replacement of %s could not be undone and is waiting for attention, so this file is not touched again. Nothing was changed. Deal with that row first.', 'wpbr-bulk-replace'),
basename($live_path)
), 'blocked_needs_attention');
}
if (!file_exists($item->staged_path)) {
return wpbr_run_result('skipped', $item->staged_filename, __('skipped: staged file missing', 'wpbr-bulk-replace'), 'skipped_staged_missing');
}
// The post references a file that isn't on disk. Writing the new file to
// that exact path repairs the link rather than leaving the page broken.
// There is nothing to back up, so this one cannot be rolled back.
$live_missing = !file_exists($live_path);
if ($live_missing) {
// A file the Media Library lists and the server does not have is not a broken link to
// repair: it is most often a file kept somewhere else (an offload or CDN plugin that
// removed its local copy). Writing it here either changes nothing visitors see, or hands
// it to a plugin that uploads it over the only copy of the original - and in neither
// case is there a backup, because there was nothing on disk to back up. So nothing is
// written. A URL with no Media Library entry behind it is still repaired, as before.
if (wpbr_attachment_id_for_path($live_path, $file_url)) {
return wpbr_run_result('failed', $item->staged_filename, __('blocked: the Media Library lists this file but it is not on this server, so it may be stored elsewhere (for example by an offload plugin). Writing it here would not change what visitors are served. Nothing was changed. Restore the original file to this server with your offload or storage provider, or contact support, then run this row again. Do not delete the Media Library entry to get past this: the original may still exist elsewhere and would be overwritten without a backup.', 'wpbr-bulk-replace'), 'blocked_offloaded');
}
$dir = dirname($live_path);
if (!is_dir($dir) && !wp_mkdir_p($dir)) {
return wpbr_run_result('failed', $item->staged_filename, sprintf(
/* translators: %s is a directory path. */
__('failed: could not create %s', 'wpbr-bulk-replace'),
$dir
), 'failed_create_directory');
}
if (!copy($item->staged_path, $live_path)) {
return wpbr_run_result('failed', $item->staged_filename, sprintf(
/* translators: %s is a file path. */
__('failed: could not write to %s', 'wpbr-bulk-replace'),
$live_path
), 'failed_write_missing_file');
}
wpbr_asset_version_register($live_path);
require_once ABSPATH . 'wp-admin/includes/image.php';
$attach_id_missing = attachment_url_to_postid(wpbr_url_without_decoration($file_url));
$created_record = false;
if (!$attach_id_missing) {
// The post held a bare URL with no Media Library entry behind it.
// Create one now so the file is properly tracked from here on.
$filetype = wp_check_filetype(basename($live_path), null);
$attach_id_missing = wp_insert_attachment(array(
'guid' => $file_url,
'post_mime_type' => $filetype['type'] ? $filetype['type'] : 'application/pdf',
'post_title' => preg_replace('/\.[^.]+$/', '', basename($live_path)),
'post_content' => '',
'post_status' => 'inherit',
), $live_path, 0);
if (is_wp_error($attach_id_missing) || !$attach_id_missing) {
$attach_id_missing = 0;
} else {
$created_record = true;
}
}
if ($attach_id_missing) {
$meta = wp_generate_attachment_metadata($attach_id_missing, $live_path);
if ($meta) wp_update_attachment_metadata($attach_id_missing, $meta);
wp_update_post(array('ID' => $attach_id_missing, 'post_modified' => current_time('mysql')));
}
// Two complete notes rather than one with a clause spliced in: the
// two halves have to agree grammatically, which a fragment cannot
// guarantee across languages.
$note_body = $created_record
? sprintf(
/* translators: %s is a Media Library attachment ID. */
__('Live file was missing from disk; wrote the new file to the path the post already referenced, and no Media Library entry existed either, so attachment #%s was created for it. No backup exists, so this row cannot be rolled back.', 'wpbr-bulk-replace'),
number_format_i18n($attach_id_missing)
)
: __('Live file was missing from disk; wrote the new file to the path the post already referenced, and the existing Media Library entry was refreshed. No backup exists, so this row cannot be rolled back.', 'wpbr-bulk-replace');
$note = trim(($item->notes ? $item->notes.' | ' : '') . $note_body);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate.
$wpdb->update($items_table, array(
'status' => 'replaced',
'backup_path' => null,
// Deliberately not recorded here, though a record may have just been
// created. created_attachment_id means "a file this row created,
// which rolling back should remove" - rollback deletes it with its
// file, and the Replace step offers the button whenever it is set.
// A repair creates a Media Library entry for a URL the post was
// already using and writes to that same path, so treating it as this
// row's to remove would delete the file the repair just wrote and
// clear the post's URL - leaving the post worse off than the missing
// file it started with, and contradicting the note directly above,
// which tells the reader this row cannot be rolled back. The ID is
// in that note instead.
'created_attachment_id' => null,
'notes' => $note,
'processed_at' => current_time('mysql'),
), array('id' => $item->id));
// Two whole sentences rather than one with a clause spliced on: the
// created/refreshed distinction changes the verb, which a translator
// cannot rearrange if it arrives as a fragment.
$outcome = $created_record
? sprintf(
/* translators: 1: a filename, 2: a Media Library attachment ID. */
__('wrote the missing file to %1$s and created attachment #%2$s', 'wpbr-bulk-replace'),
basename($live_path), number_format_i18n($attach_id_missing)
)
: sprintf(
/* translators: 1: a filename, 2: a Media Library attachment ID. */
__('wrote the missing file to %1$s and refreshed attachment #%2$s', 'wpbr-bulk-replace'),
basename($live_path), number_format_i18n($attach_id_missing)
);
return wpbr_run_result('success', $item->staged_filename, $outcome, 'repaired_missing_file');
}
// Everything from here to the update at the end of this function is three
// acts that are not atomic: back the live file up, write the staged file
// over it, record what happened. The gap between the second and the third
// contains wp_generate_attachment_metadata(), which resizes images and is
// the likeliest place on shared hosting to hit max_execution_time - so an
// interruption that leaves the file replaced and the row still saying
// "matched" is ordinary, not exotic. The row is then still eligible, the
// browser retries the chunk, and a second backup would capture the
// replacement and point the row at it. Rollback would restore the new file
// over itself and the original would be gone.
//
// Three things prevent that, in order of how much they are relied on.
// Nothing is backed up, recorded or written for a replacement that cannot be made safely.
if (!wpbr_can_overwrite_safely($live_path)) {
return wpbr_run_result('failed', $item->staged_filename, sprintf(
/* translators: %s is a folder path. */
__('blocked: WordPress cannot write to the folder %s, and a file can only be replaced safely by writing the new copy beside it. Nothing was changed. Make that folder writable for the web server, or ask your host to, then run this row again.', 'wpbr-bulk-replace'),
dirname($live_path)
), 'blocked_directory_not_writable');
}
// A backup this row already holds is the original, and the live file by
// now may not be. Never take a second one.
$backup_path = ($item->backup_path && file_exists($item->backup_path)) ? $item->backup_path : '';
// A reused backup is only the original if it still hashes to what the original hashed to when it
// was taken. A row backed up before the hash was recorded has nothing to compare with and is used
// as it stands, as it always was.
if ($backup_path && !empty($item->original_sha256) && wpbr_file_sha256($backup_path) !== $item->original_sha256) {
return wpbr_run_result('failed', $item->staged_filename, sprintf(
/* translators: %s is a file path. */
__('blocked: the backup kept at %s no longer matches the file it was taken from, so it cannot be trusted as the original. Nothing was changed. Keep that file and ask for help before running this row again.', 'wpbr-bulk-replace'),
$backup_path
), 'blocked_backup_changed');
}
// Exactly one adapter owns this file; if none does (or two claim it) nothing is written (SAF-2).
$adapter = wpbr_storage_adapter_for($live_path);
if (!$adapter) {
return wpbr_run_result('failed', $item->staged_filename, wpbr_unclaimed_file_message(), 'blocked_unsupported_storage');
}
// A row that died after recording its intent: look at the file and conclude from the intent's two possibilities
// (includes/storage-intent.php). Rows without an intent behave exactly as before.
$landed = false;
$backed_up = null; // set when THIS request takes the backup: only then is the live file known to equal it
if (wpbr_intent_pending($item)) {
$seen = wpbr_intent_classify($item, $adapter, $live_path);
if ($seen === 'original') {
wpbr_intent_clear($item->id); // the write did not land: carry on as a normal row
} elseif ($seen === 'replacement' && $backup_path) {
$landed = true; // it landed: commit it below, without a second write
} elseif ($seen === 'unknown') {
return wpbr_hold_unknown_intent($item, $live_path, $backup_path);
}
}
if (!$backup_path) {
// No record of a backup, but the live file is already byte-for-byte the
// staged file: the replacement happened and the record of it did not.
// Backing up now would store the replacement as though it were the
// original, which is worse than storing nothing, because the row would
// then offer a rollback that silently does nothing.
if (wpbr_files_are_identical($live_path, $item->staged_path)) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate.
$wpdb->update($items_table, array(
'status' => 'replaced',
'processed_at' => current_time('mysql'),
'notes' => trim(($item->notes ? $item->notes.' | ' : '') . __('This file was already in place when the row was processed, so no backup was taken and this row cannot be rolled back. That happens when an earlier run replaced the file but was interrupted before it could record it.', 'wpbr-bulk-replace')),
) + wpbr_intent_cleared(), array('id' => $item->id));
return wpbr_run_result('success', $item->staged_filename, __('already in place from an earlier run; nothing was overwritten and no backup was taken', 'wpbr-bulk-replace'), 'replaced_already_in_place');
}
// About to put a copy of a document in the backups folder: the privacy gate decides, here in the engine.
$refusal = wpbr_backup_gate_refusal($item, $live_path);
if ($refusal) {
return $refusal;
}
// A private location is checked for room before the copy: the file, the sibling temporary copy a verified overwrite
// writes, and a margin. A full disk is a refusal here, not a partial write. Sites with no private location are
// unchanged.
if (function_exists('wpbr_storage_active_dir') && wpbr_storage_active_dir() !== '') {
$room = wpbr_storage_check_room(wpbr_backups_dir(), array((int) @filesize($live_path)));
if ($room['status'] === 'fail') {
return wpbr_run_result('failed', $item->staged_filename, sprintf(
/* translators: 1: a file path, 2: how much space is needed and how much is free. */
__('failed: not enough free space in the backups folder to back up %1$s safely (%2$s). Nothing was overwritten.', 'wpbr-bulk-replace'),
$live_path,
$room['detail']
), 'failed_backup_copy');
}
}
// wp_unique_filename() because the name is only a post id, the current
// second and the filename - all three of which repeat on a retry, and
// copy() overwrites without asking.
$backup_filename = wp_unique_filename(
wpbr_backups_dir(),
$item->matched_post_id.'_'.time().'_'.basename($live_path)
);
$backup_path = wpbr_backups_dir() . '/' . $backup_filename;
$backed_up = $adapter->backup($live_path, $backup_path);
if ($backed_up->status === WPBR_Storage_Result::UNAVAILABLE) {
return wpbr_run_result('failed', $item->staged_filename, sprintf(
/* translators: %s is a file path. */
__('failed: could not back up %s', 'wpbr-bulk-replace'),
$live_path
), 'failed_backup_copy');
}
// A backup that is not the file it is a backup of is worse than none: it would be
// restored as the original. Read it back before anything depends on it. (A backup
// kept from an earlier, interrupted run is not compared with the live file - the live
// file may legitimately have changed since - and is only used as it stands.)
if (!$backed_up->is_ok()) {
wp_delete_file($backup_path);
return wpbr_run_result('failed', $item->staged_filename, sprintf(
/* translators: %s is a file path. */
__('failed: the backup of %s did not match the file, so nothing was overwritten. Try this row again.', 'wpbr-bulk-replace'),
$live_path
), 'failed_backup_unverified');
}
// Recorded before the destructive step rather than after it, so there
// is no window in which the live file has been overwritten and nothing
// in the database knows where the original went. Checked, because that
// record is the whole reason the overwrite is recoverable: without it
// there is no point proceeding, and proceeding is what would cost the
// file.
// The hash of the original, kept with the backup's address: the later proof that the backup is still what it was.
// Also on the in-memory row, because a restore later in this same request (wpbr_restore_original) checks the backup against it,
// and the row was loaded before this hash existed.
$item->original_sha256 = wpbr_file_sha256($backup_path);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate.
if (false === $wpdb->update($items_table, array('backup_path' => $backup_path, 'original_sha256' => $item->original_sha256), array('id' => $item->id))) {
return wpbr_run_result('failed', $item->staged_filename, sprintf(
/* translators: %s is a file path. */
__('failed: the backup was written to %s but the database would not record it, so nothing was overwritten. Nothing on the site has changed; try this row again.', 'wpbr-bulk-replace'),
$backup_path
), 'failed_backup_not_recorded');
}
/**
* A verified backup of the original is now on disk AND recorded on the row.
*
* For an extension that keeps a second copy somewhere else (Cloud Storage). It is told after the fact: nothing it does
* can stop, delay or change this row, and nothing it returns is read. It must not write the row, the backup or its marker.
*
* @param object $item The row, with `backup_path` and `original_sha256` set.
* @param string $backup_path Absolute path of the backup, inside the backups folder.
* @param string $original_sha256 Hash of the original, as recorded.
*/
do_action('wpbr_backup_recorded', $item, $backup_path, $item->original_sha256);
}
// If a different filename was asked for, write the file under the new name
// and repoint the post at it, rather than overwriting in place. The old
// file is left where it is so existing links to it keep resolving.
$desired = $item->rename_to ? sanitize_file_name($item->rename_to) : '';
if ($desired && $desired !== basename($live_path)) {
$target_dir = dirname($live_path);
$unique = wp_unique_filename($target_dir, $desired);
$new_path = $target_dir . '/' . $unique;
if (!copy($item->staged_path, $new_path)) {
return wpbr_run_result('failed', $item->staged_filename, sprintf(
/* translators: %s is a file path. */
__('failed: could not write the renamed file to %s', 'wpbr-bulk-replace'),
$new_path
), 'failed_write_renamed_file');
}
$upload_dir = wp_upload_dir();
$new_url = str_replace(
untrailingslashit($upload_dir['basedir']),
untrailingslashit($upload_dir['baseurl']),
$new_path
);
require_once ABSPATH . 'wp-admin/includes/image.php';
$filetype = wp_check_filetype($unique, null);
$new_attach_id = wp_insert_attachment(array(
'guid' => $new_url,
'post_mime_type' => $filetype['type'] ? $filetype['type'] : 'application/pdf',
'post_title' => preg_replace('/\.[^.]+$/', '', $unique),
'post_content' => '',
'post_status' => 'inherit',
), $new_path, 0);
if (is_wp_error($new_attach_id) || !$new_attach_id) {
return wpbr_run_result('failed', $item->staged_filename, __('failed: creating an attachment for the renamed file', 'wpbr-bulk-replace'), 'failed_create_attachment_for_renamed');
}
$meta = wp_generate_attachment_metadata($new_attach_id, $new_path);
if ($meta) wp_update_attachment_metadata($new_attach_id, $meta);
// point the post at the new file
$target_meta_key = wpbr_reference_current($batch, $item->matched_post_id)['location'];
// Before the post is repointed, not after: these two are what rollback
// needs to remove the file this plugin created and put the post back on
// the one it had. Recorded afterwards, a failed write left an
// attachment nothing would ever clean up and a post pointing at it.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate.
if (false === $wpdb->update($items_table, array(
'created_attachment_id' => $new_attach_id,
'previous_meta_value' => $file_url,
), array('id' => $item->id))) {
// The post has not been touched, so nothing references the file
// just created. Clearing it up is worth attempting even though the
// database is evidently unwell.
wp_delete_attachment($new_attach_id, true);
return wpbr_run_result('failed', $item->staged_filename, __('failed: the renamed file was created but the database would not record how to undo it, so the post was left pointing at its original file. Nothing on the site has changed; try this row again.', 'wpbr-bulk-replace'), 'failed_undo_not_recorded');
}
// A REFUSED WRITE IS A FAILURE, NOT A QUIET NO-OP. The built-in
// provider effectively never refuses; one contributed by an add-on can,
// and must - a location it owns but may not act on is exactly the case
// where writing anyway, or reporting success anyway, leaves the post
// pointing at its old file while the run says it was replaced. The
// attachment and the undo row are already recorded above, deliberately,
// so rollback can remove what was created.
if (!wpbr_reference_update($batch, $item->matched_post_id, $target_meta_key, $new_url)) {
return wpbr_run_result('failed', $item->staged_filename, sprintf(
/* translators: %s is a field name. */
__('failed: the renamed file was created but “%s” would not accept it, so the post still points at its original file. Nothing on the page has changed. Roll this row back to remove the file that was created.', 'wpbr-bulk-replace'),
$target_meta_key
), 'failed_reference_update');
}
$note = trim(($item->notes ? $item->notes.' | ' : '') . sprintf(
/* translators: 1: the new filename, 2: an attachment ID, 3: a meta field name, 4: the old filename. */
__('Renamed on replace: new file %1$s created as attachment #%2$s, and “%3$s” repointed from %4$s. The old file was left in place.', 'wpbr-bulk-replace'),
$unique, $new_attach_id, $target_meta_key, basename($live_path)
));
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate.
$wpdb->update($items_table, array(
'status' => 'replaced',
'backup_path' => $backup_path,
'created_attachment_id' => $new_attach_id,
'previous_meta_value' => $file_url,
'notes' => $note,
'processed_at' => current_time('mysql'),
), array('id' => $item->id));
return wpbr_run_result('success', $item->staged_filename, sprintf(
/* translators: 1: the new filename, 2: a Media Library attachment ID. */
__('replaced and renamed to %1$s (attachment #%2$s)', 'wpbr-bulk-replace'),
$unique,
number_format_i18n($new_attach_id)
), 'replaced_renamed');
}
if (!$landed) {
// Write-ahead: what is about to happen is recorded before it happens, so a death in the middle can be concluded from.
$base_sha = wpbr_file_sha256($backup_path);
$new_sha = wpbr_file_sha256($item->staged_path);
if ($base_sha === '' || $new_sha === '' || !wpbr_intent_begin($item, $adapter, $base_sha, $new_sha, $backup_path, $live_path)) {
return wpbr_run_result('failed', $item->staged_filename, sprintf(
/* translators: %s is a file path. */
__('failed: the backup was written to %s but the database would not record it, so nothing was overwritten. Nothing on the site has changed; try this row again.', 'wpbr-bulk-replace'),
$backup_path
), 'failed_backup_not_recorded');
}
$base = ($backed_up && $backed_up->fingerprint) ? $backed_up->fingerprint : null;
$written = $adapter->write($live_path, $item->staged_path, $base)->to_stage_array();
if ($written['stage'] !== 'written' && $written['stage'] !== 'unverified') {
wpbr_intent_clear($item->id); // known not to have landed: the live file is as it was
}
} else {
$written = array('stage' => 'written');
}
if ($written['stage'] === 'busy') {
return wpbr_run_result('skipped', $item->staged_filename, __('skipped: another request is writing this file right now. Nothing was changed; run this row again in a moment.', 'wpbr-bulk-replace'), 'skipped_file_busy');
}
if ($written['stage'] === 'conflict') {
// Something else changed the file after it was backed up. Its bytes are left exactly as they are. The backup holds the
// version before that change, so it is detached from the row: the next run takes a fresh one of what is there now
// instead of reusing this and overwriting the newer bytes. The file itself is kept.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate.
$wpdb->update($items_table, array('backup_path' => null, 'original_sha256' => null), array('id' => $item->id));
return wpbr_run_result('skipped', $item->staged_filename, sprintf(
/* translators: 1: a file path (the live file), 2: a file path (the earlier copy). */
__('skipped: %1$s was changed by something else after it was backed up, so it was left exactly as it is now and nothing was overwritten. A copy of the earlier version is kept at %2$s. Check the file, then run this row again.', 'wpbr-bulk-replace'),
$live_path,
$backup_path
), 'skipped_storage_conflict');
}
if ($written['stage'] === 'lock_unavailable') {
return wpbr_run_result('failed', $item->staged_filename, wpbr_lock_unavailable_message(), 'blocked_lock_unavailable');
}
if ($written['stage'] === 'dir_not_writable') {
return wpbr_run_result('failed', $item->staged_filename, sprintf(
/* translators: %s is a folder path. */
__('blocked: WordPress cannot write to the folder %s, and a file can only be replaced safely by writing the new copy beside it. Nothing was changed. Make that folder writable for the web server, or ask your host to, then run this row again.', 'wpbr-bulk-replace'),
dirname($live_path)
), 'blocked_directory_not_writable');
}
if ($written['stage'] === 'temp_failed') {
// Stopped before the live file was touched; it is exactly as it was.
return wpbr_run_result('failed', $item->staged_filename, sprintf(
/* translators: %s is a file path. */
__('failed: could not copy the new file over %s', 'wpbr-bulk-replace'),
$live_path
), 'failed_overwrite_copy');
}
if ($written['stage'] === 'unverified') {
// The live file may be half a file. Put the original back and prove it, or protect the
// backup and stop (includes/recovery.php).
return wpbr_recover_failed_overwrite($item, $live_path, $backup_path, isset($written['observed']) ? (string) $written['observed'] : '');
}
// Same URL, new bytes: record when, so a link to this file can say so.
// See includes/asset-versions.php.
wpbr_asset_version_register($live_path);
$attachment_id = wpbr_find_attachment_id_by_url($file_url);
if ($attachment_id) {
require_once ABSPATH . 'wp-admin/includes/image.php';
$metadata = wp_generate_attachment_metadata($attachment_id, $live_path);
wp_update_attachment_metadata($attachment_id, $metadata);
wp_update_post(array('ID' => $attachment_id, 'post_modified' => current_time('mysql'), 'post_modified_gmt' => current_time('mysql', true)));
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate.
$wpdb->update($items_table, array(
'status' => 'replaced',
'backup_path' => $backup_path,
'processed_at' => current_time('mysql'),
) + wpbr_intent_committed(wpbr_file_sha256($item->staged_path)), array('id' => $item->id));
return wpbr_run_result('success', $item->staged_filename, sprintf(
/* translators: %s is a file path. */
__('replaced; backup kept at %s', 'wpbr-bulk-replace'),
$backup_path
), 'replaced');
}
function wpbr_process_first_time_attach($item, $batch, $unshare = false) {
global $wpdb;
$items_table = wpbr_table_items();
if (!file_exists($item->staged_path)) {
return wpbr_run_result('skipped', $item->staged_filename, __('skipped: staged file missing', 'wpbr-bulk-replace'), 'skipped_staged_missing');
}
$meta_keys = wpbr_split_meta_keys($batch->meta_key_name);
if (empty($meta_keys)) {
return wpbr_run_result('skipped', $item->staged_filename, __('skipped: no meta field configured to attach to', 'wpbr-bulk-replace'), 'skipped_no_reference_field');
}
// Whichever field holds the file, falling back to the first configured one.
// A batch can list several fields and the live file is found by taking the
// first that has a value, which is not necessarily the first configured.
// Writing to $meta_keys[0] regardless is harmless on a true first-time
// attach - nothing has a value, by definition - but wrong when unsharing:
// there the post does have a file, and putting the dedicated copy in a
// different field leaves the page still reading the shared one while the
// run reports success.
$current = wpbr_reference_current($batch, $item->matched_post_id);
$target_meta_key = $current['location'];
$previous_value = $current['value'];
$ext = strtolower(pathinfo($item->staged_filename, PATHINFO_EXTENSION));
if ($unshare || !$item->rename_to) {
// Respect the configured naming rather than imposing one: this path
// fires when two posts share a file and each needs its own copy, and
// it used to hardcode a template regardless of the rename policy.
$desired_filename = wpbr_apply_rename_template(
wpbr_default_rename_template(), $item->matched_post_id, $item->staged_filename, $ext
);
} else {
$desired_filename = sanitize_file_name($item->rename_to);
}
if (!$desired_filename) {
$desired_filename = sanitize_file_name($item->staged_filename);
}
$upload_dir = wp_upload_dir();
$unique_filename = wp_unique_filename($upload_dir['path'], $desired_filename);
$target_path = $upload_dir['path'] . '/' . $unique_filename;
$target_url = $upload_dir['url'] . '/' . $unique_filename;
if (!copy($item->staged_path, $target_path)) {
return wpbr_run_result('failed', $item->staged_filename, __('failed: copying to new uploads location', 'wpbr-bulk-replace'), 'failed_attach_copy');
}
require_once ABSPATH . 'wp-admin/includes/image.php'; // wp_generate_attachment_metadata(), used below
$filetype = wp_check_filetype($target_path, null);
$attachment = array(
'post_mime_type' => $filetype['type'],
'post_title' => sanitize_file_name(pathinfo($target_path, PATHINFO_FILENAME)),
'post_content' => '',
'post_status' => 'inherit',
'guid' => $target_url,
);
$attach_id = wp_insert_attachment($attachment, $target_path, $item->matched_post_id);
if (!$attach_id || is_wp_error($attach_id)) {
return wpbr_run_result('failed', $item->staged_filename, __('failed: creating attachment record', 'wpbr-bulk-replace'), 'failed_attach_record');
}
$attach_data = wp_generate_attachment_metadata($attach_id, $target_path);
wp_update_attachment_metadata($attach_id, $attach_data);
// Before the post is repointed, for the same reason as the rename path
// above. It matters most when unsharing, because there the post already
// had a working reference to a shared file and previous_meta_value is the
// only copy of it.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate.
if (false === $wpdb->update($items_table, array(
'created_attachment_id' => $attach_id,
'previous_meta_value' => $previous_value ? $previous_value : null,
), array('id' => $item->id))) {
wp_delete_attachment($attach_id, true);
return wpbr_run_result('failed', $item->staged_filename, __('failed: the new file was created but the database would not record how to undo it, so the post was left as it was. Nothing on the site has changed; try this row again.', 'wpbr-bulk-replace'), 'failed_undo_not_recorded');
}
// Same rule as the rename path above: a provider that will not accept the
// write has not replaced anything, and saying otherwise is worse than
// failing, because nobody goes looking for a page that reported success.
if (!wpbr_reference_update($batch, $item->matched_post_id, $target_meta_key, $target_url)) {
return wpbr_run_result('failed', $item->staged_filename, sprintf(
/* translators: %s is a field name. */
__('failed: the file was created but “%s” would not accept it, so the post is unchanged. Roll this row back to remove the file that was created.', 'wpbr-bulk-replace'),
$target_meta_key
), 'failed_reference_update');
}
if ($unshare) {
$note = trim(($item->notes ? $item->notes . ' | ' : '') . sprintf(
/* translators: 1: a new filename, 2: a Media Library attachment ID, 3: a meta field name, 4: a post ID. */
__('Unshared: this post previously shared a file with another post, so a new dedicated file (%1$s, attachment #%2$s) was created and “%3$s” on post %4$s repointed to it. The old shared file was left untouched. To undo, delete attachment #%2$s and restore the previous meta value.', 'wpbr-bulk-replace'),
$unique_filename, $attach_id, $target_meta_key, $item->matched_post_id
));
} else {
$note = trim(($item->notes ? $item->notes . ' | ' : '') . sprintf(
/* translators: 1: a Media Library attachment ID, 2: a meta field name, 3: a post ID. */
__('First-time attach: created attachment #%1$s and set “%2$s” on post %3$s. No backup exists because there was nothing to overwrite — to undo, delete attachment #%1$s and clear that meta field on the post.', 'wpbr-bulk-replace'),
$attach_id, $target_meta_key, $item->matched_post_id
));
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate.
$wpdb->update($items_table, array(
'status' => 'replaced',
'backup_path' => null,
'created_attachment_id' => $attach_id,
// Only ever set when unsharing. A first-time attach overwrote nothing,
// so there is nothing to put back and rollback should clear the field;
// an unshare replaced a working reference to a shared file, and losing
// that URL would leave the post with no file at all after an undo -
// worse than the state the reader asked to undo, and unrecoverable.
'previous_meta_value' => $previous_value ? $previous_value : null,
'notes' => $note,
'processed_at' => current_time('mysql'),
), array('id' => $item->id));
if ($unshare) {
return wpbr_run_result('success', $item->staged_filename, sprintf(
/* translators: 1: a new filename, 2: a meta field name, 3: a post ID. */
__('unshared: created the dedicated file %1$s and repointed “%2$s” on post %3$s', 'wpbr-bulk-replace'),
$unique_filename, $target_meta_key, number_format_i18n($item->matched_post_id)
), 'attached_unshared');
}
return wpbr_run_result('success', $item->staged_filename, sprintf(
/* translators: 1: a meta field name, 2: a post ID. */
__('attached for the first time; “%1$s” set on post %2$s', 'wpbr-bulk-replace'),
$target_meta_key,
number_format_i18n($item->matched_post_id)
), 'attached_new');
}
add_action('wp_ajax_wpbr_mark_verified', 'wpbr_ajax_mark_verified');
function wpbr_ajax_mark_verified() {
if (!current_user_can('manage_options')) wp_send_json_error(__('Not allowed', 'wpbr-bulk-replace'));
$batch_id = isset($_POST['batch_id']) ? intval($_POST['batch_id']) : 0;
check_ajax_referer('wpbr_process_chunk_' . $batch_id, 'nonce');
global $wpdb;
$ids = wpbr_clean_ids(explode(',', isset($_POST['ids']) ? sanitize_text_field(wp_unslash($_POST['ids'])) : ''));
if (!$ids) wp_send_json_success(array('log' => array(__('Nothing selected.', 'wpbr-bulk-replace')), 'processed' => 0));
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate.
$n = $wpdb->query($wpdb->prepare(
"UPDATE {$wpdb->prefix}wpbr_batch_items SET status = 'verified'
WHERE batch_id = %d AND id IN (" . implode(',', array_fill(0, count($ids), '%d')) . ") AND status = 'replaced'",
array_merge(array($batch_id), $ids)
));
$verified_msg = sprintf(
/* translators: %s is a number of rows. */
_n('Marked %s row as verified.', 'Marked %s rows as verified.', intval($n), 'wpbr-bulk-replace'),
number_format_i18n(intval($n))
);
wpbr_log($batch_id, null, null, 'mark_verified', 'success', $verified_msg);
wp_send_json_success(array(
'log' => array($verified_msg),
'processed' => intval($n),
));
}
add_action('wp_ajax_wpbr_delete_backups', 'wpbr_ajax_delete_backups');
function wpbr_ajax_delete_backups() {
if (!current_user_can('manage_options')) wp_send_json_error(__('Not allowed', 'wpbr-bulk-replace'));
$batch_id = isset($_POST['batch_id']) ? intval($_POST['batch_id']) : 0;
check_ajax_referer('wpbr_process_chunk_' . $batch_id, 'nonce');
global $wpdb;
$items_table = wpbr_table_items();
$ids = wpbr_clean_ids(explode(',', isset($_POST['ids']) ? sanitize_text_field(wp_unslash($_POST['ids'])) : ''));
if (!$ids) wp_send_json_success(array('log' => array(__('Nothing selected.', 'wpbr-bulk-replace')), 'processed' => 0));
$items = wpbr_get_batch_items_in($batch_id, $ids);
$log = array();
$done = 0;
foreach ($items as $item) {
if ($item->status !== 'verified') {
$log[] = wpbr_run_log_line($item->staged_filename, __('skipped, not marked verified yet', 'wpbr-bulk-replace'));
continue;
}
if ($item->created_attachment_id) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate.
$wpdb->update($items_table, array('status' => 'cleaned_up'), array('id' => $item->id));
$log[] = wpbr_run_log_line($item->staged_filename, __('nothing to delete (new attachment, no backup existed)', 'wpbr-bulk-replace'));
$done++;
continue;
}
if (!$item->backup_path || !file_exists($item->backup_path)) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate.
$wpdb->update($items_table, array('status' => 'cleaned_up'), array('id' => $item->id));
$log[] = wpbr_run_log_line($item->staged_filename, __('backup already gone', 'wpbr-bulk-replace'));
$done++;
continue;
}
if (!wpbr_path_in_storage_roots($item->backup_path)) {
$log[] = wpbr_run_log_line($item->staged_filename, __('REFUSED, backup path is outside the backups folder', 'wpbr-bulk-replace'));
continue;
}
$removed = wpbr_delete_backup($item->backup_path);
if (!$removed['deleted']) {
$log[] = wpbr_run_log_line($item->staged_filename, $removed['reason'] === 'protected'
? __('kept: this backup is the only verified copy of a file that could not be put back, so it is protected', 'wpbr-bulk-replace')
: __('could not delete the backup', 'wpbr-bulk-replace'));
continue;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate.
$wpdb->update($items_table, array('status' => 'cleaned_up'), array('id' => $item->id));
$log[] = wpbr_run_log_line($item->staged_filename, __('backup deleted, this row can no longer be rolled back', 'wpbr-bulk-replace'));
wpbr_log($batch_id, $item->id, $item->staged_filename, 'delete_backup', 'success', __('Backup deleted — rollback no longer possible', 'wpbr-bulk-replace'));
$done++;
}
wp_send_json_success(array('log' => $log, 'processed' => $done));
}
/**
* Diagnostic: walk the same checks the processor does for one item and report
* each one, without changing anything.
* /wp-admin/admin-post.php?action=wpbr_diagnose&item_id=123
*/
add_action('admin_post_wpbr_diagnose', 'wpbr_diagnose_item');
/**
* Per-row diagnostic dump.
*
* Deliberately not translated. This is a plain-text technical report, gated
* behind an opt-in setting and a signed link, and its whole purpose is to be
* pasted into a support request. A translated dump is materially harder to read
* on the receiving end than an English one, and nobody reads it for pleasure.
* The surrounding UI - the button, the description, the refusal messages - is
* translated, because that is what an admin navigates.
*/
function wpbr_diagnose_item() {
wpbr_guard_diagnostic();
$link_nonce = isset($_GET['wpbr_diag_nonce']) ? sanitize_text_field(wp_unslash($_GET['wpbr_diag_nonce'])) : '';
if (!wp_verify_nonce($link_nonce, 'wpbr_diagnose')) {
wp_die(esc_html__('Invalid or missing security token. Open diagnostics from the Settings page so the link is signed.', 'wpbr-bulk-replace'));
}
global $wpdb;
$item_id = isset($_GET['item_id']) ? intval($_GET['item_id']) : 0;
$filename = isset($_GET['filename']) ? sanitize_text_field(wp_unslash($_GET['filename'])) : '';
if ($item_id) {
$item = wpbr_get_item($item_id);
} elseif ($filename) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; the screen shows live state, and a cached copy would lag the write the person just made.
$item = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}wpbr_batch_items WHERE staged_filename LIKE %s LIMIT 1",
'%'.$wpdb->esc_like($filename).'%'
));
} else {
wp_die('Pass item_id=123 or filename=partial-name');
}
if (!$item) wp_die('No item found.');
wpbr_send_plain_report(wpbr_diagnosis_report($item));
}
/** The text of the per-row diagnosis for an item, as the endpoint sends it. */
function wpbr_diagnosis_report($item) {
$batch = wpbr_get_batch($item->batch_id);
$upload_dir = wp_upload_dir();
$out = '';
$out .= "ITEM\n";
$out .= " id : ".$item->id."\n";
$out .= " staged_filename : ".$item->staged_filename."\n";
$out .= " status : ".$item->status."\n";
$out .= " matched_post_id : ".$item->matched_post_id."\n";
$out .= " staged_path : ".$item->staged_path."\n";
$out .= " staged file exists : ".(($item->staged_path && file_exists($item->staged_path)) ? 'YES' : 'NO')."\n";
$out .= " rename_to : ".$item->rename_to."\n";
$out .= " notes : ".$item->notes."\n\n";
$out .= "ELIGIBILITY\n";
$eligible = in_array($item->status, array('matched','confirmed_new_attach'), true);
$out .= " status is processable : ".($eligible ? 'YES' : 'NO - only matched / confirmed_new_attach are processed')."\n\n";
$out .= "UPLOADS\n";
$out .= " basedir : ".$upload_dir['basedir']."\n";
$out .= " baseurl : ".$upload_dir['baseurl']."\n";
$out .= " basedir writable : ".(wp_is_writable($upload_dir['basedir']) ? 'YES' : 'NO')."\n\n";
$out .= "TARGET POST\n";
if (!$item->matched_post_id) {
$out .= " (no matched post - would be SKIPPED)\n\n";
} else {
$post = get_post($item->matched_post_id);
$out .= " post exists : ".($post ? 'YES ('.$post->post_type.', '.$post->post_status.')' : 'NO')."\n";
$out .= " title : ".($post ? $post->post_title : '-')."\n";
$meta_keys = wpbr_reference_locations($batch);
$file_url = '';
foreach (wpbr_reference_map($batch, $item->matched_post_id) as $mk => $v) {
$out .= " meta[".$mk."] : ".($v !== '' ? $v : '(empty)')."\n";
if ($v && !$file_url) $file_url = $v;
}
$out .= "\n";
if ($file_url) {
$out .= "OVERWRITE PATH RESOLUTION\n";
$resolved = wpbr_url_to_upload_path($file_url);
$out .= " stored url : ".$file_url."\n";
$out .= " resolved path : ".($resolved ?: '(FAILED to resolve)')."\n";
$out .= " inside uploads : ".($resolved && wpbr_path_is_in_uploads($resolved) ? 'YES' : 'NO')."\n";
$out .= " live file exists : ".($resolved && file_exists($resolved) ? 'YES' : 'NO')."\n";
$out .= " live file writable : ".($resolved && file_exists($resolved) && wp_is_writable($resolved) ? 'YES' : 'NO')."\n";
} else {
$out .= "NO EXISTING FILE - would create a new attachment\n";
$out .= " needs status confirmed_new_attach : ".($item->status === 'confirmed_new_attach' ? 'YES, ok' : 'NO - currently '.$item->status.', would be SKIPPED')."\n";
}
$out .= "\n";
}
$out .= "BACKUPS\n";
$bdir = wpbr_backups_dir();
$out .= " dir : ".$bdir."\n";
$out .= " exists : ".(is_dir($bdir) ? 'YES' : 'NO')."\n";
$out .= " writable : ".(is_dir($bdir) && wp_is_writable($bdir) ? 'YES' : 'NO')."\n\n";
$out .= "SHARED-FILE CHECK\n";
$all = wpbr_get_batch_items($item->batch_id);
$shared = wpbr_destination_shared_with_another_post($item, $batch, wpbr_shared_destination_paths($batch, $all));
$out .= " shares its PDF : ".($shared ? 'YES - would create a new file instead of overwriting' : 'no')."\n\n";
$out .= "COLLISIONS\n";
$col = wpbr_detect_collisions($item->batch_id, $all, $batch);
$out .= " blocking : ".count($col['blocking'])." (any blocking conflict stops the whole run)\n";
$out .= " auto-resolved : ".count($col['auto'])."\n\n";
// ---- is the code on the server actually current? ----
$out .= "PLUGIN FILES\n";
$expect = array(
'matching.php' => 'wpbr_token_is_shorthand',
'paths.php' => 'wpbr_url_to_upload_path',
'ajax.php' => 'wpbr_rollback_chunk',
'db.php' => 'wpbr_table_log',
'admin-batch-detail.php' => 'wpbr_resolve_destination',
'admin-log.php' => 'wpbr_render_log_page',
'admin-settings.php' => 'wpbr_render_settings_page',
'admin-batches.php' => 'wpbr_handle_delete_batch',
'admin-help.php' => 'wpbr_render_help_page',
);
foreach ($expect as $file => $fn) {
$path = WPBR_PLUGIN_DIR . 'includes/' . $file;
$mtime = file_exists($path) ? gmdate('Y-m-d H:i', filemtime($path)) : 'missing';
$loaded = function_exists($fn) ? 'loaded' : 'NOT LOADED - file is stale or upload did not land';
$out .= " ".str_pad($file, 24)." ".str_pad($mtime, 18)." ".$loaded."\n";
}
$out .= "\n db version recorded : ".get_option('wpbr_db_version')."\n";
$out .= " db version expected : ".WPBR_DB_VERSION."\n";
if (function_exists('opcache_get_status')) {
$st = @opcache_get_status(false);
$out .= " opcache : ".(!empty($st['opcache_enabled']) ? 'enabled' : 'off')."\n";
}
return $out;
}