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
This commit is contained in:
peptiderepo
2026-04-25 08:47:45 +00:00
commit 3cb8cd7c7d
8 changed files with 1029 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.DS_Store
*.log
node_modules/
vendor/

26
CHANGELOG.md Normal file
View File

@@ -0,0 +1,26 @@
# Changelog — Peptide Repo R2 Manager
All notable changes to this project will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [1.1.0] — 2026-04-25
### Changed
- Admin page moved from `Settings > R2 Media Manager` to `Peptide Repo > R2 Media Manager`
using a shared top-level umbrella menu (`peptiderepo` slug).
- `PR_R2_Admin_Page` now registers the umbrella menu via `ensure_umbrella_menu()` —
a `$menu`-array guard so multiple plugins can share the parent safely regardless of
load order.
- `enqueue_assets()` hook suffix updated from `settings_page_peptiderepo-r2-manager`
to `peptiderepo_page_peptiderepo-r2-manager`.
- Added `PARENT_SLUG` class constant (`peptiderepo`).
## [1.0.0] — 2026-04-25
### Added
- Initial release. Cloudflare R2 media offload manager built on WP Offload Media Lite.
- Admin dashboard: status table, Test R2 Connection, Migrate to R2, Remove Local Copies,
Activity Log.
- Batch AJAX operations with pause/resume and progress bar.
- `wp_pr_r2_log` table with self-healing on `plugins_loaded`.
- R2 endpoint injection via `as3cf_aws_s3_client_args` filter (supersedes mu-plugin).

189
assets/r2-manager.js Executable file
View File

