Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
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
4 changes: 2 additions & 2 deletions src/admin/js/media-manager/drop-zone.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ function addDropZoneListenerToMediaManager( targetDocument ) {
}

// Get the file converters for the incoming files.
const fileConverters = Array.from( event.dataTransfer.files )
.map( file => getFileConverter( file ) )
const files = Array.from( event.dataTransfer.files )
const fileConverters = await Promise.all( files.map( file => getFileConverter( file ) ) )
Comment on lines +37 to +38
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: same await-before-preventDefault race condition as in select-files.js.

event.preventDefault() on line 65 is unreachable until after the await Promise.all(...) on line 38 resolves. By then the browser has already finished dispatching the drop event — native drop handling (and potentially navigation to the dropped file) will have already occurred.

Move preventDefault() / stopPropagation() before the first await, and re-dispatch the event if conversion turns out to be unnecessary.

Proposed fix (same pattern as select-files.js)
 	const customDropHandler = async event => {
 		if ( event.__cimo_converted ) {
 			return
 		}

 		const files = Array.from( event.dataTransfer.files )
+
+		// Must stop the event synchronously before any await
+		event.preventDefault()
+		event.stopPropagation()
+
 		const fileConverters = await Promise.all( files.map( file => getFileConverter( file ) ) )

 		if ( ! requiresFileConversion( fileConverters ) ) {
+			// No conversion needed — re-dispatch the original drop
+			const dropEvent = new DragEvent( 'drop', { bubbles: true } )
+			Object.defineProperty( dropEvent, 'dataTransfer', {
+				value: event.dataTransfer,
+				writable: false,
+			} )
+			dropEvent.__cimo_converted = true
+			event.target.dispatchEvent( dropEvent )
 			return
 		}
 		...
-		event.preventDefault()
-		event.stopPropagation()

Also applies to: 65-66

🤖 Prompt for AI Agents
In `@src/admin/js/media-manager/drop-zone.js` around lines 37 - 38, The drop
handler is calling await Promise.all(...) before calling
event.preventDefault()/stopPropagation(), causing a race where native drop
behavior can occur; move event.preventDefault() and event.stopPropagation() to
immediately after entering the handler (before the first await), then proceed to
compute files and call getFileConverter(...) / Promise.all; if you determine
after conversion that no custom processing is needed, re-dispatch the original
event (or synthesize a new one) so native handling can continue. Update the code
around the drop handler where files, fileConverters, and getFileConverter are
used to reflect this ordering and add the re-dispatch fallback.


// Do not continue if we do not need to convert any files.
if ( ! requiresFileConversion( fileConverters ) ) {
Expand Down
4 changes: 2 additions & 2 deletions src/admin/js/media-manager/select-files.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ function addSelectFilesListenerToFileUploads( targetDocument ) {
}

// Get the file converters for the incoming files.
const fileConverters = Array.from( event.target.files )
.map( file => getFileConverter( file ) )
const files = Array.from( event.target.files )
const fileConverters = await Promise.all( files.map( file => getFileConverter( file ) ) )
Comment on lines +37 to +38
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: event.preventDefault() is called after await — the event will have already propagated, causing double uploads.

After the await Promise.all(...) on line 38, the synchronous portion of this async handler returns and the browser finishes dispatching the event. By the time execution resumes and reaches preventDefault() / stopImmediatePropagation() on lines 64–66, the original change event has already been fully processed by WordPress's native upload handler. This means the unconverted files get uploaded by WP and the converted files get uploaded by your synthetic event — a double-upload regression.

Before this PR getFileConverter was synchronous, so the preventDefault was reached synchronously. Now that it's async, you must call preventDefault before the first await.

Proposed fix
 	const selectFilesListener = async event => {
 		// Check if it's a file select.
 		if ( event.target.type !== 'file' ) {
 			return
 		}

 		// If this is a synthetic change event dispatched by us after conversion, skip conversion.
 		if ( event.__cimo_converted ) {
 			return
 		}

 		// Get the file converters for the incoming files.
 		const files = Array.from( event.target.files )
+
+		// Must stop the event synchronously before any await,
+		// otherwise the browser will finish dispatching the event
+		// while we're waiting for the async format-support check.
+		event.preventDefault()
+		event.stopPropagation()
+		event.stopImmediatePropagation()
+
 		const fileConverters = await Promise.all( files.map( file => getFileConverter( file ) ) )

 		// Do not continue if we do not need to convert any files.
 		if ( ! requiresFileConversion( fileConverters ) ) {
+			// No conversion needed — re-dispatch the original event
+			const changeEvent = new Event( 'change', { bubbles: true } )
+			changeEvent.__cimo_converted = true
+			event.target.dispatchEvent( changeEvent )
 			return
 		}
 		...
-		// Prevent the default file handling
-		event.preventDefault()
-		event.stopPropagation()
-		event.stopImmediatePropagation()

Note: The same issue exists in src/admin/js/media-manager/drop-zone.js at lines 37–38 / 65–66.

Also applies to: 64-66

🤖 Prompt for AI Agents
In `@src/admin/js/media-manager/select-files.js` around lines 37 - 38, The change
handler currently awaits Promise.all(files.map(file => getFileConverter(file)))
before calling event.preventDefault() and event.stopImmediatePropagation(),
which lets WordPress process the original change and causes double uploads; move
the calls to event.preventDefault() and event.stopImmediatePropagation() to the
top of the handler (i.e., immediately after entering the handler and before the
first await/Promise.all) so the native upload is suppressed, then perform the
async getFileConverter/Promise.all work and dispatch the synthetic upload; apply
the same fix in the analogous handler in drop-zone.js.


// Do not continue if we do not need to convert any files.
if ( ! requiresFileConversion( fileConverters ) ) {
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
53 changes: 34 additions & 19 deletions 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 { isFormatSupported } from './util'

// 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,24 +174,37 @@ 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

canvas.toBlob( function( blob ) {
// Clean up resources
URL.revokeObjectURL( objectUrl )
objectUrl = null

// Clear canvas to free memory
ctx.clearRect( 0, 0, canvas.width, canvas.height )
canvas.width = 0
canvas.height = 0

if ( blob ) {
resolve( blob )
} else {
reject( new Error( 'Failed to convert image' ) )
const q = ( outputFormat === 'webp' || outputFormat === 'jpg' || outputFormat === 'avif' ) ? quality : undefined

if ( outputFormat === 'avif' ) {
try {
const { encode: encodeAvif } = await import( '@jsquash/avif' )
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' }` ) )
}
}, format.mimeType, q )
} else {
canvas.toBlob( function( blob ) {
// Clean up resources
URL.revokeObjectURL( objectUrl )
objectUrl = null

// Clear canvas to free memory
ctx.clearRect( 0, 0, canvas.width, canvas.height )
canvas.width = 0
canvas.height = 0

if ( blob ) {
resolve( blob )
} else {
reject( new Error( 'Failed to convert image' ) )
}
}, format.mimeType, q )
}
}

img.onerror = () => {
Expand Down Expand Up @@ -225,8 +241,7 @@ class ImageConverter extends Converter {
}

// Check if the browser supports the desired output format
const testCanvas = document.createElement( 'canvas' )
if ( formatInfo && ! testCanvas.toDataURL( formatInfo.mimeType ).startsWith( `data:${ formatInfo.mimeType }` ) ) {
if ( ! await isFormatSupported( format ) ) {
// If not supported, skip conversion and return the original file
// eslint-disable-next-line no-console
console.error( '[Cimo] ' + format + ' is not supported by the browser, please use another modern browser' )
Expand Down
48 changes: 6 additions & 42 deletions src/shared/converters/index.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { ImageConverter } from './image-converter'
import { NullConverter } from './null-converter'
import { applyFilters } from '@wordpress/hooks'
import { isFormatSupported } from './util'

/**
* Get the file converter for the given file.
*
* @param {File} _file - The file to get the converter for.
* @return {Converter} - The file converter.
*/
export const getFileConverter = _file => {
export const getFileConverter = async _file => {
let file = _file
// In some cases (e.g., when called from an iframe), the File object may come from a different window context,
// so instanceof File can fail even if it's a valid File. Instead, check for file-like shape.
Expand Down Expand Up @@ -38,13 +39,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 ( ! await isFormatSupported( imageOutputFormat ) ) {
return new NullConverter( file )
}
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 All @@ -67,41 +69,3 @@ export function requiresFileConversion( converters ) {
return ! converters.every( converter => converter.constructor.name === 'NullConverter' )
}

/**
* Check if a specific image format is supported by the browser
*
* @param {string} format - Format name ('webp', 'jpg', 'png', 'avif') or MIME type ('image/webp')
* @return {boolean} - True if format is supported, false otherwise
*/
function isFormatSupported( format ) {
if ( ! format || typeof format !== 'string' ) {
return false
}

// Map format names to MIME types
const formatMap = {
webp: 'image/webp',
avif: 'image/avif',
}

// Get MIME type (either from map or use as-is if already a MIME type)
const mimeType = formatMap[ format.toLowerCase() ] || ( format.startsWith( 'image/' ) ? format : null )

if ( ! mimeType ) {
return false
}

// Create a test canvas and check if toDataURL supports the format
const canvas = document.createElement( 'canvas' )
canvas.width = 1
canvas.height = 1

try {
const dataUrl = canvas.toDataURL( mimeType )
// If the browser doesn't support the format, it falls back to image/png
// Check if the data URL starts with the requested mime type
return dataUrl.startsWith( `data:${ mimeType }` )
} catch ( e ) {
return false
}
}
Loading