'', 'level' => 'balanced', 'auto_optimize' => 1, 'keep_originals' => 1, 'webp' => 1, 'avif' => 0, 'serve_nextgen' => 0, 'keep_metadata' => 0, 'max_dimension' => 0, 'local' => 0, 'cdn_url' => '', 'skip_rules' => '', ); } /** Choices for "resize large images": longest side in pixels, 0 = leave as uploaded. */ public static function dimensions() { return array( 0, 1920, 2560, 3840 ); } public static function levels() { return array( 'lossless' => __( 'Lossless – no visible change, smallest savings', 'lean-pixel' ), 'balanced' => __( 'Balanced – recommended', 'lean-pixel' ), 'aggressive' => __( 'Aggressive – smallest files, slight quality loss', 'lean-pixel' ), ); } /** Short names for the compression levels. */ public static function level_names() { return array( 'lossless' => __( 'Lossless', 'lean-pixel' ), 'balanced' => __( 'Balanced', 'lean-pixel' ), 'aggressive' => __( 'Aggressive', 'lean-pixel' ), ); } public static function all() { return wp_parse_args( (array) get_option( self::OPTION, array() ), self::defaults() ); } public static function get( $key ) { $all = self::all(); return isset( $all[ $key ] ) ? $all[ $key ] : null; } public static function update( array $changes ) { update_option( self::OPTION, array_merge( self::all(), $changes ), false ); } public static function is_connected() { return '' !== (string) self::get( 'api_key' ); } /** Whether images are processed on this server instead of by the service. */ public static function is_local() { return (bool) self::get( 'local' ); } /** Whether the plugin can optimize images: either mode is set up. */ public static function is_ready() { return self::is_local() || ( self::is_connected() && ! self::is_pending() ); } /** Whether the key is waiting for its email address to be confirmed. */ public static function is_pending() { return self::is_connected() && false !== get_option( self::PENDING, false ); } /** * Sanitize the settings form. The API key is managed separately (connect / * disconnect), so it is carried over from the stored value. * * @param mixed $input Raw form input. * @return array */ public static function sanitize( $input ) { $input = is_array( $input ) ? $input : array(); $old = self::all(); $out = array( 'api_key' => isset( $input['api_key'] ) ? self::clean_key( $input['api_key'] ) : $old['api_key'], 'level' => ( isset( $input['level'] ) && array_key_exists( $input['level'], self::levels() ) ) ? $input['level'] : $old['level'], ); foreach ( array( 'auto_optimize', 'keep_originals', 'webp', 'avif', 'serve_nextgen', 'keep_metadata', 'local' ) as $flag ) { $out[ $flag ] = empty( $input[ $flag ] ) ? 0 : 1; } // Local processing cannot be switched on where PHP has no image library. if ( $out['local'] && empty( $old['local'] ) && '' === LeanPixel_Local::capabilities()['engine'] ) { $out['local'] = 0; if ( function_exists( 'add_settings_error' ) ) { add_settings_error( self::OPTION, 'leanpixel_local', __( 'Images cannot be processed on this server: PHP has neither Imagick nor GD. The setting was left off.', 'lean-pixel' ) ); } } $max = isset( $input['max_dimension'] ) ? (int) $input['max_dimension'] : (int) $old['max_dimension']; $out['max_dimension'] = in_array( $max, self::dimensions(), true ) ? $max : 0; $out['cdn_url'] = self::sanitize_cdn( $input, $old ); $out['skip_rules'] = self::clean_rules( isset( $input['skip_rules'] ) ? $input['skip_rules'] : $old['skip_rules'] ); return $out; } /** * A new CDN address is only accepted once an image has loaded through it, * so a typing mistake cannot break the pictures on the site. * * @param array $input Raw form input. * @param array $old Stored settings. * @return string */ private static function sanitize_cdn( array $input, array $old ) { static $checked = array(); // WordPress can run the sanitizer twice for one save. if ( ! isset( $input['cdn_url'] ) ) { return (string) $old['cdn_url']; } $typed = trim( (string) $input['cdn_url'] ); $cdn = LeanPixel_CDN::clean( $typed ); if ( $cdn === (string) $old['cdn_url'] || '' === $typed ) { return $cdn; } $report = function_exists( 'add_settings_error' ); // Only loaded on admin screens. if ( '' === $cdn ) { if ( $report ) { add_settings_error( self::OPTION, 'leanpixel_cdn', __( 'That CDN address is not valid, so it was not saved. It should look like https://example.b-cdn.net.', 'lean-pixel' ) ); } return (string) $old['cdn_url']; } if ( ! isset( $checked[ $cdn ] ) ) { $checked[ $cdn ] = LeanPixel_CDN::check( $cdn ); if ( is_wp_error( $checked[ $cdn ] ) && $report ) { add_settings_error( self::OPTION, 'leanpixel_cdn', $checked[ $cdn ]->get_error_message() ); } } return true === $checked[ $cdn ] ? $cdn : (string) $old['cdn_url']; } /** * Tidy the exclude rules: one per line, no blank or over-long lines, at most 50. * * @param mixed $text Raw textarea content. * @return string Rules joined by line breaks. */ public static function clean_rules( $text ) { $rules = array(); foreach ( preg_split( '/\r\n|\r|\n/', (string) $text ) as $line ) { $line = trim( sanitize_text_field( $line ) ); $line = ltrim( str_replace( '\\', '/', $line ), '/' ); // A rule of only "*" would exclude everything by accident. if ( '' !== $line && '' !== trim( $line, '*' ) && strlen( $line ) <= 200 ) { $rules[ $line ] = true; } if ( count( $rules ) >= 50 ) { break; } } return implode( "\n", array_keys( $rules ) ); } /** @return string[] The exclude rules, one per entry. */ public static function exclude_rules() { $text = (string) self::get( 'skip_rules' ); return '' === $text ? array() : explode( "\n", $text ); } public static function clean_key( $key ) { $key = trim( (string) $key ); return preg_match( '/^io_[a-f0-9]{40}$/', $key ) ? $key : ''; } /* Running totals shown on the dashboard. */ const VERSION_OPTION = 'leanpixel_version'; public static function empty_stats() { return array( 'files' => 0, 'before' => 0, 'after' => 0, 'next_before' => 0, 'next_after' => 0, ); } public static function stats() { return wp_parse_args( (array) get_option( self::STATS, array() ), self::empty_stats() ); } /** * Add one file's record to the totals, or take it out again. * * @param array $record File record from the optimizer. * @param int $sign 1 to add, -1 to remove. */ public static function record_stats( array $record, $sign = 1 ) { $s = self::stats(); self::apply_record( $s, $record, $sign ); update_option( self::STATS, $s, false ); } private static function apply_record( array &$s, array $record, $sign ) { if ( ! empty( $record['skip'] ) || ! isset( $record['before'], $record['after'] ) ) { return; } $s['files'] = max( 0, $s['files'] + $sign ); $s['before'] = max( 0, $s['before'] + $sign * (int) $record['before'] ); $s['after'] = max( 0, $s['after'] + $sign * (int) $record['after'] ); // The smallest WebP/AVIF copy, measured against the file as it was uploaded. $copies = array_filter( array( empty( $record['webp'] ) ? 0 : (int) $record['webp'], empty( $record['avif'] ) ? 0 : (int) $record['avif'], ) ); if ( $copies ) { $s['next_before'] = max( 0, $s['next_before'] + $sign * (int) $record['before'] ); $s['next_after'] = max( 0, $s['next_after'] + $sign * min( $copies ) ); } } /** * Recalculate the totals from the per-image records. Runs once after an * update, so totals stay right when what they count changes. */ public static function rebuild_stats() { global $wpdb; $s = self::empty_stats(); $offset = 0; do { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-off recalculation. $rows = $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s ORDER BY meta_id LIMIT %d, 1000", '_leanpixel_data', $offset ) ); foreach ( $rows as $row ) { $data = maybe_unserialize( $row ); if ( is_array( $data ) && ! empty( $data['files'] ) && is_array( $data['files'] ) ) { foreach ( $data['files'] as $record ) { if ( is_array( $record ) ) { self::apply_record( $s, $record, 1 ); } } } } $offset += 1000; } while ( count( $rows ) === 1000 ); update_option( self::STATS, $s, false ); } public static function maybe_upgrade() { if ( get_option( self::VERSION_OPTION ) !== LEANPIXEL_VERSION ) { self::rebuild_stats(); LeanPixel_Optimizer::migrate_backup_dir(); LeanPixel_Delivery::sync_rules(); delete_transient( 'leanpixel_lock' ); update_option( self::VERSION_OPTION, LEANPIXEL_VERSION, false ); } } }