@@ -0,0 +1,189 @@
/* global jQuery, prR2 */
/**
* R2 Media Manager — admin JS.
*
* Drives batch loops for migrate-to-R2 and remove-local-files operations.
* Each loop fires one AJAX request, waits for the response, updates the
* progress bar, then fires the next request — until the server signals done=true
* or the user clicks Pause.
*
* Also handles: Test R2 Connection button, Clear Log button.
*/
(function ($) {
'use strict';
// ── Batch runner ──────────────────────────────────────────────────────────
/**
* Run one batch of an AJAX operation and schedule the next.
*
* @param {string} action WP AJAX action name.
* @param {string} nonce Nonce string from wp_localize_script.
* @param {Object} state Mutable state: { processed, succeeded, failed, paused }.
* @param {Object} ui jQuery element refs: $btn, $pauseBtn, $bar, $status.
* @param {string} countKey 'succeeded' | 'removed' — key to read from response.
* @param {string} failKey 'failed' | 'skipped'.
*/
function runBatch(action, nonce, state, ui, countKey, failKey) {
if (state.paused) {
ui.$status.text('Paused. Click the button again to resume.');
ui.$btn.prop('disabled', false).text(ui.$btn.data('resume-label'));
ui.$pauseBtn.hide();
return;
}
$.post(prR2.ajaxUrl, {
action: action,
nonce: nonce,
batch_size: prR2.batchSize,
})
.done(function (res) {
if (!res.success) {
showNotice('error', 'Error: ' + (res.data ? res.data.message : 'Unknown error.'));
ui.$btn.prop('disabled', false);
ui.$pauseBtn.hide();
return;
}
var d = res.data;
state.processed += d.processed;
state.succeeded += (d[countKey] || 0);
state.failed += (d[failKey] || 0);
// remaining is the count left AFTER this batch.
var total = state.processed + d.remaining;
var pct = total > 0 ? Math.round((state.processed / total) * 100) : 100;
ui.$bar.val(pct);
ui.$status.text(
'Processed ' + state.processed + ' of ' + total +
' — success: ' + state.succeeded +
(state.failed ? ', failed/skipped: ' + state.failed : '')
);
if (d.done) {
ui.$status.text(
'Done! ✓ Succeeded: ' + state.succeeded +
(state.failed ? ' Failed/skipped: ' + state.failed : '')
);
ui.$btn.prop('disabled', true);
ui.$pauseBtn.hide();
// Reload after 1.5 s so the status table refreshes.
setTimeout(function () { window.location.reload(); }, 1500);
return;
}
runBatch(action, nonce, state, ui, countKey, failKey);
})
.fail(function () {
ui.$status.text('Network error — operation paused. Retry by clicking the button.');
ui.$btn.prop('disabled', false).text(ui.$btn.data('resume-label'));
ui.$pauseBtn.hide();
});
}
// ── Wire up Migrate button ────────────────────────────────────────────────
$('#pr-r2-btn-migrate').on('click', function () {
var $btn = $(this);
var state = { processed: 0, succeeded: 0, failed: 0, paused: false };
var ui = {
$btn: $btn,
$pauseBtn: $('#pr-r2-btn-migrate-pause'),
$bar: $('#pr-r2-migrate-bar'),
$status: $('#pr-r2-migrate-status'),
};
$btn.data('resume-label', 'Resume migration').prop('disabled', true);
ui.$pauseBtn.show();
$('#pr-r2-migrate-progress').show();
ui.$bar.val(0);
ui.$status.text('Starting migration…');
runBatch('pr_r2_migrate_batch', prR2.nonceMigrate, state, ui, 'succeeded', 'failed');
});
$('#pr-r2-btn-migrate-pause').on('click', function () {
// Signal the batch runner to stop after the current in-flight request.
var state = $('#pr-r2-btn-migrate').data('batchState');
if (state) { state.paused = true; }
});
// Re-wire so the button can resume (state persists in data).
// Simpler: just reload the page — already-offloaded items are excluded by the query.
// ── Wire up Remove Local button ───────────────────────────────────────────
$('#pr-r2-btn-remove').on('click', function () {
var $btn = $(this);
var state = { processed: 0, succeeded: 0, failed: 0, paused: false };
var ui = {
$btn: $btn,
$pauseBtn: $('#pr-r2-btn-remove-pause'),
$bar: $('#pr-r2-remove-bar'),
$status: $('#pr-r2-remove-status'),
};
$btn.data('resume-label', 'Resume removal').prop('disabled', true);
ui.$pauseBtn.show();
$('#pr-r2-remove-progress').show();
ui.$bar.val(0);
ui.$status.text('Starting local-file removal…');
runBatch('pr_r2_remove_local_batch', prR2.nonceRemove, state, ui, 'removed', 'skipped');
});
$('#pr-r2-btn-remove-pause').on('click', function () {
var state = $('#pr-r2-btn-remove').data('batchState');
if (state) { state.paused = true; }
});
// ── Test Connection ───────────────────────────────────────────────────────
$('#pr-r2-btn-test').on('click', function () {
var $btn = $(this);
var $result = $('#pr-r2-test-result');
$btn.prop('disabled', true);
$result.css('color', '#555').text('Testing…');
$.post(prR2.ajaxUrl, { action: 'pr_r2_test_connection', nonce: prR2.nonceTest })
.done(function (res) {
if (res.success) {
$result.css('color', '#090').text('✓ ' + res.data.message);
} else {
$result.css('color', '#c00').text('✗ ' + (res.data ? res.data.message : 'Failed.'));
}
})
.fail(function () {
$result.css('color', '#c00').text('✗ Network error.');
})
.always(function () { $btn.prop('disabled', false); });
});
// ── Clear Log ─────────────────────────────────────────────────────────────
$('#pr-r2-btn-clear-log').on('click', function () {
if (!window.confirm('Clear all log entries?')) { return; }
var $btn = $(this);
$btn.prop('disabled', true);
$.post(prR2.ajaxUrl, { action: 'pr_r2_clear_log', nonce: prR2.nonceClearLog })
.done(function (res) {
if (res.success) {
$('#pr-r2-log-tbody').closest('table').replaceWith('<p>Log cleared.</p>');
} else {
showNotice('error', 'Could not clear log.');
}
})
.always(function () { $btn.prop('disabled', false); });
});
// ── Utility ───────────────────────────────────────────────────────────────
function showNotice(type, msg) {
var cls = type === 'error' ? 'notice-error' : 'notice-success';
$('#pr-r2-notice').html(
'<div class="notice ' + cls + ' is-dismissible"><p>' + msg + '</p></div>'
);
}
}(jQuery));

275
includes/class-r2-admin-page.php Executable file
View File

