Files
peptiderepo-r2-manager/includes/class-r2-ajax.php
peptiderepo 3cb8cd7c7d feat: initial release v1.1.0
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
2026-04-25 08:48:01 +00:00

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;
}
}