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