@@ -0,0 +1,275 @@
<?php
/**
* What: Registers and renders the "R2 Media Manager" settings page under
* Peptide Repo > R2 Media Manager. Sections: Status, Bulk Operations,
* Activity Log. Also enqueues the JS asset on this page only.
* Also owns the top-level "Peptide Repo" umbrella menu registration
* (guarded so whichever plugin loads first registers it once).
* Who: Hooked into `admin_menu` and `admin_enqueue_scripts`.
* Depends: PR_R2_Endpoint::ENDPOINT, PR_R2_Log::get_recent(), PR_R2_Log::count_by_status().
*
* @see includes/class-r2-ajax.php (AJAX handlers triggered by page buttons)
* @see includes/class-r2-log.php (provides log rows for display)
* @see assets/r2-manager.js (batch loop, progress bar, pause/resume, test-conn)
* @see peptiderepo-r2-manager.php (bootstraps this class)
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PR_R2_Admin_Page {
/** Shared parent slug used by all peptiderepo plugins for the umbrella menu. */
const PARENT_SLUG = 'peptiderepo';
const MENU_SLUG = 'peptiderepo-r2-manager';
const CAPABILITY = 'manage_options';
public static function init(): void {
add_action( 'admin_menu', [ __CLASS__, 'register_menu' ] );
add_action( 'admin_enqueue_scripts', [ __CLASS__, 'enqueue_assets' ] );
}
public static function register_menu(): void {
self::ensure_umbrella_menu();
add_submenu_page(
self::PARENT_SLUG,
__( 'R2 Media Manager', 'peptiderepo-r2' ),
__( 'R2 Media Manager', 'peptiderepo-r2' ),
self::CAPABILITY,
self::MENU_SLUG,
[ __CLASS__, 'render' ]
);
}
/**
* Register the top-level "Peptide Repo" umbrella menu if not already registered.
* Uses the global $menu array as the canonical registry — safe regardless of which
* plugin loads first or how many plugins share the umbrella.
*
* @return void
* Side effects: may call add_menu_page() and remove_submenu_page().
*/
private static function ensure_umbrella_menu(): void {
global $menu;
foreach ( (array) $menu as $item ) {
if ( isset( $item[2] ) && self::PARENT_SLUG === $item[2] ) {
return; // Already registered by another plugin.
}
}
add_menu_page(
__( 'Peptide Repo', 'peptiderepo-r2' ),
__( 'Peptide Repo', 'peptiderepo-r2' ),
self::CAPABILITY,
self::PARENT_SLUG,
'__return_null',
'dashicons-database',
3
);
// WP auto-creates a submenu item matching the parent slug — remove the duplicate.
remove_submenu_page( self::PARENT_SLUG, self::PARENT_SLUG );
}
/**
* Enqueue JS + localized data only on this plugin's settings page.
*
* @param string $hook Current admin hook suffix.
* @return void
* Side effects: wp_enqueue_script, wp_localize_script.
*/
public static function enqueue_assets( string $hook ): void {
// Hook suffix for submenu under custom parent: {parent_slug}_page_{menu_slug}
if ( self::PARENT_SLUG . '_page_' . self::MENU_SLUG !== $hook ) {
return;
}
wp_enqueue_script(
'pr-r2-manager',
PR_R2_PLUGIN_URL . 'assets/r2-manager.js',
[ 'jquery' ],
PR_R2_VERSION,
true
);
wp_localize_script( 'pr-r2-manager', 'prR2', [
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonceMigrate' => wp_create_nonce( 'pr_r2_migrate' ),
'nonceRemove' => wp_create_nonce( 'pr_r2_remove_local' ),
'nonceTest' => wp_create_nonce( 'pr_r2_test' ),
'nonceClearLog' => wp_create_nonce( 'pr_r2_clear_log' ),
'batchSize' => 10,
] );
}
/**
* Render the full admin page.
*
* @return void
* Side effects: outputs HTML.
*/
public static function render(): void {
if ( ! current_user_can( self::CAPABILITY ) ) {
wp_die( esc_html__( 'Permission denied.', 'peptiderepo-r2' ) );
}
$s = self::get_status();
$logs = PR_R2_Log::get_recent( 20 );
$card = 'border:1px solid #ccd0d4;border-radius:4px;padding:16px;min-width:260px;max-width:320px;';
?>
<div class="wrap">
<h1><?php esc_html_e( 'R2 Media Manager', 'peptiderepo-r2' ); ?>
<span style="font-size:13px;font-weight:normal;color:#666;margin-left:8px;">v<?php echo esc_html( PR_R2_VERSION ); ?></span>
</h1>
<?php /* ── STATUS TABLE ───────────────────────────────────────────── */ ?>
<h2><?php esc_html_e( 'Status', 'peptiderepo-r2' ); ?></h2>
<table class="widefat striped" style="max-width:520px;margin-bottom:8px;">
<tbody>
<tr><td><strong><?php esc_html_e( 'Total attachments', 'peptiderepo-r2' ); ?></strong></td>
<td><?php echo esc_html( $s['total'] ); ?></td></tr>
<tr><td><strong><?php esc_html_e( 'Offloaded to R2', 'peptiderepo-r2' ); ?></strong></td>
<td><?php echo esc_html( $s['offloaded'] ); ?> (<?php echo esc_html( $s['pct'] ); ?>%)</td></tr>
<tr><td><strong><?php esc_html_e( 'Not yet offloaded', 'peptiderepo-r2' ); ?></strong></td>
<td><?php echo esc_html( $s['not_offloaded'] ); ?></td></tr>
<tr><td><strong><?php esc_html_e( 'Local copies remaining', 'peptiderepo-r2' ); ?></strong></td>
<td><?php echo esc_html( $s['local_remaining'] ); ?></td></tr>
<tr><td><strong><?php esc_html_e( 'R2 endpoint', 'peptiderepo-r2' ); ?></strong></td>
<td><code style="font-size:11px;"><?php echo esc_html( PR_R2_Endpoint::ENDPOINT ); ?></code></td></tr>
<tr><td><strong><?php esc_html_e( 'Delivery domain', 'peptiderepo-r2' ); ?></strong></td>
<td><code>media.peptiderepo.com</code></td></tr>
</tbody>
</table>
<p>
<button id="pr-r2-btn-test" class="button"><?php esc_html_e( 'Test R2 Connection', 'peptiderepo-r2' ); ?></button>
<span id="pr-r2-test-result" style="margin-left:10px;font-style:italic;"></span>
</p>
<?php /* ── BULK OPERATIONS ────────────────────────────────────────── */ ?>
<h2><?php esc_html_e( 'Bulk Operations', 'peptiderepo-r2' ); ?></h2>
<div style="display:flex;gap:20px;flex-wrap:wrap;margin-bottom:24px;">
<div style="<?php echo esc_attr( $card ); ?>">
<h3 style="margin-top:0;"><?php esc_html_e( 'Migrate to R2', 'peptiderepo-r2' ); ?></h3>
<p><?php esc_html_e( 'Upload local files not yet on R2. Safe to run multiple times.', 'peptiderepo-r2' ); ?></p>
<button id="pr-r2-btn-migrate" class="button button-primary"
<?php disabled( $s['not_offloaded'], 0 ); ?>>
<?php
echo $s['not_offloaded'] > 0
? esc_html( sprintf( __( 'Migrate %d files →', 'peptiderepo-r2' ), $s['not_offloaded'] ) )
: esc_html__( 'All files migrated ✓', 'peptiderepo-r2' );
?>
</button>
<button id="pr-r2-btn-migrate-pause" class="button" style="display:none;margin-left:6px;"><?php esc_html_e( 'Pause', 'peptiderepo-r2' ); ?></button>
<div id="pr-r2-migrate-progress" style="margin-top:10px;display:none;">
<progress id="pr-r2-migrate-bar" value="0" max="100" style="width:100%;height:18px;"></progress>
<p id="pr-r2-migrate-status" style="margin:4px 0 0;font-size:12px;color:#555;"></p>
</div>
</div>
<div style="<?php echo esc_attr( $card ); ?>">
<h3 style="margin-top:0;"><?php esc_html_e( 'Remove Local Copies', 'peptiderepo-r2' ); ?></h3>
<p><?php esc_html_e( 'Delete local copies for files already on R2. Frees Hostinger disk. Irreversible.', 'peptiderepo-r2' ); ?></p>
<button id="pr-r2-btn-remove" class="button button-secondary"
<?php disabled( $s['local_remaining'], 0 ); ?>>
<?php
echo $s['local_remaining'] > 0
? esc_html( sprintf( __( 'Remove %d local copies', 'peptiderepo-r2' ), $s['local_remaining'] ) )
: esc_html__( 'No local copies remaining ✓', 'peptiderepo-r2' );
?>
</button>
<button id="pr-r2-btn-remove-pause" class="button" style="display:none;margin-left:6px;"><?php esc_html_e( 'Pause', 'peptiderepo-r2' ); ?></button>
<div id="pr-r2-remove-progress" style="margin-top:10px;display:none;">
<progress id="pr-r2-remove-bar" value="0" max="100" style="width:100%;height:18px;"></progress>
<p id="pr-r2-remove-status" style="margin:4px 0 0;font-size:12px;color:#555;"></p>
</div>
</div>
</div>
<?php /* ── ACTIVITY LOG ─────────────────────────────────────────────── */ ?>
<h2><?php esc_html_e( 'Activity Log', 'peptiderepo-r2' ); ?>
<button id="pr-r2-btn-clear-log" class="button" style="font-size:12px;margin-left:10px;"><?php esc_html_e( 'Clear log', 'peptiderepo-r2' ); ?></button>
</h2>
<?php if ( empty( $logs ) ) : ?>
<p><?php esc_html_e( 'No log entries yet. Run a migration to populate.', 'peptiderepo-r2' ); ?></p>
<?php else : ?>
<table class="widefat striped" style="font-size:12px;">
<thead>
<tr>
<th><?php esc_html_e( 'Time', 'peptiderepo-r2' ); ?></th>
<th><?php esc_html_e( 'Operation', 'peptiderepo-r2' ); ?></th>
<th><?php esc_html_e( 'Status', 'peptiderepo-r2' ); ?></th>
<th><?php esc_html_e( 'File', 'peptiderepo-r2' ); ?></th>
<th><?php esc_html_e( 'Error', 'peptiderepo-r2' ); ?></th>
</tr>
</thead>
<tbody id="pr-r2-log-tbody">
<?php foreach ( $logs as $row ) : ?>
<tr>
<td><?php echo esc_html( $row->created_at ); ?></td>
<td><?php echo esc_html( $row->operation ); ?></td>
<td><?php
$colour = 'error' === $row->status ? '#c00' : ( 'skipped' === $row->status ? '#888' : '#090' );
printf( '<span style="color:%s;font-weight:bold;">%s</span>', esc_attr( $colour ), esc_html( $row->status ) );
?></td>
<td style="word-break:break-all;"><?php echo esc_html( basename( $row->file_path ) ); ?></td>
<td><?php echo esc_html( $row->error_msg ); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<div id="pr-r2-notice" style="margin-top:16px;"></div>
</div>
<?php
}
/**
* Return current offload status counts.
* Public — also consumed by PR_R2_Ajax::handle_get_status().
*
* @return array{total:int,offloaded:int,pct:int,not_offloaded:int,local_remaining:int}
*/
public static function get_status(): array {
global $wpdb;
$total = (int) $wpdb->get_var(
"SELECT COUNT(ID) FROM {$wpdb->posts}
WHERE post_type = 'attachment' AND post_status = 'inherit'"
);
$offloaded_table = $wpdb->prefix . 'as3cf_items';
$table_exists = $wpdb->get_var( "SHOW TABLES LIKE '{$offloaded_table}'" ) === $offloaded_table;
$offloaded = 0;
if ( $table_exists ) {
$offloaded = (int) $wpdb->get_var(
"SELECT COUNT(DISTINCT source_id) FROM {$offloaded_table}
WHERE source_type = 'media-library'"
);
}
$local_remaining = 0;
if ( $table_exists && $offloaded > 0 ) {
// WP Offload Media does NOT rewrite _wp_attached_file in the DB — it applies
// s3auto:// at runtime via a filter. The only reliable check is physical disk
// presence: build the absolute path from the upload base + raw meta value.
$upload_base = wp_upload_dir()['basedir'];
$offloaded_ids = $wpdb->get_col(
"SELECT DISTINCT oi.source_id FROM {$offloaded_table} oi
INNER JOIN {$wpdb->posts} p ON p.ID = oi.source_id
WHERE oi.source_type = 'media-library' AND p.post_type = 'attachment'"
);
foreach ( $offloaded_ids as $id ) {
$rel = get_post_meta( (int) $id, '_wp_attached_file', true );
if ( $rel && file_exists( $upload_base . '/' . $rel ) ) {
$local_remaining++;
}
}
}
$not_offloaded = max( 0, $total - $offloaded );
$pct = $total > 0 ? (int) round( ( $offloaded / $total ) * 100 ) : 0;
return compact( 'total', 'offloaded', 'pct', 'not_offloaded', 'local_remaining' );
}
}

