Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"prettier": "^3.3.3"
},
"dependencies": {
"@jsquash/avif": "^2.1.1",
"@mediabunny/mp3-encoder": "^1.25.0",
"mediabunny": "^1.25.0"
}
Expand Down
8 changes: 8 additions & 0 deletions src/admin/class-admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ public function register_settings() {
'schema' => [
'type' => 'object',
'properties' => [
'image_output_format' => [
'type' => 'string',
],
Comment on lines +59 to +62
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Restrict image_output_format to an allowlist of valid values.

Currently the schema has no enum constraint and the sanitizer only calls sanitize_text_field, meaning any arbitrary string (including malicious input) can be persisted. Since only 'webp' and 'avif' are valid, add validation at both layers:

Proposed fix

In the REST schema (around line 59):

 'image_output_format' => [
     'type' => 'string',
+    'enum' => [ 'webp', 'avif' ],
 ],

In sanitize_options (around line 225):

 if ( isset( $options['image_output_format'] ) ) {
-    $sanitized['image_output_format'] = sanitize_text_field( $options['image_output_format'] );
+    $format = sanitize_text_field( $options['image_output_format'] );
+    $sanitized['image_output_format'] = in_array( $format, [ 'webp', 'avif' ], true ) ? $format : 'webp';
 }

Also applies to: 224-227

🤖 Prompt for AI Agents
In `@src/admin/class-admin.php` around lines 59 - 61, The REST schema's
'image_output_format' entry lacks an enum and sanitize_options does not validate
allowed values; update the schema for 'image_output_format' (in class-admin.php)
to include an 'enum' restricting values to 'webp' and 'avif', and in the
sanitize_options function validate the incoming option by calling
sanitize_text_field then checking against the allowlist (e.g., in_array($val,
['webp','avif'], true)), returning a safe default or removing/setting null when
invalid; apply the same validation logic to the related code block around lines
224-227 so only 'webp' or 'avif' can be persisted.

'webp_quality' => [
'type' => 'integer',
],
Expand Down Expand Up @@ -218,6 +221,11 @@ public function sanitize_options( $options ) {
$current = get_option( 'cimo_options', [] );
$sanitized = is_array( $current ) ? $current : [];

// Sanitize image output format
if ( isset( $options['image_output_format'] ) ) {
$sanitized['image_output_format'] = sanitize_text_field( $options['image_output_format'] );
}

// Sanitize webp quality
if ( isset( $options['webp_quality'] ) ) {
$quality = absint( $options['webp_quality'] );
Expand Down
1 change: 1 addition & 0 deletions src/admin/class-script-loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ public function enqueue_cimo_assets() {
[
'restUrl' => rest_url( 'cimo/v1/' ),
'nonce' => wp_create_nonce( 'wp_rest' ),
'imageOutputFormat' => ! empty( $settings['image_output_format'] ) ? $settings['image_output_format'] : 'webp',
'webpQuality' => ! empty( $settings['webp_quality'] ) ? (int) $settings['webp_quality'] : 80,
'maxImageDimension' => ! empty( $settings['max_image_dimension'] ) ? (int) $settings['max_image_dimension'] : 0,
'videoOptimizationEnabled' => isset( $settings['video_optimization_enabled'] ) ? (int) $settings['video_optimization_enabled'] : 1,
Expand Down
27 changes: 27 additions & 0 deletions src/admin/js/page/admin-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const buildType = applyFilters( 'cimo.admin.settings.buildType', 'free' )

const AdminSettings = () => {
const [ settings, setSettings ] = useState( {
imageOutputFormat: 'webp',
webpQuality: 80,
maxImageDimension: '',
disableWpScaling: 1,
Expand Down Expand Up @@ -71,6 +72,7 @@ const AdminSettings = () => {
const cimoOptions = data.cimo_options || {}
const fetchedSettings = {
// Image Optimization settings
imageOutputFormat: cimoOptions.image_output_format || 'webp',
webpQuality: cimoOptions.webp_quality !== undefined ? cimoOptions.webp_quality : 80,
maxImageDimension: cimoOptions.max_image_dimension || '',
disableWpScaling: cimoOptions.disable_wp_scaling !== undefined ? cimoOptions.disable_wp_scaling : 1,
Expand Down Expand Up @@ -132,6 +134,7 @@ const AdminSettings = () => {
setSettings( settings => {
return {
...settings,
imageOutputFormat: 'webp',
webpQuality: 80,
maxImageDimension: 1920,
disableWpScaling: 1,
Expand All @@ -145,6 +148,7 @@ const AdminSettings = () => {
setSettings( settings => {
return {
...settings,
imageOutputFormat: 'webp',
webpQuality: '',
maxImageDimension: '',
disableWpScaling: 1,
Expand Down Expand Up @@ -229,6 +233,7 @@ const AdminSettings = () => {
data: {
cimo_options: {
// Image Optimization settings
image_output_format: settings.imageOutputFormat,
webp_quality: parseInt( settings.webpQuality ) || 0,
max_image_dimension: parseInt( settings.maxImageDimension ) || 0,
disable_wp_scaling: settings.disableWpScaling,
Expand Down Expand Up @@ -397,6 +402,28 @@ const AdminSettings = () => {
</Button>
</div>

<div className="cimo-setting-field">
<ToggleGroupControl
__nextHasNoMarginBottom
__next40pxDefaultSize
label={ __( 'Image Output Format', 'cimo-image-optimizer' ) }
value={ settings.imageOutputFormat || 'webp' }
onChange={ value => handleInputChange( 'imageOutputFormat', value ) }
isBlock
help={ __( 'Set the resulting format of the optimized files.', 'cimo-image-optimizer' ) }
>
<ToggleGroupControlOption
value="webp"
label={ __( 'WebP', 'cimo-image-optimizer' ) }
/>
<ToggleGroupControlOption
value="avif"
label={ __( 'AVIF', 'cimo-image-optimizer' ) }
disabled={ buildType === 'free' }
/>
</ToggleGroupControl>
</div>

<div className="cimo-setting-field">
<RangeControl
id="webpQuality"
Expand Down
25 changes: 24 additions & 1 deletion src/shared/converters/image-converter.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Converter } from './converter-abstract'
import { encode as encodeAvif } from '@jsquash/avif'

// Supported output formats
const supportedFormats = [
{ value: 'webp', mimeType: 'image/webp' },
{ value: 'jpg', mimeType: 'image/jpeg' },
{ value: 'png', mimeType: 'image/png' },
{ value: 'avif', mimeType: 'image/avif' },
]

/**
Expand All @@ -19,6 +21,7 @@ class ImageConverter extends Converter {
'image/png',
'image/webp',
'image/jpg',
'image/avif',
]
}

Expand Down Expand Up @@ -171,7 +174,27 @@ class ImageConverter extends Converter {

const format = supportedFormats.find( f => f.value === outputFormat )
// Only use quality for lossy formats
const q = ( outputFormat === 'webp' || outputFormat === 'jpg' ) ? quality : undefined
const q = ( outputFormat === 'webp' || outputFormat === 'jpg' || outputFormat === 'avif' ) ? quality : undefined

if ( outputFormat === 'avif' ) {
try {
const imageData = ctx.getImageData( 0, 0, width, height )
const avifQuality = Math.max( 1, Math.min( 100, Math.round( ( ( q ?? 0.5 ) * 100 ) ) ) )
const avifBuffer = await encodeAvif( imageData, { quality: avifQuality } )
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

@jsquash/avif encode function quality parameter documentation

💡 Result:

@jsquash/avif’s encode(imageData, options) does not expose a quality option. Instead, AVIF “quality” is controlled via quantizer / constant-quality options—primarily cqLevel (and cqAlphaLevel for alpha). The package’s EncodeOptions type includes cqLevel, cqAlphaLevel, speed, subsample, etc. [1].

  • cqLevel: constant/constrained quality level on a 0–63 scale (lower = better). In libavif/libaom tooling, 0 is lossless, and the range is 0–63 [2].
  • If you’re looking for a JPEG-style 0–100 “quality” like avifenc -q 75 ..., that’s a libavif CLI convenience flag, not the option used by @jsquash/avif’s JS API [3].

Practical mapping (if you have a 0–100 “quality” and want a cqLevel):

// quality: 0..100 where 100 ~= best
const cqLevel = Math.round((100 - quality) * 63 / 100);

(This mapping is approximate; different encoders/settings can shift results.)

Sources: [1] EncodeOptions in jsquash-avif (cqLevel, cqAlphaLevel, etc.) https://cdn.jsdelivr.net/npm/jsquash-avif@1.0.2/codec/enc/avif_enc.d.ts • [2] libavif/libaom quantizer range notes (0–63, 0 lossless) https://github.com/vHeemstra/imagemin-avifenc • [3] libavif CLI example using -q https://github.com/AOMediaCodec/libavif


Fix AVIF quality parameter: replace quality with cqLevel.

@jsquash/avif's encode() does not support a quality parameter. It uses cqLevel on a 0–63 scale (where 0 is lossless). The current code passes { quality: avifQuality }, which is silently ignored, and the encoder uses its default settings instead.

Map quality (0–1) to cqLevel (0–63) with: cqLevel: Math.round((100 - (q ?? 0.5) * 100) * 63 / 100).

🤖 Prompt for AI Agents
In `@src/shared/converters/image-converter.js` around lines 182 - 183, The AVIF
encoder call using encodeAvif(imageData, { quality: avifQuality }) is wrong
because `@jsquash/avif` expects cqLevel (0–63) not quality; update the encodeAvif
call in the image conversion flow (where avifQuality and q are calculated) to
pass cqLevel instead, mapping q (0–1) to the 0–63 scale using the provided
formula: cqLevel: Math.round((100 - (q ?? 0.5) * 100) * 63 / 100), so that
encodeAvif(imageData, { cqLevel: ... }) receives the correct parameter.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

const avifBlob = new Blob( [ avifBuffer ], { type: 'image/avif' } )
resolve( avifBlob )
} catch ( e ) {
reject( new Error( `Failed to encode AVIF: ${ e instanceof Error ? e.message : 'Unknown error' }` ) )
}
} else {
canvas.toBlob( function( blob ) {
if ( blob ) {
resolve( blob )
} else {
reject( new Error( 'Failed to convert image' ) )
}
}, format.mimeType, quality )
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

canvas.toBlob( function( blob ) {
// Clean up resources
Expand Down
7 changes: 4 additions & 3 deletions src/shared/converters/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,14 @@ export const getFileConverter = _file => {
}

if ( file.type.startsWith( 'image/' ) ) {
// If the browser doesn't support webp, then we can't convert it.
if ( ! isFormatSupported( 'webp' ) ) {
const imageOutputFormat = window.cimoSettings?.imageOutputFormat || 'webp'
// If the browser doesn't support set output format, then we can't convert it.
if ( ! isFormatSupported( imageOutputFormat ) ) {
return new NullConverter( file )
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: isFormatSupported is async but is not awaited — the check is always truthy.

isFormatSupported() returns a Promise. Without await, ! promise is always false, so the unsupported-format guard on line 44 never fires. Every format—including unsupported ones—will reach ImageConverter.

Since getFileConverter is synchronous, you'll need to either:

  1. Make getFileConverter async and await the call, updating all call sites, or
  2. Pre-check format support at initialization (e.g., eagerly populate the cache) and expose a synchronous lookup.
🤖 Prompt for AI Agents
In `@src/shared/converters/index.js` around lines 42 - 46, The call to
isFormatSupported (which is async) inside getFileConverter is not awaited, so
the unsupported-format branch never runs; update getFileConverter to handle this
by either making getFileConverter async and awaiting isFormatSupported before
returning a NullConverter (adjusting all call sites to await getFileConverter),
or alternatively perform the async support checks at startup to populate a
synchronous lookup/cache (then change the check to consult that cache
synchronously) so the code that selects between NullConverter and ImageConverter
(referencing getFileConverter, isFormatSupported, NullConverter, ImageConverter,
and window.cimoSettings?.imageOutputFormat) works correctly.

if ( ImageConverter.supportsMimeType( file.type ) ) {
return new ImageConverter( file, {
format: 'webp',
format: imageOutputFormat,
quality: window.cimoSettings?.webpQuality || 0.8,
maxDimension: window.cimoSettings?.maxImageDimension || 0,
} )
Expand Down