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