292
includes/class-r2-ajax.php Executable file
View File

@@ -0,0 +1,292 @@
<?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;
}
}

54
includes/class-r2-endpoint.php Executable file
View File

@@ -0,0 +1,54 @@
<?php
/**
* What: Injects the Cloudflare R2 endpoint into WP Offload Media's AWS SDK client.
* Supersedes wp-content/mu-plugins/peptiderepo-r2-endpoint.php — that
* mu-plugin should be deleted once this plugin is active.
* Who: Registered via `as3cf_aws_s3_client_args` filter; fires whenever WP
* Offload Media initialises its S3 client (uploads, deletes, URL signing).
* Depends: WP Offload Media Lite (amazon-s3-and-cloudfront).
*
* @see peptiderepo-r2-manager.php (bootstraps this class)
* @see https://deliciousbrains.com/wp-offload-media/doc/custom-s3-compatible-storage/
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PR_R2_Endpoint {
/**
* Cloudflare R2 account-level S3-compatible endpoint.
* Using the account endpoint (not the public URL) so the AWS SDK can reach
* the bucket via path-style access regardless of custom domain routing.
*/
const ENDPOINT = 'https://a16f4baa82a1074c47f696e7cb6d5995.r2.cloudflarestorage.com';
/**
* Register the endpoint-injection filter.
* Called once from the plugin bootstrap.
*
* @return void
*/
public static function init(): void {
add_filter( 'as3cf_aws_s3_client_args', [ __CLASS__, 'inject_r2_endpoint' ], 10, 1 );
}
/**
* Override the AWS SDK S3 client endpoint to Cloudflare R2.
*
* Uses path-style endpoint so the bucket name appears in the URL path
* (required with the account-level R2 endpoint, which doesn't support
* virtual-hosted-style bucket subdomains).
*
* @param array<string,mixed> $args AWS SDK client constructor args from WP Offload Media.
* @return array<string,mixed> Modified args with R2 endpoint injected.
*
* Side effects: none — pure filter.
*/
public static function inject_r2_endpoint( array $args ): array {
$args['endpoint'] = self::ENDPOINT;
$args['use_path_style_endpoint'] = true;
return $args;
}
}

