Cloudflare R2 media offload manager for peptiderepo.com. Built on WP Offload Media Lite. Supersedes the one-off migration script and mu-plugin peptiderepo-r2-endpoint.php. Features: - Admin page under Peptide Repo > R2 Media Manager (shared umbrella menu) - ensure_umbrella_menu() guard — safe for multi-plugin umbrella sharing - Batch AJAX migration to R2 with pause/resume and progress bar - Remove-local-copies batch with physical disk presence check - Activity log table (wp_pr_r2_log) with self-healing on plugins_loaded - Test R2 connection button - R2 endpoint injection via as3cf_aws_s3_client_args filter
293 lines
11 KiB
PHP
Executable File
293 lines
11 KiB
PHP
Executable File
<?php
|
|
/**
|
|
* What: AJAX handlers for: batch migrate-to-R2, batch remove-local-files,
|
|
* test R2 connection, and clear log. All actions are admin-only and
|
|
* nonce-protected.
|
|
* Who: wp_ajax_ hooks registered by PR_R2_Ajax::init(). Triggered by the
|
|
* JS in assets/r2-manager.js via jQuery.post().
|
|
* Depends: global $as3cf (WP Offload Media Lite), PR_R2_Log, PR_R2_Admin_Page.
|
|
*
|
|
* @see includes/class-r2-admin-page.php (renders the buttons that trigger these actions)
|
|
* @see includes/class-r2-log.php (receives log writes after each file operation)
|
|
* @see assets/r2-manager.js (drives the batch loop + progress bar)
|
|
*
|
|
* AJAX actions:
|
|
* pr_r2_migrate_batch — upload a batch of unprocessed attachments to R2
|
|
* pr_r2_remove_local_batch — delete local copies for a batch of offloaded attachments
|
|
* pr_r2_test_connection — verify the R2 bucket is reachable
|
|
* pr_r2_clear_log — truncate wp_pr_r2_log
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
class PR_R2_Ajax {
|
|
|
|
/** Attachments processed per AJAX call. 10 keeps each call well under 30s. */
|
|
const BATCH_SIZE = 10;
|
|
|
|
public static function init(): void {
|
|
add_action( 'wp_ajax_pr_r2_migrate_batch', [ __CLASS__, 'handle_migrate_batch' ] );
|
|
add_action( 'wp_ajax_pr_r2_remove_local_batch', [ __CLASS__, 'handle_remove_local_batch' ] );
|
|
add_action( 'wp_ajax_pr_r2_test_connection', [ __CLASS__, 'handle_test_connection' ] );
|
|
add_action( 'wp_ajax_pr_r2_clear_log', [ __CLASS__, 'handle_clear_log' ] );
|
|
}
|
|
|
|
// ── Migrate batch ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Upload one batch of not-yet-offloaded attachments to R2.
|
|
*
|
|
* POST params: nonce, batch_size (optional int, capped at 50).
|
|
* Returns JSON: { processed, succeeded, failed, remaining, done }.
|
|
*
|
|
* Side effects: uploads files to R2, writes as3cf_items rows, writes log rows.
|
|
*/
|
|
public static function handle_migrate_batch(): void {
|
|
self::verify_nonce( 'pr_r2_migrate' );
|
|
global $as3cf;
|
|
if ( ! $as3cf ) {
|
|
wp_send_json_error( [ 'message' => 'WP Offload Media Lite is not active.' ] );
|
|
}
|
|
$batch_size = min( 50, max( 1, (int) ( $_POST['batch_size'] ?? self::BATCH_SIZE ) ) );
|
|
$ids = self::get_not_offloaded_ids( $batch_size );
|
|
if ( empty( $ids ) ) {
|
|
wp_send_json_success( [ 'processed' => 0, 'succeeded' => 0, 'failed' => 0, 'remaining' => 0, 'done' => true ] );
|
|
}
|
|
$upload_handler = $as3cf->get_item_handler( 'upload' );
|
|
$succeeded = 0;
|
|
$failed = 0;
|
|
foreach ( $ids as $id ) {
|
|
$id = (int) $id;
|
|
$file_path = get_attached_file( $id ) ?: '';
|
|
try {
|
|
$item = \DeliciousBrains\WP_Offload_Media\Items\Media_Library_Item::create_from_source_id( $id );
|
|
if ( ! $item ) {
|
|
PR_R2_Log::write( $id, $file_path, 'migrate', PR_R2_Log::STATUS_SKIP, 'create_from_source_id returned null' );
|
|
$failed++;
|
|
continue;
|
|
}
|
|
$result = $upload_handler->handle( $item );
|
|
if ( is_wp_error( $result ) ) {
|
|
PR_R2_Log::write( $id, $file_path, 'migrate', PR_R2_Log::STATUS_ERROR, $result->get_error_message() );
|
|
$failed++;
|
|
} else {
|
|
PR_R2_Log::write( $id, $file_path, 'migrate', PR_R2_Log::STATUS_OK );
|
|
$succeeded++;
|
|
}
|
|
} catch ( \Exception $e ) {
|
|
PR_R2_Log::write( $id, $file_path, 'migrate', PR_R2_Log::STATUS_ERROR, $e->getMessage() );
|
|
$failed++;
|
|
}
|
|
}
|
|
$remaining = self::count_not_offloaded();
|
|
wp_send_json_success( [
|
|
'processed' => count( $ids ),
|
|
'succeeded' => $succeeded,
|
|
'failed' => $failed,
|
|
'remaining' => $remaining,
|
|
'done' => $remaining === 0,
|
|
] );
|
|
}
|
|
|
|
// ── Remove-local batch ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Delete local file copies for one batch of already-offloaded attachments.
|
|
*
|
|
* POST params: nonce, batch_size (optional int, capped at 50).
|
|
* Returns JSON: { processed, removed, skipped, remaining, done }.
|
|
*
|
|
* Side effects: deletes local files from Hostinger disk, writes log rows.
|
|
*/
|
|
public static function handle_remove_local_batch(): void {
|
|
self::verify_nonce( 'pr_r2_remove_local' );
|
|
global $as3cf;
|
|
if ( ! $as3cf ) {
|
|
wp_send_json_error( [ 'message' => 'WP Offload Media Lite is not active.' ] );
|
|
}
|
|
$batch_size = min( 50, max( 1, (int) ( $_POST['batch_size'] ?? self::BATCH_SIZE ) ) );
|
|
$ids = self::get_local_remaining_ids( $batch_size );
|
|
if ( empty( $ids ) ) {
|
|
wp_send_json_success( [ 'processed' => 0, 'removed' => 0, 'skipped' => 0, 'remaining' => 0, 'done' => true ] );
|
|
}
|
|
$remove_handler = $as3cf->get_item_handler( 'remove-local' );
|
|
$removed = 0;
|
|
$skipped = 0;
|
|
foreach ( $ids as $id ) {
|
|
$id = (int) $id;
|
|
$file_path = get_attached_file( $id ) ?: '';
|
|
try {
|
|
$item = \DeliciousBrains\WP_Offload_Media\Items\Media_Library_Item::get_by_source_id( $id );
|
|
if ( ! $item ) {
|
|
PR_R2_Log::write( $id, $file_path, 'remove-local', PR_R2_Log::STATUS_SKIP, 'no as3cf_items row' );
|
|
$skipped++;
|
|
continue;
|
|
}
|
|
$result = $remove_handler->handle( $item );
|
|
if ( is_wp_error( $result ) ) {
|
|
PR_R2_Log::write( $id, $file_path, 'remove-local', PR_R2_Log::STATUS_ERROR, $result->get_error_message() );
|
|
$skipped++;
|
|
} else {
|
|
PR_R2_Log::write( $id, $file_path, 'remove-local', PR_R2_Log::STATUS_OK );
|
|
$removed++;
|
|
}
|
|
} catch ( \Exception $e ) {
|
|
PR_R2_Log::write( $id, $file_path, 'remove-local', PR_R2_Log::STATUS_ERROR, $e->getMessage() );
|
|
$skipped++;
|
|
}
|
|
}
|
|
$remaining = self::count_local_remaining();
|
|
wp_send_json_success( [
|
|
'processed' => count( $ids ),
|
|
'removed' => $removed,
|
|
'skipped' => $skipped,
|
|
'remaining' => $remaining,
|
|
'done' => $remaining === 0,
|
|
] );
|
|
}
|
|
|
|
// ── Test R2 connection ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Verify the R2 bucket is reachable via a read-only ListObjects call.
|
|
*
|
|
* POST params: nonce.
|
|
* Returns JSON: success { message } | error { message }.
|
|
*
|
|
* Side effects: one S3 ListObjectsV2 API call.
|
|
*/
|
|
public static function handle_test_connection(): void {
|
|
self::verify_nonce( 'pr_r2_test' );
|
|
global $as3cf;
|
|
if ( ! $as3cf ) {
|
|
wp_send_json_error( [ 'message' => 'WP Offload Media Lite is not active.' ] );
|
|
}
|
|
try {
|
|
$provider = $as3cf->get_storage_provider();
|
|
$client = $provider->get_client();
|
|
$bucket = $as3cf->get_setting( 'bucket' );
|
|
$client->list_objects( [ 'Bucket' => $bucket, 'MaxKeys' => 1 ] );
|
|
wp_send_json_success( [ 'message' => "R2 connection OK — bucket '{$bucket}' is accessible." ] );
|
|
} catch ( \Exception $e ) {
|
|
wp_send_json_error( [ 'message' => 'Connection failed: ' . $e->getMessage() ] );
|
|
}
|
|
}
|
|
|
|
// ── Clear log ──────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Truncate the wp_pr_r2_log table.
|
|
*
|
|
* POST params: nonce.
|
|
* Returns JSON: success { message }.
|
|
*
|
|
* Side effects: deletes all rows from wp_pr_r2_log.
|
|
*/
|
|
public static function handle_clear_log(): void {
|
|
self::verify_nonce( 'pr_r2_clear_log' );
|
|
PR_R2_Log::clear();
|
|
wp_send_json_success( [ 'message' => 'Log cleared.' ] );
|
|
}
|
|
|
|
// ── Private helpers ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Verify nonce and capability; send JSON error and die on failure.
|
|
*
|
|
* @param string $action Nonce action string.
|
|
* @return void
|
|
*/
|
|
private static function verify_nonce( string $action ): void {
|
|
if ( ! current_user_can( PR_R2_Admin_Page::CAPABILITY ) ) {
|
|
wp_send_json_error( [ 'message' => 'Permission denied.' ], 403 );
|
|
}
|
|
if ( ! check_ajax_referer( $action, 'nonce', false ) ) {
|
|
wp_send_json_error( [ 'message' => 'Invalid or expired nonce.' ], 403 );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return up to $limit attachment IDs with no row in as3cf_items.
|
|
* Always queries from the start — processed items drop out of the result set.
|
|
*
|
|
* @param int $limit
|
|
* @return int[]
|
|
*/
|
|
private static function get_not_offloaded_ids( int $limit ): array {
|
|
global $wpdb;
|
|
$table = $wpdb->prefix . 'as3cf_items';
|
|
return array_map( 'intval', $wpdb->get_col( $wpdb->prepare(
|
|
"SELECT p.ID FROM {$wpdb->posts} p
|
|
LEFT JOIN {$table} oi ON oi.source_id = p.ID AND oi.source_type = 'media-library'
|
|
WHERE p.post_type = 'attachment' AND p.post_status = 'inherit'
|
|
AND oi.id IS NULL
|
|
LIMIT %d",
|
|
$limit
|
|
) ) );
|
|
}
|
|
|
|
/** @return int */
|
|
private static function count_not_offloaded(): int {
|
|
global $wpdb;
|
|
$table = $wpdb->prefix . 'as3cf_items';
|
|
return (int) $wpdb->get_var(
|
|
"SELECT COUNT(p.ID) FROM {$wpdb->posts} p
|
|
LEFT JOIN {$table} oi ON oi.source_id = p.ID AND oi.source_type = 'media-library'
|
|
WHERE p.post_type = 'attachment' AND p.post_status = 'inherit'
|
|
AND oi.id IS NULL"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Return up to $limit attachment IDs that are offloaded but have local copies.
|
|
* Checks physical disk presence — WP Offload Media does not rewrite
|
|
* _wp_attached_file in the DB; s3auto:// is applied at runtime via filter only.
|
|
*
|
|
* @param int $limit
|
|
* @return int[]
|
|
*/
|
|
private static function get_local_remaining_ids( int $limit ): array {
|
|
global $wpdb;
|
|
$table = $wpdb->prefix . 'as3cf_items';
|
|
$upload_base = wp_upload_dir()['basedir'];
|
|
$all_ids = $wpdb->get_col(
|
|
"SELECT DISTINCT oi.source_id FROM {$table} oi
|
|
INNER JOIN {$wpdb->posts} p ON p.ID = oi.source_id
|
|
WHERE oi.source_type = 'media-library' AND p.post_type = 'attachment'"
|
|
);
|
|
$result = [];
|
|
foreach ( $all_ids as $id ) {
|
|
if ( count( $result ) >= $limit ) {
|
|
break;
|
|
}
|
|
$rel = get_post_meta( (int) $id, '_wp_attached_file', true );
|
|
if ( $rel && file_exists( $upload_base . '/' . $rel ) ) {
|
|
$result[] = (int) $id;
|
|
}
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
/** @return int */
|
|
private static function count_local_remaining(): int {
|
|
global $wpdb;
|
|
$table = $wpdb->prefix . 'as3cf_items';
|
|
$upload_base = wp_upload_dir()['basedir'];
|
|
$all_ids = $wpdb->get_col(
|
|
"SELECT DISTINCT oi.source_id FROM {$table} oi
|
|
INNER JOIN {$wpdb->posts} p ON p.ID = oi.source_id
|
|
WHERE oi.source_type = 'media-library' AND p.post_type = 'attachment'"
|
|
);
|
|
$count = 0;
|
|
foreach ( $all_ids as $id ) {
|
|
$rel = get_post_meta( (int) $id, '_wp_attached_file', true );
|
|
if ( $rel && file_exists( $upload_base . '/' . $rel ) ) {
|
|
$count++;
|
|
}
|
|
}
|
|
return $count;
|
|
}
|
|
}
|