diff --git a/CHANGELOG.md b/CHANGELOG.md
index 25c478c..618063b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## 3.0.1.3
+
+- [LDEV-6243](https://luceeserver.atlassian.net/browse/LDEV-6243) fix member function race condition under concurrent requests
+- [LDEV-6244](https://luceeserver.atlassian.net/browse/LDEV-6244) replace GetApplicationSettings BIF with getCustom() to avoid DummyWSHandler exception spam
+- [LDEV-6245](https://luceeserver.atlassian.net/browse/LDEV-6245) performance: remove redundant coder cache, fix double write, reduce Tika MIME detection calls, eliminate exception-driven format matching
+
## 3.0.1.2 (2026-03-26)
- [LDEV-5129](https://luceeserver.atlassian.net/browse/LDEV-5129) remove unused bundled jars: commons-io (CVE-2024-47554), xmpcore, apiguardian, hamcrest, opentest4j
diff --git a/pom.xml b/pom.xml
index d9c4f58..dbeace8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,7 +4,7 @@
4.0.0
org.lucee
image-extension
- 3.0.1.2-SNAPSHOT
+ 3.0.1.3-SNAPSHOT
pom
Image Extension
diff --git a/source/java/src/org/lucee/extension/image/ImageUtil.java b/source/java/src/org/lucee/extension/image/ImageUtil.java
index d3a976a..ac2a524 100644
--- a/source/java/src/org/lucee/extension/image/ImageUtil.java
+++ b/source/java/src/org/lucee/extension/image/ImageUtil.java
@@ -54,8 +54,6 @@
public class ImageUtil {
- private static Coder _coder;
-
private static final boolean useSunCodec = getSunCodec();
private static Class JPEGCodec;
private static Class JPEGEncodeParam;
@@ -63,10 +61,7 @@ public class ImageUtil {
private static int counter = 0;
private static Coder getCoder() {
- if (true || _coder == null) {
- _coder = Coder.getInstance(CFMLEngineFactory.getInstance().getThreadPageContext());
- }
- return _coder;
+ return Coder.getInstance(CFMLEngineFactory.getInstance().getThreadPageContext());
}
public static String getOneWriterFormatName(String... preferences) throws IOException {
@@ -179,23 +174,34 @@ public static byte[] readBase64(String b64str, StringBuilder mimetype) throws IO
return Base64.decodeBase64(b64str.getBytes());
}
+ /**
+ * Detect the image format for a resource. The detection order is:
+ * 1. Coder-specific magic byte detection (JDeli, TwelveMonkeys etc) — handles misnamed files
+ * 2. MIME type detection via Tika (content-based) — fallback for formats coders don't recognise
+ * 3. File extension — last resort, trusts the filename
+ *
+ * MIME type (Tika) is resolved once and passed to all coders to avoid repeated detection.
+ * This is critical for performance — Tika's magic byte scanning is expensive (~600k allocations
+ * per 5k image ops when called per-coder).
+ *
+ * Misnamed files (e.g. a JPEG saved as .png) are handled by steps 1 and 2 which inspect
+ * actual file content, not the extension.
+ */
public static String getFormat(Resource res) throws IOException {
long len = res.length();
+ // resolve MIME type once and pass to coders — avoids repeated Tika detection
+ String mt = len > 0 ? getMimeType(res, null) : null;
Coder c = getCoder();
if (c instanceof FormatExtract) {
- String format = ((FormatExtract) c).getFormat(res, null);
+ String format = ((FormatExtract) c).getFormat(res, mt, null);
if (!Util.isEmpty(format, true)) {
return format;
}
}
- // there is no need to check the mime type if the file is empty
- if (len > 0) {
- String mt = getMimeType(res, null);
- if (!Util.isEmpty(mt)) {
- String format = getImageFormatFromMimeType(mt, null);
- if (!Util.isEmpty(format)) {
- return format;
- }
+ if (!Util.isEmpty(mt)) {
+ String format = getImageFormatFromMimeType(mt, null);
+ if (!Util.isEmpty(format)) {
+ return format;
}
}
return getFormatFromExtension(res, null);
@@ -210,21 +216,23 @@ public static String getMimeType(byte[] binary, String defaultValue) {
}
public static String getFormat(byte[] binary) throws IOException {
+ String mt = CFMLEngineFactory.getInstance().getResourceUtil().getMimeType(binary, "");
Coder c = getCoder();
if (c instanceof FormatExtract) {
- String format = ((FormatExtract) c).getFormat(binary, null);
+ String format = ((FormatExtract) c).getFormat(binary, mt, null);
if (!Util.isEmpty(format, true)) return format;
}
- return getFormatFromMimeType(CFMLEngineFactory.getInstance().getResourceUtil().getMimeType(binary, ""));
+ return getFormatFromMimeType(mt);
}
public static String getFormat(byte[] binary, String defaultValue) {
+ String mt = CFMLEngineFactory.getInstance().getResourceUtil().getMimeType(binary, "");
Coder c = getCoder();
if (c instanceof FormatExtract) {
- String format = ((FormatExtract) c).getFormat(binary, null);
+ String format = ((FormatExtract) c).getFormat(binary, mt, null);
if (!Util.isEmpty(format, true)) return format;
}
- return getImageFormatFromMimeType(CFMLEngineFactory.getInstance().getResourceUtil().getMimeType(binary, ""), defaultValue);
+ return getImageFormatFromMimeType(mt, defaultValue);
}
public static String toFormat(String format) {
diff --git a/source/java/src/org/lucee/extension/image/coder/AImageIOInterface.java b/source/java/src/org/lucee/extension/image/coder/AImageIOInterface.java
index 5e241e1..129eec6 100644
--- a/source/java/src/org/lucee/extension/image/coder/AImageIOInterface.java
+++ b/source/java/src/org/lucee/extension/image/coder/AImageIOInterface.java
@@ -110,7 +110,10 @@ public BufferedImage read(byte[] bytes, String format) throws IOException {
@Override
public void write(Image img, Resource destination, String format, float quality, boolean noMeta) throws IOException {
- if (destination instanceof File) writeImage(img, destination, format, quality, noMeta);
+ if (destination instanceof File) {
+ writeImage(img, destination, format, quality, noMeta);
+ return;
+ }
OutputStream os = null;
try {
os = destination.getOutputStream();
@@ -169,6 +172,14 @@ public String getFormat(Resource res, String defaultValue) {
}
+ @Override
+ public String getFormat(Resource res, String mimeType, String defaultValue) {
+ if (!Util.isEmpty(mimeType)) {
+ return getFormatbyMimeType(mimeType, defaultValue);
+ }
+ return getFormat(res, defaultValue);
+ }
+
@Override
public String getFormat(byte[] bytes) throws IOException {
return getFormatbyMimeType(CFMLEngineFactory.getInstance().getResourceUtil().getMimeType(bytes, null));
@@ -179,30 +190,30 @@ public String getFormat(byte[] bytes, String defaultValue) {
return getFormatbyMimeType(CFMLEngineFactory.getInstance().getResourceUtil().getMimeType(bytes, null), defaultValue);
}
- private String getFormatbyMimeType(String mimeType, String defaultValue) {
+ @Override
+ public String getFormat(byte[] bytes, String mimeType, String defaultValue) {
if (!Util.isEmpty(mimeType)) {
- try {
- return getFormatbyMimeType(mimeType);
- }
- catch (Throwable t) {
- if (t instanceof ThreadDeath) throw (ThreadDeath) t;
- }
+ return getFormatbyMimeType(mimeType, defaultValue);
}
- return defaultValue;
+ return getFormat(bytes, defaultValue);
}
- private String getFormatbyMimeType(String mimeType) throws IOException {
+ private String getFormatbyMimeType(String mimeType, String defaultValue) {
if (!Util.isEmpty(mimeType)) {
-
for (Map.Entry e: codecs.entrySet()) {
for (String mt: e.getValue().mimeTypes) {
-
if (mimeType.equalsIgnoreCase(mt)) {
return e.getKey();
}
}
}
}
+ return defaultValue;
+ }
+
+ private String getFormatbyMimeType(String mimeType) throws IOException {
+ String result = getFormatbyMimeType(mimeType, null);
+ if (result != null) return result;
throw new IOException("no matching format found for mimetype [" + mimeType + "]");
}
diff --git a/source/java/src/org/lucee/extension/image/coder/ImageIOCoder.java b/source/java/src/org/lucee/extension/image/coder/ImageIOCoder.java
index 1cf5c83..1216abd 100644
--- a/source/java/src/org/lucee/extension/image/coder/ImageIOCoder.java
+++ b/source/java/src/org/lucee/extension/image/coder/ImageIOCoder.java
@@ -139,6 +139,14 @@ public String getFormat(Resource res, String defaultValue) {
return defaultValue;
}
+ @Override
+ public String getFormat(Resource res, String mimeType, String defaultValue) {
+ if (!Util.isEmpty(mimeType)) {
+ return getFormatbyMimeType(mimeType, defaultValue);
+ }
+ return getFormat(res, defaultValue);
+ }
+
@Override
public String getFormat(byte[] bytes) throws IOException {
return getFormatbyMimeType(CFMLEngineFactory.getInstance().getResourceUtil().getMimeType(bytes, null));
@@ -149,27 +157,31 @@ public String getFormat(byte[] bytes, String defaultValue) {
return getFormatbyMimeType(CFMLEngineFactory.getInstance().getResourceUtil().getMimeType(bytes, null), defaultValue);
}
+ @Override
+ public String getFormat(byte[] bytes, String mimeType, String defaultValue) {
+ if (!Util.isEmpty(mimeType)) {
+ return getFormatbyMimeType(mimeType, defaultValue);
+ }
+ return getFormat(bytes, defaultValue);
+ }
+
private String getFormatbyMimeType(String mimeType, String defaultValue) {
if (!Util.isEmpty(mimeType)) {
try {
- return getFormatbyMimeType(mimeType);
+ Iterator it = ImageIO.getImageReadersByMIMEType(mimeType);
+ while (it != null && it.hasNext()) {
+ return it.next().getFormatName();
+ }
}
- catch (Throwable t) {
- if (t instanceof ThreadDeath) throw (ThreadDeath) t;
+ catch (IOException e) {
}
}
return defaultValue;
}
private String getFormatbyMimeType(String mimeType) throws IOException {
- if (!Util.isEmpty(mimeType)) {
- Iterator it = ImageIO.getImageReadersByMIMEType(mimeType);
- while (it != null && it.hasNext()) {
- String fn = it.next().getFormatName();
- return fn;
- }
-
- }
+ String result = getFormatbyMimeType(mimeType, null);
+ if (result != null) return result;
throw new IOException("no matching format found for mimetype [" + mimeType + "]");
}
diff --git a/source/java/src/org/lucee/extension/image/coder/MultiCoder.java b/source/java/src/org/lucee/extension/image/coder/MultiCoder.java
index a09bc90..2633291 100644
--- a/source/java/src/org/lucee/extension/image/coder/MultiCoder.java
+++ b/source/java/src/org/lucee/extension/image/coder/MultiCoder.java
@@ -276,9 +276,14 @@ public String getFormat(byte[] bytes) throws IOException {
@Override
public String getFormat(Resource res, String defaultValue) {
+ return getFormat(res, null, defaultValue);
+ }
+
+ @Override
+ public String getFormat(Resource res, String mimeType, String defaultValue) {
for (Coder coder: coders) {
if (!(coder instanceof FormatExtract)) continue;
- String format = ((FormatExtract) coder).getFormat(res, null);
+ String format = ((FormatExtract) coder).getFormat(res, mimeType, null);
if (!Util.isEmpty(format)) {
return format;
}
@@ -288,9 +293,14 @@ public String getFormat(Resource res, String defaultValue) {
@Override
public String getFormat(byte[] bytes, String defaultValue) {
+ return getFormat(bytes, null, defaultValue);
+ }
+
+ @Override
+ public String getFormat(byte[] bytes, String mimeType, String defaultValue) {
for (Coder coder: coders) {
if (!(coder instanceof FormatExtract)) continue;
- String format = ((FormatExtract) coder).getFormat(bytes, null);
+ String format = ((FormatExtract) coder).getFormat(bytes, mimeType, null);
if (!Util.isEmpty(format)) return format;
}
return defaultValue;
diff --git a/source/java/src/org/lucee/extension/image/format/FormatExtract.java b/source/java/src/org/lucee/extension/image/format/FormatExtract.java
index 8bbf0c0..bffd309 100644
--- a/source/java/src/org/lucee/extension/image/format/FormatExtract.java
+++ b/source/java/src/org/lucee/extension/image/format/FormatExtract.java
@@ -12,4 +12,13 @@ public interface FormatExtract {
public abstract String getFormat(byte[] bytes) throws IOException;
public abstract String getFormat(byte[] bytes, String defaultValue);
+
+ // overloads that accept a pre-resolved MIME type to avoid repeated Tika detection
+ default String getFormat(Resource res, String mimeType, String defaultValue) {
+ return getFormat(res, defaultValue);
+ }
+
+ default String getFormat(byte[] bytes, String mimeType, String defaultValue) {
+ return getFormat(bytes, defaultValue);
+ }
}
diff --git a/source/java/src/org/lucee/extension/image/util/CommonUtil.java b/source/java/src/org/lucee/extension/image/util/CommonUtil.java
index 0710b33..5d23af3 100644
--- a/source/java/src/org/lucee/extension/image/util/CommonUtil.java
+++ b/source/java/src/org/lucee/extension/image/util/CommonUtil.java
@@ -36,8 +36,8 @@ public class CommonUtil {
public static final short UNDEFINED_NODE = -1;
private static final String _8220 = String.valueOf((char) 8220);
- private static Map members;
- private static BIF GetApplicationSettings;
+ private static volatile Map members;
+ private static final Object membersLock = new Object();
public static String unwrap(String str) {
if (str == null) return "";
@@ -156,31 +156,34 @@ else if (res.isDirectory()) {
public static Map getMembers(PageContext pc) throws PageException {
if (members == null) {
- Cast cast = CFMLEngineFactory.getInstance().getCastUtil();
- members = new HashMap();
- ConfigWeb config = pc.getConfig();
- Object[] flds = getFLDs(config, 1);
- Map funcs;
- Iterator it;
- Object func;
- String[] names;
- boolean chaining;
- BIF bif;
- Coll coll;
- for (int i = 0; i < flds.length; i++) {
- funcs = getFunctions(flds[i]);
- it = funcs.values().iterator();
- while (it.hasNext()) {
- func = it.next();
- if (getMemberType(func) == Image.TYPE_IMAGE) {
- names = getMemberNames(func);
- if (names != null && names.length > 0) {
- coll = new Coll(getBIF(func), getMemberChaining(func));
- for (String name: names) {
- members.put(cast.toKey(name), coll);
+ synchronized (membersLock) {
+ if (members == null) {
+ Cast cast = CFMLEngineFactory.getInstance().getCastUtil();
+ Map local = new HashMap<>();
+ ConfigWeb config = pc.getConfig();
+ Object[] flds = getFLDs(config, 1);
+ Map funcs;
+ Iterator it;
+ Object func;
+ String[] names;
+ Coll coll;
+ for (int i = 0; i < flds.length; i++) {
+ funcs = getFunctions(flds[i]);
+ it = funcs.values().iterator();
+ while (it.hasNext()) {
+ func = it.next();
+ if (getMemberType(func) == Image.TYPE_IMAGE) {
+ names = getMemberNames(func);
+ if (names != null && names.length > 0) {
+ coll = new Coll(getBIF(func), getMemberChaining(func));
+ for (String name: names) {
+ local.put(cast.toKey(name), coll);
+ }
+ }
}
}
}
+ members = local;
}
}
}
@@ -268,22 +271,34 @@ public static void close(ImageInputStream iis) {
}
}
+ private static Method getCustomMethod;
+
public static Set getCoders(StringBuilder sb, PageContext pc) {
Set result = null;
try {
CFMLEngine eng = CFMLEngineFactory.getInstance();
if (pc == null) pc = eng.getThreadPageContext();
if (pc == null) return null;
- if (GetApplicationSettings == null) {
- GetApplicationSettings = eng.getClassUtil().loadBIF(pc, "lucee.runtime.functions.system.GetApplicationSettings");
+
+ Object ac = pc.getApplicationContext();
+ if (ac == null) return null;
+
+ // read this.image from ApplicationContext via getCustom(Key)
+ Object o = null;
+ if (getCustomMethod == null || getCustomMethod.getDeclaringClass() != ac.getClass()) {
+ try {
+ getCustomMethod = ac.getClass().getMethod("getCustom", new Class[] { Collection.Key.class });
+ }
+ catch (NoSuchMethodException e) {
+ return null;
+ }
}
- Struct sct = (Struct) GetApplicationSettings.invoke(pc, new Object[] { Boolean.TRUE });
- Object o = sct.get("image", null);
+ o = getCustomMethod.invoke(ac, new Object[] { eng.getCastUtil().toKey("image") });
+
if (o instanceof Struct) {
Struct image = (Struct) o;
- // type
o = image.get("coder", null);
- if (o == null) image.get("coders", null);
+ if (o == null) o = image.get("coders", null);
if (o != null && eng.getDecisionUtil().isCastableToArray(o)) {
String[] coders = eng.getListUtil().toStringArray(eng.getCastUtil().toArray(o));
@@ -295,7 +310,6 @@ public static Set getCoders(StringBuilder sb, PageContext pc) {
}
}
}
-
}
catch (Exception e) {
Coder.log(pc);
diff --git a/tests/ImageReadMisnamed.cfc b/tests/ImageReadMisnamed.cfc
new file mode 100644
index 0000000..87392ca
--- /dev/null
+++ b/tests/ImageReadMisnamed.cfc
@@ -0,0 +1,84 @@
+component extends="org.lucee.cfml.test.LuceeTestCase" labels="image" {
+
+ variables.imgDir = getDirectoryFromPath( getCurrentTemplatePath() ) & "images/";
+ variables.testDir = getTempDirectory( "misnamed" );
+
+ function beforeAll(){
+ // cleanup from previous runs
+ if ( directoryExists( testDir ) ) directoryDelete( testDir, true );
+ directoryCreate( testDir );
+
+ var jpgSrc = imgDir & "image.jpg";
+ var pngSrc = imgDir & "lucee-logo.png";
+ var webpSrc = imgDir & "small-sample.webp";
+
+ // JPEG saved with .png extension
+ fileCopy( jpgSrc, testDir & "actually-jpeg.png" );
+ // PNG saved with .jpg extension
+ fileCopy( pngSrc, testDir & "actually-png.jpg" );
+ // JPEG saved with no extension
+ fileCopy( jpgSrc, testDir & "no-extension" );
+ // PNG saved with .bmp extension
+ fileCopy( pngSrc, testDir & "actually-png.bmp" );
+ // WebP saved with .jpg extension
+ fileCopy( webpSrc, testDir & "actually-webp.jpg" );
+ // WebP saved with .png extension
+ fileCopy( webpSrc, testDir & "actually-webp.png" );
+ }
+
+ function run( testResults, testBox ){
+ describe( "imageRead with misnamed files", function(){
+
+ it( title="reads a JPEG file saved with .png extension", body=function(){
+ var img = imageRead( testDir & "actually-jpeg.png" );
+ expect( isImage( img ) ).toBeTrue();
+ expect( imageGetWidth( img ) ).toBeGT( 0 );
+ });
+
+ it( title="reads a PNG file saved with .jpg extension", body=function(){
+ var img = imageRead( testDir & "actually-png.jpg" );
+ expect( isImage( img ) ).toBeTrue();
+ expect( imageGetWidth( img ) ).toBeGT( 0 );
+ });
+
+ it( title="reads a JPEG file with no extension", body=function(){
+ var img = imageRead( testDir & "no-extension" );
+ expect( isImage( img ) ).toBeTrue();
+ expect( imageGetWidth( img ) ).toBeGT( 0 );
+ });
+
+ it( title="reads a PNG file saved with .bmp extension", body=function(){
+ var img = imageRead( testDir & "actually-png.bmp" );
+ expect( isImage( img ) ).toBeTrue();
+ expect( imageGetWidth( img ) ).toBeGT( 0 );
+ });
+
+ it( title="imageInfo works on misnamed JPEG (.png ext)", body=function(){
+ var img = imageRead( testDir & "actually-jpeg.png" );
+ var info = imageInfo( img );
+ expect( info.width ).toBeGT( 0 );
+ expect( info.height ).toBeGT( 0 );
+ });
+
+ it( title="imageResize works on misnamed PNG (.jpg ext)", body=function(){
+ var img = imageRead( testDir & "actually-png.jpg" );
+ imageResize( img, 50, 50 );
+ expect( imageGetWidth( img ) ).toBe( 50 );
+ });
+
+ it( title="reads a WebP file saved with .jpg extension", body=function(){
+ var img = imageRead( testDir & "actually-webp.jpg" );
+ expect( isImage( img ) ).toBeTrue();
+ expect( imageGetWidth( img ) ).toBeGT( 0 );
+ });
+
+ it( title="reads a WebP file saved with .png extension", body=function(){
+ var img = imageRead( testDir & "actually-webp.png" );
+ expect( isImage( img ) ).toBeTrue();
+ expect( imageGetWidth( img ) ).toBeGT( 0 );
+ });
+
+ });
+ }
+
+}
diff --git a/tests/images/small-sample.webp b/tests/images/small-sample.webp
new file mode 100644
index 0000000..a750b74
Binary files /dev/null and b/tests/images/small-sample.webp differ