131
includes/class-r2-log.php Executable file
View File

@@ -0,0 +1,131 @@
<?php
/**
* What: Manages the wp_pr_r2_log table used to track every offload and
* remove-local operation: success, error, or skipped. Surfaces the
* most recent entries on the admin page for visibility.
* Who: PR_R2_Log::create_table() called on plugin activation.
* PR_R2_Log::write() called by PR_R2_Ajax after each file operation.
* PR_R2_Log::get_recent() called by PR_R2_Admin_Page::render().
* Depends: WordPress $wpdb global.
*
* @see includes/class-r2-ajax.php (writes log rows after each operation)
* @see includes/class-r2-admin-page.php (reads log rows for display)
* @see peptiderepo-r2-manager.php (register_activation_hook + self-heal)
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class PR_R2_Log {
const TABLE_NAME = 'pr_r2_log';
const STATUS_OK = 'success';
const STATUS_ERROR = 'error';
const STATUS_SKIP = 'skipped';
/**
* Create the log table. Uses CREATE TABLE IF NOT EXISTS for idempotency.
* Safe to call repeatedly — no-op if the table already exists.
*
* @return void
* Side effects: may CREATE wp_pr_r2_log in the WordPress database.
*/
public static function create_table(): void {
global $wpdb;
$table = $wpdb->prefix . self::TABLE_NAME;
$charset_cs = $wpdb->get_charset_collate();
$wpdb->query(
"CREATE TABLE IF NOT EXISTS {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
attachment_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
file_path VARCHAR(500) NOT NULL DEFAULT '',
operation VARCHAR(20) NOT NULL DEFAULT '',
status ENUM('success','error','skipped') NOT NULL DEFAULT 'success',
error_msg TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_attachment_id (attachment_id),
KEY idx_created_at (created_at)
) {$charset_cs}"
);
}
/**
* Insert one log row.
*
* @param int $attachment_id WP attachment post ID.
* @param string $file_path Relative file path (e.g. 2025/11/photo.jpg).
* @param string $operation 'migrate' | 'remove-local'.
* @param string $status One of the STATUS_* constants.
* @param string $error_msg Human-readable error on failure; empty otherwise.
* @return void
* Side effects: inserts a row into wp_pr_r2_log.
*/
public static function write(
int $attachment_id,
string $file_path,
string $operation,
string $status,
string $error_msg = ''
): void {
global $wpdb;
$wpdb->insert(
$wpdb->prefix . self::TABLE_NAME,
[
'attachment_id' => $attachment_id,
'file_path' => $file_path,
'operation' => $operation,
'status' => $status,
'error_msg' => $error_msg,
'created_at' => current_time( 'mysql' ),
],
[ '%d', '%s', '%s', '%s', '%s', '%s' ]
);
}
/**
* Return the most recent log rows for display on the admin page.
*
* @param int $limit Number of rows to return (default 20).
* @return array<int,object>
*/
public static function get_recent( int $limit = 20 ): array {
global $wpdb;
return $wpdb->get_results( $wpdb->prepare(
'SELECT * FROM ' . $wpdb->prefix . self::TABLE_NAME . ' ORDER BY id DESC LIMIT %d',
$limit
) );
}
/**
* Delete all rows from the log table.
*
* @return void
* Side effects: truncates wp_pr_r2_log.
*/
public static function clear(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE ' . $wpdb->prefix . self::TABLE_NAME );
}
/**
* Count rows by status for a given operation.
*
* @param string $operation 'migrate' | 'remove-local' | '' for all.
* @return array{success:int,error:int,skipped:int}
*/
public static function count_by_status( string $operation = '' ): array {
global $wpdb;
$table = $wpdb->prefix . self::TABLE_NAME;
$where = $operation ? $wpdb->prepare( 'WHERE operation = %s', $operation ) : '';
$rows = $wpdb->get_results(
"SELECT status, COUNT(*) AS cnt FROM {$table} {$where} GROUP BY status"
);
$out = [ 'success' => 0, 'error' => 0, 'skipped' => 0 ];
foreach ( $rows as $row ) {
$out[ $row->status ] = (int) $row->cnt;
}
return $out;
}
}

