Files
peptiderepo-r2-manager/includes/class-r2-admin-page.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

276 lines
12 KiB
PHP
Executable File

<?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' );
}
}