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:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
node_modules/
|
||||||
|
vendor/
|
||||||
26
CHANGELOG.md
Normal file
26
CHANGELOG.md
Normal 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
189
assets/r2-manager.js
Executable 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
275
includes/class-r2-admin-page.php
Executable 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
292
includes/class-r2-ajax.php
Executable 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
54
includes/class-r2-endpoint.php
Executable 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
131
includes/class-r2-log.php
Executable 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
58
peptiderepo-r2-manager.php
Executable 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();
|
||||||
Reference in New Issue
Block a user