' . esc_html__( 'AffStackDev Image Manager is inactive on this unsupported environment. A single site and WordPress 6.9+ are required.', 'affstackdev-image-manager' ) . '
';
}
}
);
return;
}
if ( (int) get_option( 'afsd_im_schema' ) !== SCHEMA ) {
if ( ( is_admin() && current_user_can( 'activate_plugins' ) ) || ( defined( 'WP_CLI' ) && WP_CLI ) ) {
Reviewed_Store::housekeeping( array( Repository::class, 'install' ) );
}
if ( (int) get_option( 'afsd_im_schema' ) !== SCHEMA ) {
add_action(
'admin_notices',
static function () {
if ( current_user_can( 'activate_plugins' ) ) {
echo '' . esc_html__( 'Image Manager could not finish its database update. Original media is unchanged. Check database permissions and restore the previous plugin package if necessary; do not delete image records.', 'affstackdev-image-manager' ) . '
';
}
}
);
return;
}
}
// Only an administrator's or WP-CLI's request reads the data version (Upgrade loads for them only).
if ( ( ( is_admin() && current_user_can( 'activate_plugins' ) ) || ( defined( 'WP_CLI' ) && WP_CLI ) ) && get_option( Upgrade::VERSION_OPTION, '' ) !== VERSION ) {
Reviewed_Store::housekeeping( array( Upgrade::class, 'initialize' ) );
}
add_filter( 'cron_schedules', array( self::class, 'intervals' ) );
Hooks::register();
$observers = array(
array( 'add_attachment', 'attachment_saved', 10, 1 ),
array( 'edit_attachment', 'attachment_saved', 10, 1 ),
array( 'delete_attachment', 'attachment_deleted', 10, 1 ),
array( 'added_post_meta', 'meta_changed', 20, 4 ),
array( 'updated_post_meta', 'meta_changed', 20, 4 ),
array( 'deleted_post_meta', 'meta_changed', 20, 4 ),
array( 'save_post', 'post_saved', 100, 3 ),
array( 'before_delete_post', 'post_deleted', 5, 1 ),
array( 'trashed_post', 'post_deleted', 5, 1 ),
array( 'woocommerce_after_product_object_save', 'product_saved', 100, 1 ),
array( 'comment_post', 'comment_saved', 100, 1 ),
array( 'edit_comment', 'comment_saved', 100, 1 ),
array( 'transition_comment_status', 'comment_transition', 20, 3 ),
array( 'delete_comment', 'comment_deleted', 5, 1 ),
array( 'trashed_comment', 'comment_deleted', 5, 1 ),
);
foreach ( $observers as list( $hook, $method, $priority, $argc ) ) {
add_action(
$hook,
static function ( ...$args ) use ( $method ) {
// Unrelated post meta (stock, prices, order data...) never touches the Image Manager owner lock.
if ( 'meta_changed' === $method && ! in_array( $args[2] ?? '', self::IMAGE_META, true ) ) {
return;
}
try {
// Media uploads, edits, deletions and image-meta changes wait briefly for a background batch (seconds)
// instead of losing the change. Other saves (orders, ordinary posts, products) never wait.
$held = Reviewed_Store::enter( in_array( $method, self::WAIT_ON_BUSY, true ) ? 8 : 0 );
try {
self::$method( ...$args );
} finally {
Reviewed_Store::leave( $held );
}
} catch ( \Throwable $e ) {
// Another Image Manager writer holds the owner lock (a batch, a backup, a scheduled run): not a failure.
// The change is recorded and the owner tracks it once the lock is free (F-IM-17, F-IM-02).
if ( Reviewed_Store::busy( $e ) && self::defer( $method, $args ) ) {
return;
}
self::tracking_failed();
}
},
$priority,
$argc
);
}
/**
* Fires once after Image Manager has booted, before it announces its contract: where code that extends Image
* Manager starts.
*
* @since 1.9.0
*/
do_action( 'afsd_im_booted' );
Foundation::announce();
}
/**
* What a callback prints, caught in an output buffer that this function opens and closes again whatever happens (a
* throw closes it too, and any buffer the callback left open).
*
* @param callable $output The callback that prints.
* @return string What it printed.
* @throws \Throwable What the callback threw, after its output was discarded.
*/
public static function capture( callable $output ): string {
$level = ob_get_level();
ob_start();
try {
$output();
} catch ( \Throwable $e ) {
while ( ob_get_level() > $level ) {
ob_end_clean();
}
throw $e;
}
while ( ob_get_level() > $level + 1 ) {
ob_end_flush(); // A buffer the callback left open passes its output on to this one.
}
return (string) ob_get_clean();
}
/**
* The named fields of this request, unslashed. Only those fields are read, never the whole request; each caller
* checks its nonce (or reads navigation only) and validates every field it uses.
*
* @param array $keys The field names the handler reads.
* @param bool $query True for the query string, false for the posted form.
* @return array Field name => unslashed value, for the fields that were sent.
*/
public static function fields( array $keys, bool $query = false ): array {
$out = array();
foreach ( $keys as $key ) {
// phpcs:disable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each caller checks its nonce and validates the fields it uses (see above).
if ( $query ? isset( $_GET[ $key ] ) : isset( $_POST[ $key ] ) ) {
$out[ $key ] = wp_unslash( $query ? $_GET[ $key ] : $_POST[ $key ] );
}
// phpcs:enable
}
return $out;
}
/**
* The scheduled events of code that extends Image Manager, which deactivation clears with Image Manager's own.
*
* @return array WP-Cron hook names.
*/
private static function extension_events(): array {
/**
* Filters the WP-Cron hooks of code that extends Image Manager; deactivation clears them too.
*
* @since 1.9.0
*
* @param array $hooks Hook names (none by default).
*/
return array_values( array_filter( (array) apply_filters( 'afsd_im_extension_events', array() ), 'is_string' ) );
}
/**
* Add Image Manager's two-minute schedule to WP-Cron.
*
* @param array $schedules WP-Cron's schedules.
* @return array The schedules.
*/
public static function intervals( array $schedules ): array {
$schedules['afsd_im_two_minutes'] = array(
'interval' => 120,
'display' => __( 'Image Manager: every two minutes', 'affstackdev-image-manager' ),
);
return $schedules;
}
/**
* A change that could not be tracked or recorded. Optimization bookkeeping must never make a native upload/save fail:
* stop new delivery and leave an actionable message; never print paths/errors. True when the pause was saved.
*/
public static function tracking_failed(): bool {
try {
Settings::patch(
array(
'delivery' => false,
'uploads' => false,
'paused' => true,
'pause_reason' => __( 'Image tracking could not complete. Native saving was allowed to continue. Review managed copies and caches before resuming, especially after privacy/deletion changes.', 'affstackdev-image-manager' ),
)
);
return true;
} catch ( \Throwable $ignored ) {
return false; /* Native saving still takes precedence. */
}
}
/**
* A busy observer's change, recorded by ID for the owner (Tracking replays it). False when nothing could be recorded.
*
* @param string $method The observer that was busy.
* @param array $args Its arguments.
* @return bool True when the change is recorded.
*/
private static function defer( string $method, array $args ): bool {
try {
switch ( $method ) {
case 'meta_changed':
$entries = array( 'meta_changed:' . (int) $args[1] . ':' . (string) $args[2] );
break;
case 'post_saved':
$id = (int) $args[0];
$entries = wp_is_post_revision( $id ) || wp_is_post_autosave( $id ) ? array() : array( 'post_saved:' . $id );
break;
case 'product_saved':
$entries = is_object( $args[0] ) && method_exists( $args[0], 'get_id' ) ? array( 'post_saved:' . (int) $args[0]->get_id() ) : array();
break;
case 'post_deleted':
// Read the post's sources now: once WordPress has deleted it, its attachments no longer name it as parent.
$entries = array_map(
static function ( $source ) {
return 'source_retired:' . $source;
},
self::post_sources( (int) $args[0] )
);
break;
case 'comment_transition':
$entries = $args[0] !== $args[1] && is_object( $args[2] ) && isset( $args[2]->comment_ID ) ? array( 'comment_saved:' . (int) $args[2]->comment_ID ) : array();
break;
default:
$entries = array( $method . ':' . (int) $args[0] ); // attachment_saved, attachment_deleted, comment_saved, comment_deleted.
}
return Tracking::defer( $entries );
} catch ( \Throwable $e ) {
return false;
}
}
/**
* Deactivation: unschedule Image Manager's work and checks; settings, records, copies and recorded changes stay.
*
* @throws \RuntimeException What a step inside threw, passed on after cleaning up.
*/
public static function deactivate(): void {
try {
$held = Reviewed_Store::enter( 15 );
} catch ( \RuntimeException $e ) {
if ( ! Reviewed_Store::busy( $e ) ) {
throw $e;
}
// A writer is still finishing. Deactivation must still succeed: stop every future run (scheduler state
// only) and leave Image Manager's own records untouched for that writer. Settings are not rewritten.
$hooks = array_merge( array( 'afsd_im_reviewed_input_cleanup', Engine::HOOK, Scheduler::WAKE, CDN::HOOK, Tracking::HOOK ), self::extension_events() );
foreach ( $hooks as $hook ) {
wp_clear_scheduled_hook( $hook );
}
if ( 'Action Scheduler' === Scheduler::backend() ) {
as_unschedule_all_actions( Engine::HOOK, array(), Engine::GROUP );
as_unschedule_all_actions( Scheduler::WAKE, array(), Engine::GROUP );
}
Backup::stop_schedule();
Remote_Backup::stop_schedule();
Remote_Backup::stop_transfer_schedule();
return;
}
try {
Reviewed_Store::wp_clear_scheduled_hook( 'afsd_im_reviewed_input_cleanup' );
foreach ( self::extension_events() as $hook ) {
Reviewed_Store::wp_clear_scheduled_hook( $hook );
} // Their own settings stay.
Reviewed_Store::wp_clear_scheduled_hook( CDN::HOOK ); // The daily CDN check; the saved address and its last result stay.
Reviewed_Store::wp_clear_scheduled_hook( Tracking::HOOK ); // Recorded changes stay; activation replays them.
Engine::stop();
Backup::stop_schedule();
Remote_Backup::deactivate();
Settings::patch(
array(
'delivery' => false,
'uploads' => false,
'auto_discovery' => false,
'cdn' => false,
'generation' => Settings::get()['generation'] + 1,
)
);
// Retain managed files because cached HTML can continue to reference them.
} finally {
Reviewed_Store::leave( $held );
}
}
/**
* Keep the records of a saved attachment in step: copies no longer eligible are invalidated, and the attachment is
* looked at again.
*
* @param mixed $attachment The attachment.
*/
public static function attachment_saved( $attachment ): void {
$im_reviewed_owner_lock = Reviewed_Store::enter();
try {
$attachment = (int) $attachment;
Sources::forget_cache();
$post = get_post( $attachment );
if ( ! $post || 'attachment' !== $post->post_type ) {
return;
}
$row = Repository::attachment( $attachment );
if ( $row && ! Sources::eligible( $row ) && ! empty( Variants::flatten( $row['manifest'], true ) ) ) {
Engine::invalidate_attachment( $attachment );
}
if ( ! Settings::get()['uploads'] ) {
return;
}
if ( ! str_starts_with( (string) $post->post_mime_type, 'image/' ) ) {
return;
}
$row = Repository::discover(
$attachment,
5,
array(
'surface' => 'new_upload',
'mapping' => 'awaiting_public_association',
),
'upload'
);
if ( $post->post_parent ) {
$parent = get_post( $post->post_parent );
$id = $parent && 'product_variation' === $parent->post_type ? (int) $parent->post_parent : (int) $post->post_parent;
Sources::post( $id, true );
}
$row = Repository::row( (int) $row['id'] );
if ( Sources::eligible( $row ) ) {
if ( ! in_array( $row['state'], array( 'ready', 'queued', 'no_saving', 'unsupported' ), true ) ) {
Repository::request( (int) $row['id'], 'upload' );
}
} elseif ( empty( $row['manifest']['variants'] ) ) {
Repository::update(
(int) $row['id'],
array(
'state' => 'held',
'requested' => 0,
'reason' => 'New upload: awaiting a supported published post/product or approved comment association.',
)
);
}
} finally {
Reviewed_Store::leave( $im_reviewed_owner_lock );
}
}
/**
* Invalidate the copies of a deleted attachment.
*
* @param mixed $id The attachment.
*/
public static function attachment_deleted( $id ): void {
$im_reviewed_owner_lock = Reviewed_Store::enter();
try {
Engine::invalidate_attachment( (int) $id, true );
} finally {
Reviewed_Store::leave( $im_reviewed_owner_lock );
}
}
/**
* React to a change of an attachment's file or metadata, or of a post's featured image or product gallery.
*
* @param mixed $meta_id The meta row.
* @param mixed $id The post.
* @param mixed $key The meta key.
* @param mixed $value The new value.
*/
public static function meta_changed( $meta_id, $id, $key, $value ): void {
$im_reviewed_owner_lock = Reviewed_Store::enter();
try {
if ( in_array( $key, array( '_wp_attached_file', '_wp_attachment_metadata' ), true ) ) {
Engine::invalidate_attachment( (int) $id );
Sources::forget_cache();
self::attachment_saved( $id );
} elseif ( in_array( $key, array( '_thumbnail_id', '_product_image_gallery' ), true ) ) {
self::post_saved( $id );
}
} finally {
Reviewed_Store::leave( $im_reviewed_owner_lock );
}
}
/**
* Known associations only: never enumerate every file on an ordinary save request.
*
* @param mixed $id The post.
* @param mixed $post The post object.
* @param mixed $update Whether it was an update.
*/
public static function post_saved( $id, $post = null, $update = true ): void {
$im_reviewed_owner_lock = Reviewed_Store::enter();
try {
if ( self::$refreshing || wp_is_post_revision( $id ) || wp_is_post_autosave( $id ) ) {
return;
}
$post = get_post( $id );
if ( ! $post ) {
return;
}
if ( 'attachment' === $post->post_type ) {
self::attachment_saved( $id );
return;
}
if ( 'product_variation' === $post->post_type ) {
$id = (int) $post->post_parent;
}
self::$refreshing = true;
try {
Sources::forget_cache();
foreach ( self::post_sources( (int) $id ) as $source ) {
$row = Repository::row( $source );
if ( $row && ! Sources::eligible( $row ) ) {
Engine::invalidate_attachment( (int) $row['attachment_id'] );
}
}
if ( Settings::get()['uploads'] ) {
Sources::post( (int) $id, true );
}
} finally {
self::$refreshing = false;
}
} finally {
Reviewed_Store::leave( $im_reviewed_owner_lock );
}
}
/**
* The sources a post owns: through its own records and its comments.
*
* @param int $id The post.
* @return array Source row IDs.
*/
private static function post_sources( int $id ): array {
global $wpdb;
$ids = Repository::affected( 'post', $id );
// Covers comment owners and a private attachment parent even when another public page also uses it.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Join the plugin ownership index to current post/comment relationships during invalidation; no WP API supplies this join and cached relationships can retain stale delivery.
$comment_ids = $wpdb->get_col( $wpdb->prepare( "SELECT o.source_id FROM %i o INNER JOIN %i c ON o.owner_id=c.comment_ID WHERE o.owner_type='comment' AND c.comment_post_ID=%d", Repository::table( 'owners' ), $wpdb->comments, $id ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Join the plugin ownership index to current post/comment relationships during invalidation; no WP API supplies this join and cached relationships can retain stale delivery.
$parent_ids = $wpdb->get_col( $wpdb->prepare( 'SELECT s.id FROM %i s INNER JOIN %i a ON s.attachment_id=a.ID LEFT JOIN %i p ON a.post_parent=p.ID WHERE a.post_parent=%d OR (p.post_type=%s AND p.post_parent=%d)', Repository::table( 'sources' ), $wpdb->posts, $wpdb->posts, $id, 'product_variation', $id ) );
return array_values( array_unique( array_map( 'intval', array_merge( $ids, $comment_ids, $parent_ids ) ) ) );
}
/**
* Retire the sources of a deleted or trashed post.
*
* @param mixed $id The post.
*/
public static function post_deleted( $id ): void {
$im_reviewed_owner_lock = Reviewed_Store::enter();
try {
foreach ( self::post_sources( (int) $id ) as $source ) {
self::source_retired( $source );
}
} finally {
Reviewed_Store::leave( $im_reviewed_owner_lock );
}
}
/**
* One source of a deleted or trashed post: its copies stop being delivered and are retired.
*
* @param mixed $source The source row.
*/
public static function source_retired( $source ): void {
$im_reviewed_owner_lock = Reviewed_Store::enter();
try {
$row = Repository::row( (int) $source );
if ( $row ) {
Engine::invalidate_attachment( (int) $row['attachment_id'], true );
}
} finally {
Reviewed_Store::leave( $im_reviewed_owner_lock );
}
}
/**
* Treat a saved WooCommerce product as a saved post.
*
* @param mixed $product The product.
*/
public static function product_saved( $product ): void {
$im_reviewed_owner_lock = Reviewed_Store::enter();
try {
if ( is_object( $product ) && method_exists( $product, 'get_id' ) ) {
self::post_saved( $product->get_id() );
}
} finally {
Reviewed_Store::leave( $im_reviewed_owner_lock );
}
}
/**
* Look again at the sources of a saved comment.
*
* @param mixed $id The comment.
*/
public static function comment_saved( $id ): void {
$im_reviewed_owner_lock = Reviewed_Store::enter();
try {
Sources::forget_cache();
foreach ( Repository::affected( 'comment', (int) $id ) as $source ) {
$row = Repository::row( $source );
if ( $row && ! Sources::eligible( $row ) ) {
Engine::invalidate_attachment( (int) $row['attachment_id'] );
}
}
if ( Settings::get()['uploads'] ) {
Sources::comment( (int) $id, true );
}
} finally {
Reviewed_Store::leave( $im_reviewed_owner_lock );
}
}
/**
* Look again at a comment's sources when its status changes.
*
* @param mixed $new The new status.
* @param mixed $old The old status.
* @param mixed $comment The comment.
*/
public static function comment_transition( $new, $old, $comment ): void {
$im_reviewed_owner_lock = Reviewed_Store::enter();
try {
if ( $new !== $old && $comment ) {
self::comment_saved( (int) $comment->comment_ID );
}
} finally {
Reviewed_Store::leave( $im_reviewed_owner_lock );
}
}
/**
* Revoke what a deleted or trashed comment owned.
*
* @param mixed $id The comment.
*/
public static function comment_deleted( $id ): void {
$im_reviewed_owner_lock = Reviewed_Store::enter();
try {
Engine::revoke_owner( 'comment', (int) $id );
} finally {
Reviewed_Store::leave( $im_reviewed_owner_lock );
}
}
}