array( 2000000, 12582912, 268435456 ), 'wordpress' => array( 6553600, 25165824, 805306368 ), 'all' => array( 67108864, 67108864, 2147483648 ), ); /** * Every setting with its value at the start. * * @return array Setting => default value. */ public static function defaults(): array { return array( 'delivery' => false, 'uploads' => false, 'auto_discovery' => false, 'paused' => false, 'enhanced_ui' => true, 'theme_images' => true, 'catalog' => true, 'comments' => true, 'local_cdn' => false, 'public_policy' => false, 'pause_reason' => '', 'lazy_loading' => false, 'large_pictures' => 'wordpress', // 1.8.0: folders for these post types' admin lists, measured sizes for the page, the store owner's own CDN. 'content_folders' => array(), 'fit' => false, // 1.9.0: the missing width and height on measured pages, only where the measurement showed nothing moves (off at the start). 'dimensions' => false, 'cdn' => false, 'cdn_url' => '', 'cdn_state' => array(), // 1.9.0: AI assistants through WordPress's Abilities API and Image Manager's MCP address (proposals wait for approval). 'ai_assistants' => true, // 1.9.0: empty alt text and copied titles of new uploads filled from file names (off at the start). 'auto_text' => false, // 1.9.0: the largest new upload WordPress uses on pages (its big_image_size_threshold); 'wordpress' leaves WordPress's own value. 'upload_limit' => 'wordpress', // 1.9.0: registered sizes WordPress does not make for new uploads (none at the start). 'skip_sizes' => array(), // 1.9.0: folders inside wp-content whose pictures are treated like the active theme's (none at the start). 'picture_folders' => array(), // 1.9.0: a watermark on the copies public pages show (off at the start); the originals are never changed. 'watermark' => false, 'watermark_image' => 0, 'watermark_place' => 'bottom-right', 'watermark_size' => 20, 'watermark_opacity' => 60, 'watermark_min' => 400, // 1.9.0: SVG uploads, cleaned before they are saved, for the roles chosen (off at the start; Administrators chosen). 'svg_uploads' => false, 'svg_roles' => array( 'administrator' ), 'max_pixels' => 6553600, 'max_bytes' => 25165824, 'compression' => array( 'mode' => 'lossless', 'content' => 'auto', 'effort' => 'balanced', 'formats' => 'auto', 'metadata' => 'preserve', ), 'min_free' => 134217728, 'min_saving' => 5, 'generation' => 1, ); } /** * The pixel and byte limits always follow the saved choice, never an older stored number. * * @param array $s The settings. * @return array The settings with max_pixels and max_bytes taken from the largest-picture choice. */ private static function limits( array $s ): array { if ( ! is_string( $s['large_pictures'] ) || ! isset( self::LARGE[ $s['large_pictures'] ] ) ) { $s['large_pictures'] = 'wordpress'; // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- a key of LARGE (the saved choice), not text. } $s['max_pixels'] = self::LARGE[ $s['large_pictures'] ][0]; $s['max_bytes'] = self::LARGE[ $s['large_pictures'] ][1]; return $s; } /** * The saved settings over the defaults (from WordPress's option cache). * * @return array The settings. */ public static function get(): array { return self::limits( array_merge( self::defaults(), (array) get_option( self::OPTION, array() ) ) ); } /** * Worker/control reads bypass a request-local non-autoloaded option cache. * * @param string $name The option. * @param mixed $default The value when it is not saved. * @return mixed The option's saved value, read past the request's cache. */ public static function fresh_option( string $name, $default = false ) { if ( function_exists( 'wp_cache_delete' ) ) { wp_cache_delete( $name, 'options' ); } return get_option( $name, $default ); } /** * The saved settings over the defaults, read past the request's option cache. * * @return array The settings. */ public static function fresh(): array { return self::limits( array_merge( self::defaults(), (array) self::fresh_option( self::OPTION, array() ) ) ); } /** * Save changed settings under the settings lock; a publication generation never goes down. * * @param array $values The settings to change. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function patch( array $values ): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $lock = Repository::acquire( 'settings', 30 ); if ( ! $lock ) { throw new \RuntimeException( 'worker_busy' ); } try { $current = self::fresh(); // An older settings form must never lower or reuse a publication generation. if ( array_key_exists( 'generation', $values ) ) { $values['generation'] = max( (int) $current['generation'] + 1, (int) $values['generation'] ); } Reviewed_Store::update_option( self::OPTION, array_merge( $current, $values ), false ); } finally { Repository::release( $lock, 'settings' ); } } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Pause processing with a reason and start a new publication generation. * * @param string $reason Why processing was paused (shown to the owner). */ public static function pause( string $reason ): void { self::patch( array( 'paused' => true, 'pause_reason' => $reason, 'generation' => (int) self::fresh()['generation'] + 1, ) ); } /** * Image Manager's folder for copies inside the uploads folder (wp-content/uploads/affstackdev-image-manager), checked; * with an index.html that stops directory listings. * * @param bool $create Whether to create the folder when it is missing. * @return string The folder's real path. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function root( bool $create = false ): string { $uploads = wp_get_upload_dir(); if ( ! empty( $uploads['error'] ) || empty( $uploads['basedir'] ) ) { throw new \RuntimeException( 'uploads_unavailable' ); } $base = realpath( $uploads['basedir'] ); if ( false === $base ) { throw new \RuntimeException( 'uploads_unavailable' ); } $root = $base . '/affstackdev-image-manager'; if ( is_link( $root ) ) { throw new \RuntimeException( 'unsafe_storage' ); } if ( $create && ! is_dir( $root ) && ! wp_mkdir_p( $root ) ) { throw new \RuntimeException( 'storage_not_writable' ); } if ( is_link( $root . '/index.html' ) ) { throw new \RuntimeException( 'unsafe_storage' ); } if ( $create && ! file_exists( $root . '/index.html' ) ) { // Stop ordinary directory-index exposure without modifying host rewrite/configuration files. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Write fixed deny-access marker content in the validated local derivative root before image work. Reviewed_Store::file_put_contents( $root . '/index.html', '', LOCK_EX ); } if ( is_dir( $root ) && realpath( $root ) !== $root ) { throw new \RuntimeException( 'unsafe_storage' ); } return $root; } /** * The memory one picture's work may use: WordPress's image memory limit, within the cap of the largest-picture choice, * less what the request uses and 32 MB to spare. * * @return int Bytes. */ public static function memory_budget(): int { // WordPress's own image memory (WP_MAX_MEMORY_LIMIT, filter image_memory_limit), as when it makes an upload's sizes. wp_raise_memory_limit( 'image' ); $limit = wp_convert_hr_to_bytes( (string) ini_get( 'memory_limit' ) ); // A finite plugin budget remains in force on hosts with unlimited PHP memory; it follows the largest-picture choice. $cap = self::LARGE[ self::get()['large_pictures'] ][2]; $limit = $limit > 0 ? min( $cap, $limit ) : $cap; return max( 0, $limit - memory_get_usage( true ) - 33554432 ); } /** Read-only estimate for the Settings tab: the per-pixel factors of ImageInput::read() plus a share for the file itself. */ public static function largest_now(): array { $budget = self::memory_budget(); $imagick = class_exists( '\Imagick' ); $pixels = (int) floor( $budget / ( $imagick ? 85 : 45 ) ); return array( 'memory' => $budget, 'backend' => $imagick ? 'Imagick' : 'GD', 'pixels' => min( $pixels, self::get()['max_pixels'] ), ); } /** * The server and site facts Tools and the diagnostics show: versions, encoders, memory, limits, scheduling, * integrations and storage. * * @return array The facts. */ public static function preflight(): array { $s = self::get(); $out = array( 'plugin' => VERSION, 'php' => PHP_VERSION, 'wordpress' => get_bloginfo( 'version' ), 'html_api' => class_exists( '\WP_HTML_Tag_Processor' ), 'codec' => Codec::backend(), 'compression_capabilities' => Compression::capabilities(), 'memory_available' => self::memory_budget(), 'max_pixels' => $s['max_pixels'], 'max_source_bytes' => $s['max_bytes'], 'scheduler' => Engine::scheduler(), 'cron_disabled' => defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON, 'last_worker' => (int) get_option( 'afsd_im_last_worker', 0 ), 'single_site_supported' => ! is_multisite(), 'jetpack_image_cdn' => class_exists( '\Automattic\Jetpack\Image_CDN\Image_CDN' ) || class_exists( '\Jetpack_Photon' ), 'woo' => defined( 'WC_VERSION' ) ? WC_VERSION : 'not active', 'delivery_integrations' => Integrations::matrix(), 'theme' => wp_get_theme()->get( 'Name' ), 'theme_version' => wp_get_theme()->get( 'Version' ), ); try { $root = self::root( true ); $free = disk_free_space( $root ); $out['writable'] = wp_is_writable( $root ); $out['free_bytes'] = false === $free ? null : (int) $free; $out['storage_ready'] = $out['writable'] && null !== $out['free_bytes'] && $free >= $s['min_free']; } catch ( \Throwable $e ) { $out['storage_ready'] = false; $out['storage_reason'] = sanitize_key( $e->getMessage() ); } return $out; } }