58
peptiderepo-r2-manager.php Executable file
View File

@@ -0,0 +1,58 @@
<?php
/**
* Plugin Name: Peptide Repo R2 Manager
* Plugin URI: https://peptiderepo.com
* Description: Cloudflare R2 media offload manager — admin dashboard for bulk
* migration, remove-local operations, connection testing, and error
* logging. Supersedes mu-plugin peptiderepo-r2-endpoint.php.
* Version: 1.1.0
* Author: peptiderepo
* License: GPL-2.0-or-later
* Text Domain: peptiderepo-r2
*
* What: Admin plugin for managing Cloudflare R2 media offloading on top of
* WP Offload Media Lite (amazon-s3-and-cloudfront). Provides the bulk
* migration UI, error log, test-connection, and R2 endpoint injection
* that the Lite tier omits.
* Who: Loaded by WordPress on every request (active plugin).
* Depends: WP Offload Media Lite active; AS3CF_SETTINGS defined in wp-config.php.
*
* @see wp-config.php (AS3CF_SETTINGS constant with R2 credentials)
* @see includes/class-r2-endpoint.php (R2 endpoint injection — replaces mu-plugin)
* @see includes/class-r2-log.php (DB log table management)
* @see includes/class-r2-admin-page.php (Settings > R2 Media Manager page)
* @see includes/class-r2-ajax.php (AJAX batch + test-connection handlers)
*
* CHANGELOG
* 1.0.0 — 2026-04-25 Initial release. Full CPO-spec implementation on top of WP
* Offload Media Lite. Supersedes one-off migration script and
* mu-plugin endpoint filter.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'PR_R2_VERSION', '1.1.0' );
define( 'PR_R2_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'PR_R2_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'PR_R2_PLUGIN_FILE', __FILE__ );
require_once PR_R2_PLUGIN_DIR . 'includes/class-r2-endpoint.php';
require_once PR_R2_PLUGIN_DIR . 'includes/class-r2-log.php';
require_once PR_R2_PLUGIN_DIR . 'includes/class-r2-admin-page.php';
require_once PR_R2_PLUGIN_DIR . 'includes/class-r2-ajax.php';
// Create log table on activation. Self-heals on every boot if table is missing.
register_activation_hook( PR_R2_PLUGIN_FILE, [ 'PR_R2_Log', 'create_table' ] );
add_action( 'plugins_loaded', function () {
global $wpdb;
$table = $wpdb->prefix . 'pr_r2_log';
if ( $wpdb->get_var( "SHOW TABLES LIKE '{$table}'" ) !== $table ) {
PR_R2_Log::create_table();
}
}, 1 );
PR_R2_Endpoint::init();
PR_R2_Admin_Page::init();
PR_R2_Ajax::init();