001/*
002 * This library is part of OpenCms -
003 * the Open Source Content Management System
004 *
005 * Copyright (c) Alkacon Software GmbH & Co. KG (https://www.alkacon.com)
006 *
007 * This library is free software; you can redistribute it and/or
008 * modify it under the terms of the GNU Lesser General Public
009 * License as published by the Free Software Foundation; either
010 * version 2.1 of the License, or (at your option) any later version.
011 *
012 * This library is distributed in the hope that it will be useful,
013 * but WITHOUT ANY WARRANTY; without even the implied warranty of
014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
015 * Lesser General Public License for more details.
016 *
017 * For further information about Alkacon Software, please see the
018 * company website: https://www.alkacon.com
019 *
020 * For further information about OpenCms, please see the
021 * project website: https://www.opencms.org
022 *
023 * You should have received a copy of the GNU Lesser General Public
024 * License along with this library; if not, write to the Free Software
025 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
026 */
027
028package org.opencms.loader;
029
030import org.opencms.ade.galleries.CmsPreviewService;
031import org.opencms.cache.CmsVfsNameBasedDiskCache;
032import org.opencms.configuration.CmsConfigurationException;
033import org.opencms.configuration.CmsImageCacheConfiguration;
034import org.opencms.configuration.CmsParameterConfiguration;
035import org.opencms.file.CmsFile;
036import org.opencms.file.CmsObject;
037import org.opencms.file.CmsResource;
038import org.opencms.loader.imagecache.CmsImageCacheAccessRenewal;
039import org.opencms.loader.imagecache.CmsImageCacheMaintenanceCleaner;
040import org.opencms.loader.imagecache.CmsImageCacheMaintenanceService;
041import org.opencms.main.CmsEvent;
042import org.opencms.main.CmsException;
043import org.opencms.main.CmsLog;
044import org.opencms.main.I_CmsEventListener;
045import org.opencms.main.OpenCms;
046import org.opencms.scheduler.jobs.CmsImageCacheCleanupJob;
047import org.opencms.security.CmsPermissionSet;
048import org.opencms.util.CmsFileUtil;
049import org.opencms.util.CmsRequestUtil;
050import org.opencms.util.CmsStringUtil;
051
052import java.io.ByteArrayOutputStream;
053import java.io.File;
054import java.io.FileInputStream;
055import java.io.FileOutputStream;
056import java.io.IOException;
057import java.io.OutputStream;
058import java.time.Instant;
059import java.util.Map;
060
061import org.apache.commons.codec.digest.DigestUtils;
062import org.apache.commons.logging.Log;
063
064import jakarta.servlet.http.HttpServletRequest;
065import jakarta.servlet.http.HttpServletResponse;
066
067/**
068 * Loader for images from the OpenCms VSF with integrated image scaling and processing capabilities.<p>
069 *
070 * To scale or process an image, the parameter <code>{@link org.opencms.loader.CmsImageScaler#PARAM_SCALE}</code>
071 * has to be appended to the image URI. The value for the parameter needs to be composed from the <code>SCALE_PARAM</code>
072 * options provided by the constants in the <code>{@link org.opencms.file.types.CmsResourceTypeImage}</code> class.<p>
073 *
074 * For example, to scale an image to exact 800x600 pixel with center fitting and a background color of grey,
075 * the following parameter String can be used: <code>w:800,h:600,t:0,c:c0c0c0</code>.<p>
076 *
077 * @since 6.2.0
078 */
079public class CmsImageLoader extends CmsDumpLoader implements I_CmsEventListener, I_CmsStaticExportDirectResponseLoader {
080
081    /** Controls max number of threads that are allowed to scale images concurrently. */
082    public static final String CONFIGURATION_CONCURRENCY = "image.scaling.concurrency";
083
084    /** The configuration parameter for the OpenCms XML configuration to set the image down scale operation. */
085    public static final String CONFIGURATION_DOWNSCALE = "image.scaling.downscale";
086
087    /** The configuration parameter for the OpenCms XML configuration to set the image cache repository. */
088    public static final String CONFIGURATION_IMAGE_FOLDER = "image.folder";
089
090    /** The configuration parameter for the OpenCms XML configuration to set the maximum image blur size. */
091    public static final String CONFIGURATION_MAX_BLUR_SIZE = "image.scaling.maxblursize";
092
093    /** The configuration parameter for the OpenCms XML configuration to set the maximum image scale size. */
094    public static final String CONFIGURATION_MAX_SCALE_SIZE = "image.scaling.maxsize";
095
096    /** The configuration parameter for the OpenCms XML configuration to enable the image scaling. */
097    public static final String CONFIGURATION_SCALING_ENABLED = "image.scaling.enabled";
098
099    /** Weak ETag prefix. */
100    private static final String WEAK_ETAG_PREFIX = "W/";
101
102    /** Default name for the image cache repository. */
103    public static final String IMAGE_REPOSITORY_DEFAULT = "/WEB-INF/imagecache/";
104
105    /** Clear event parameter. */
106    public static final String PARAM_CLEAR_IMAGES_CACHE = "_IMAGES_CACHE_";
107
108    /** The id of this loader. */
109    public static final int RESOURCE_LOADER_ID_IMAGE_LOADER = 2;
110
111    /** The log object for this class. */
112    protected static final Log LOG = CmsLog.getLog(CmsImageLoader.class);
113
114    /** The (optional) image down scale parameters for image write operations. */
115    protected static String m_downScaleParams;
116
117    /** Indicates if image scaling is active. */
118    protected static boolean m_enabled;
119
120    /** The maximum image size (width * height) to apply image blurring when down scaling (setting this to high may cause "out of memory" errors). */
121    protected static int m_maxBlurSize = CmsImageScaler.SCALE_DEFAULT_MAX_BLUR_SIZE;
122
123    /** The disk cache to use for saving scaled image versions. */
124    protected static CmsVfsNameBasedDiskCache m_vfsDiskCache;
125
126    /** The image cache repository path, or <code>null</code> for image caches without file system access. */
127    protected static String m_imageRepositoryPath;
128
129    /** The optional external image cache. */
130    protected static I_CmsImageCache m_imageCache;
131
132    /** Asynchronous access-triggered renewal for the configured external image cache. */
133    protected static CmsImageCacheAccessRenewal m_imageCacheAccessRenewal;
134
135    /** The name of the configured image cache repository. */
136    protected String m_imageRepositoryFolder;
137
138    /** The maximum image size (width or height) to allow when up scaling an image using request parameters. */
139    protected int m_maxScaleSize = CmsImageScaler.SCALE_DEFAULT_MAX_SIZE;
140
141    /**
142     * Creates a new image loader.<p>
143     */
144    public CmsImageLoader() {
145
146        super();
147    }
148
149    /**
150     * Returns the image down scale parameters,
151     * which is set with the {@link #CONFIGURATION_DOWNSCALE} configuration option.<p>
152     *
153     * If no down scale parameters have been set in the configuration, this will return <code>null</code>.
154     *
155     * @return the image down scale parameters
156     */
157    public static String getDownScaleParams() {
158
159        return m_downScaleParams;
160    }
161
162    /**
163     * Returns the configured external image cache.<p>
164     *
165     * The classic RFS image cache is not represented by an {@link I_CmsImageCache} instance, so this method returns
166     * <code>null</code> for it.<p>
167     *
168     * @return the configured external image cache, or <code>null</code> for the classic RFS image cache
169     */
170    public static I_CmsImageCache getImageCache() {
171
172        return m_imageCache;
173    }
174
175    /**
176     * Returns the path of the image cache repository folder in the RFS.<p>
177     *
178     * For image cache backends without file system access, this returns <code>null</code>.<p>
179     *
180     * @return the path of the image cache repository folder in the RFS, or <code>null</code>
181     */
182    public static String getImageRepositoryPath() {
183
184        return m_imageRepositoryPath;
185    }
186
187    /**
188     * The maximum blur size for image re-scale operations,
189     * which is set with the {@link #CONFIGURATION_MAX_BLUR_SIZE} configuration option.<p>
190     *
191     * The default is 2500 * 2500 pixel.<p>
192     *
193     * @return the maximum blur size for image re-scale operations
194     */
195    public static int getMaxBlurSize() {
196
197        return m_maxBlurSize;
198    }
199
200    /**
201     * Returns <code>true</code> if the image scaling and processing capabilities for the
202     * OpenCms VFS images have been enabled, <code>false</code> if not.<p>
203     *
204     * Image scaling is enabled by setting the loader parameter <code>image.scaling.enabled</code>
205     * to the value <code>true</code> in the configuration file <code>opencms-vfs.xml</code>.<p>
206     *
207     * Enabling image processing in OpenCms may require several additional configuration steps
208     * on the server running OpenCms, especially in UNIX systems. Here it is often required to have an X window server
209     * configured and accessible so that the required Java ImageIO operations work.
210     * Therefore the image scaling capabilities in OpenCms are disabled by default.<p>
211     *
212     * @return <code>true</code> if the image scaling and processing capabilities for the
213     *      OpenCms VFS images have been enabled
214     */
215    public static boolean isEnabled() {
216
217        return m_enabled;
218    }
219
220    /**
221     * Validates the system configuration for an external image cache.<p>
222     *
223     * The cache is closed before a configuration exception is thrown.<p>
224     *
225     * @param imageCache the external image cache
226     * @param configuration the image cache system configuration
227     * @throws CmsConfigurationException if no explicit image cache configuration is present
228     */
229    static void validateExternalImageCacheConfiguration(
230        I_CmsImageCache imageCache,
231        CmsImageCacheConfiguration configuration)
232    throws CmsConfigurationException {
233
234        if (!configuration.isConfigured()) {
235            CmsConfigurationException configurationException = new CmsConfigurationException(
236                Messages.get().container(
237                    Messages.ERR_IMAGE_CACHE_CONFIG_POLICY_REQUIRED_1,
238                    imageCache.getClass().getName()));
239            try {
240                imageCache.close();
241            } catch (Exception e) {
242                configurationException.addSuppressed(e);
243            }
244            throw configurationException;
245        }
246    }
247
248    /**
249     * @see org.opencms.configuration.I_CmsConfigurationParameterHandler#addConfigurationParameter(java.lang.String, java.lang.String)
250     */
251    @Override
252    public void addConfigurationParameter(String paramName, String paramValue) {
253
254        if (CmsStringUtil.isNotEmpty(paramName) && CmsStringUtil.isNotEmpty(paramValue)) {
255            if (CONFIGURATION_SCALING_ENABLED.equals(paramName)) {
256                m_enabled = Boolean.valueOf(paramValue).booleanValue();
257            }
258            if (CONFIGURATION_IMAGE_FOLDER.equals(paramName)) {
259                m_imageRepositoryFolder = paramValue.trim();
260            }
261            if (CONFIGURATION_MAX_SCALE_SIZE.equals(paramName)) {
262                m_maxScaleSize = CmsStringUtil.getIntValue(
263                    paramValue,
264                    CmsImageScaler.SCALE_DEFAULT_MAX_SIZE,
265                    paramName);
266            }
267            if (CONFIGURATION_MAX_BLUR_SIZE.equals(paramName)) {
268                m_maxBlurSize = CmsStringUtil.getIntValue(
269                    paramValue,
270                    CmsImageScaler.SCALE_DEFAULT_MAX_BLUR_SIZE,
271                    paramName);
272            }
273            if (CONFIGURATION_DOWNSCALE.equals(paramName)) {
274                m_downScaleParams = paramValue.trim();
275            }
276
277            if (CONFIGURATION_CONCURRENCY.equals(paramName)) {
278                int concurrency = CmsStringUtil.getIntValue(paramValue, CmsImageScaler.DEFAULT_CONCURRENCY, paramName);
279                if (concurrency > 0) {
280                    CmsImageScaler.setConcurrency(concurrency);
281                }
282            }
283        }
284        super.addConfigurationParameter(paramName, paramValue);
285    }
286
287    /**
288     * @see org.opencms.main.I_CmsEventListener#cmsEvent(org.opencms.main.CmsEvent)
289     */
290    public void cmsEvent(CmsEvent event) {
291
292        if (event == null) {
293            return;
294        }
295        // only react on the clear caches event
296        int type = event.getType();
297        if (type != I_CmsEventListener.EVENT_CLEAR_CACHES) {
298            return;
299        }
300        // only react if the clear images cache parameter is set
301        Map<String, ?> data = event.getData();
302        if (data == null) {
303            return;
304        }
305        Object param = data.get(PARAM_CLEAR_IMAGES_CACHE);
306        if (param == null) {
307            return;
308        }
309        if (m_imageCache != null) {
310            try {
311                float age = param instanceof Number
312                ? ((Number)param).floatValue()
313                : Float.valueOf(String.valueOf(param)).floatValue();
314                Instant cutoff = Instant.ofEpochMilli(System.currentTimeMillis() - (long)(age * 60f * 60f * 1000f));
315                CmsImageCacheMaintenanceService service = CmsImageCacheMaintenanceService.create(m_imageCache);
316                int batchSize = 1000;
317                CmsImageCacheMaintenanceCleaner.Result result = CmsImageCacheMaintenanceCleaner.delete(
318                    service,
319                    cutoff,
320                    batchSize,
321                    null);
322                if (result.getFailed() > 0) {
323                    LOG.warn(
324                        "Unable to delete "
325                            + result.getFailed()
326                            + " configured image cache entries during time-based cleanup.");
327                }
328            } catch (Exception e) {
329                LOG.error("Unable to clean the configured image cache by timestamp.", e);
330            }
331            return;
332        }
333        float age = -1;
334        if (param instanceof String) {
335            age = Float.valueOf((String)param).floatValue();
336        } else if (param instanceof Number) {
337            age = ((Number)param).floatValue();
338        } else {
339            return;
340        }
341        CmsImageCacheCleanupJob.cleanImageCache(age);
342    }
343
344    /**
345     * @see org.opencms.loader.I_CmsResourceLoader#destroy()
346     */
347    @Override
348    public void destroy() {
349
350        m_enabled = false;
351        m_vfsDiskCache = null;
352        m_imageRepositoryPath = null;
353        if (m_imageCacheAccessRenewal != null) {
354            m_imageCacheAccessRenewal.close();
355            m_imageCacheAccessRenewal = null;
356        }
357        if (m_imageCache != null) {
358            try {
359                m_imageCache.close();
360            } catch (Exception e) {
361                LOG.warn("Unable to close the configured image cache.", e);
362            }
363        }
364        m_imageCache = null;
365        m_imageRepositoryFolder = null;
366    }
367
368    /**
369     * @see org.opencms.loader.I_CmsResourceLoader#export(org.opencms.file.CmsObject, org.opencms.file.CmsResource, jakarta.servlet.http.HttpServletRequest, jakarta.servlet.http.HttpServletResponse)
370     */
371    @Override
372    public byte[] export(CmsObject cms, CmsResource resource, HttpServletRequest req, HttpServletResponse res)
373    throws IOException, CmsException {
374
375        if (m_enabled && (req != null)) {
376            CmsImageScaler scaler = new CmsImageScaler(req, m_maxScaleSize, m_maxBlurSize);
377            if (scaler.isValid()) {
378                if (m_imageCache != null) {
379                    String key = ensureImageCacheEntry(cms, resource, scaler, isHeadImageCacheRequest(req));
380                    ByteArrayOutputStream out = new ByteArrayOutputStream();
381                    writeImageCacheEntryTo(cms, resource, scaler, key, out);
382                    CmsFile file = (resource instanceof CmsFile) ? (CmsFile)resource : new CmsFile(resource);
383                    file.setContents(out.toByteArray());
384                    if (res != null) {
385                        loadImageCacheEntry(cms, resource, scaler, key, req, res);
386                    }
387                    return file.getContents();
388                }
389                CmsFile file = getScaledImage(cms, resource, scaler);
390                if (res != null) {
391                    super.load(cms, file, req, res);
392                }
393                return file.getContents();
394            }
395        }
396        return super.export(cms, resource, req, res);
397    }
398
399    /**
400     * @see org.opencms.loader.I_CmsStaticExportStreamLoader#exportTo(org.opencms.file.CmsObject, org.opencms.file.CmsResource, jakarta.servlet.http.HttpServletRequest, jakarta.servlet.http.HttpServletResponse, java.io.OutputStream)
401     */
402    @Override
403    public void exportTo(
404        CmsObject cms,
405        CmsResource resource,
406        HttpServletRequest req,
407        HttpServletResponse res,
408        OutputStream exportOut)
409    throws IOException, CmsException {
410
411        if (m_enabled && (req != null)) {
412            CmsImageScaler scaler = new CmsImageScaler(req, m_maxScaleSize, m_maxBlurSize);
413            if (scaler.isValid()) {
414                if (m_imageCache != null) {
415                    String key = ensureImageCacheEntry(cms, resource, scaler, isHeadImageCacheRequest(req));
416                    writeImageCacheEntryTo(cms, resource, scaler, key, exportOut);
417                    if (res != null) {
418                        loadImageCacheEntry(cms, resource, scaler, key, req, res);
419                    }
420                    return;
421                }
422                File imageCacheFile = getScaledImageFile(cms, resource, scaler);
423                try (FileInputStream in = new FileInputStream(imageCacheFile)) {
424                    CmsFileUtil.copy(in, exportOut);
425                }
426                if (res != null) {
427                    loadCacheFile(resource, imageCacheFile, req, res);
428                }
429                return;
430            }
431        }
432        super.exportTo(cms, resource, req, res, exportOut);
433    }
434
435    /**
436     * @see org.opencms.configuration.I_CmsConfigurationParameterHandler#getConfiguration()
437     */
438    @Override
439    public CmsParameterConfiguration getConfiguration() {
440
441        CmsParameterConfiguration result = new CmsParameterConfiguration();
442        CmsParameterConfiguration config = super.getConfiguration();
443        if (config != null) {
444            result.putAll(config);
445        }
446        return result;
447    }
448
449    /**
450     * @see org.opencms.loader.I_CmsResourceLoader#getLoaderId()
451     */
452    @Override
453    public int getLoaderId() {
454
455        return RESOURCE_LOADER_ID_IMAGE_LOADER;
456    }
457
458    /**
459     * Returns the RFS cache file for the image scaler parameters from the request.<p>
460     *
461     * @param cms the current users OpenCms context
462     * @param resource the base VFS resource for the image
463     * @param req the current request
464     *
465     * @return the scaled image cache file, or <code>null</code> if the request contains no valid scaling parameters
466     *
467     * @throws IOException in case of errors accessing the disk based cache
468     * @throws CmsException in case of errors accessing the OpenCms VFS
469     */
470    public File getScaledImageFile(CmsObject cms, CmsResource resource, HttpServletRequest req)
471    throws IOException, CmsException {
472
473        CmsImageScaler scaler = new CmsImageScaler(req, m_maxScaleSize, m_maxBlurSize);
474        if (scaler.isValid()) {
475            return getScaledImageFile(cms, resource, scaler);
476        }
477        return null;
478    }
479
480    /**
481     * @see org.opencms.configuration.I_CmsConfigurationParameterHandler#initConfiguration()
482     */
483    @Override
484    public void initConfiguration() throws CmsConfigurationException {
485
486        if (CmsStringUtil.isEmpty(m_imageRepositoryFolder)) {
487            m_imageRepositoryFolder = IMAGE_REPOSITORY_DEFAULT;
488        }
489        m_imageCache = null;
490        m_imageRepositoryPath = null;
491        m_vfsDiskCache = new CmsVfsNameBasedDiskCache("", m_imageRepositoryFolder);
492        if (CmsLog.INIT.isInfoEnabled()) {
493            CmsLog.INIT.info(
494                Messages.get().getBundle().key(Messages.INIT_IMAGE_SCALING_ENABLED_1, Boolean.valueOf(m_enabled)));
495        }
496    }
497
498    /**
499     * Initializes the image cache after the complete OpenCms runtime configuration is available.<p>
500     *
501     * @param propertyConfiguration the OpenCms properties
502     * @throws CmsConfigurationException if the image cache can not be initialized
503     */
504    public void initializeImageCache(CmsParameterConfiguration propertyConfiguration) throws CmsConfigurationException {
505
506        initImageCache(propertyConfiguration);
507        OpenCms.addCmsEventListener(this);
508        if (CmsLog.INIT.isInfoEnabled() && (m_imageRepositoryPath != null)) {
509            CmsLog.INIT.info(
510                Messages.get().getBundle().key(Messages.INIT_IMAGE_REPOSITORY_PATH_1, m_imageRepositoryPath));
511        }
512    }
513
514    /**
515     * @see org.opencms.loader.I_CmsStoredContentDirectDeliveryLoader#isStoredContentDirectDeliveryEnabled(org.opencms.file.CmsObject, org.opencms.file.CmsResource, jakarta.servlet.http.HttpServletRequest, jakarta.servlet.http.HttpServletResponse)
516     */
517    @Override
518    public boolean isStoredContentDirectDeliveryEnabled(
519        CmsObject cms,
520        CmsResource resource,
521        HttpServletRequest req,
522        HttpServletResponse res)
523    throws CmsException {
524
525        if ((req == null) || !m_enabled) {
526            return true;
527        }
528        CmsImageScaler scaler = new CmsImageScaler(req, m_maxScaleSize, m_maxBlurSize);
529        return !scaler.isValid();
530    }
531
532    /**
533     * @see org.opencms.loader.I_CmsResourceLoader#load(org.opencms.file.CmsObject, org.opencms.file.CmsResource, jakarta.servlet.http.HttpServletRequest, jakarta.servlet.http.HttpServletResponse)
534     */
535    @Override
536    public void load(CmsObject cms, CmsResource resource, HttpServletRequest req, HttpServletResponse res)
537    throws IOException, CmsException {
538
539        if (m_enabled) {
540            // get the scale information from the request
541            CmsImageScaler scaler = new CmsImageScaler(req, m_maxScaleSize, m_maxBlurSize);
542            if (scaler.isValid()) {
543                if (m_imageCache != null) {
544                    String key = getImageCacheKey(resource, scaler);
545                    String etag = getImageCacheETag(key);
546                    if (trySendImageCacheNotModified(resource, req, res, etag)) {
547                        recordImageCacheAccess(key);
548                        return;
549                    }
550                    key = ensureImageCacheEntry(cms, resource, scaler, isHeadImageCacheRequest(req));
551                    loadImageCacheEntry(cms, resource, scaler, key, req, res, etag);
552                    return;
553                }
554                if (canSendLastModifiedHeader(resource, req, res)) {
555                    // no image processing required at all
556                    return;
557                }
558                File file = getScaledImageFile(cms, resource, scaler);
559                loadCacheFile(resource, file, req, res);
560            } else {
561                super.load(cms, resource, req, res);
562            }
563        } else {
564            // scaling is disabled
565            super.load(cms, resource, req, res);
566        }
567    }
568
569    /**
570     * Loads an image cache file to the response.<p>
571     *
572     * @param resource the original resource
573     * @param file the image cache file
574     * @param req the current request
575     * @param res the current response
576     *
577     * @throws IOException in case writing fails
578     */
579    public void loadCacheFile(CmsResource resource, File file, HttpServletRequest req, HttpServletResponse res)
580    throws IOException {
581
582        res.setStatus(HttpServletResponse.SC_OK);
583        res.setContentLength((int)file.length());
584        setDateHeaders(resource, req, res);
585        try (FileInputStream in = new FileInputStream(file)) {
586            CmsFileUtil.copy(in, res.getOutputStream());
587        }
588    }
589
590    /**
591     * @see org.opencms.loader.I_CmsStaticExportDirectResponseLoader#tryExportDirectResponse(org.opencms.file.CmsObject, org.opencms.file.CmsResource, jakarta.servlet.http.HttpServletRequest, jakarta.servlet.http.HttpServletResponse)
592     */
593    public boolean tryExportDirectResponse(
594        CmsObject cms,
595        CmsResource resource,
596        HttpServletRequest req,
597        HttpServletResponse res)
598    throws IOException, CmsException {
599
600        if ((m_imageCache == null)
601            || !m_enabled
602            || (req == null)
603            || !("GET".equalsIgnoreCase(req.getMethod()) || "HEAD".equalsIgnoreCase(req.getMethod()))) {
604            return false;
605        }
606        if (!cms.hasPermissions(resource, CmsPermissionSet.ACCESS_READ)) {
607            return false;
608        }
609        CmsImageScaler scaler = new CmsImageScaler(req, m_maxScaleSize, m_maxBlurSize);
610        if (!scaler.isValid()) {
611            return false;
612        }
613        String key = getImageCacheKey(resource, scaler);
614        String etag = null;
615        if (res != null) {
616            etag = getImageCacheETag(key);
617            if (trySendImageCacheNotModified(resource, req, res, etag)) {
618                recordImageCacheAccess(key);
619                return true;
620            }
621        }
622        key = ensureImageCacheEntry(cms, resource, scaler, isHeadImageCacheRequest(req));
623        if (res != null) {
624            loadImageCacheEntry(cms, resource, scaler, key, req, res, etag);
625        }
626        return true;
627    }
628
629    /**
630     * Creates an image cache entry without checking whether it already exists.<p>
631     *
632     * @param cms the CMS context
633     * @param resource the image resource
634     * @param scaler the image scaler
635     * @param key the image cache key
636     * @throws IOException in case scaling fails
637     * @throws CmsException in case VFS or cache access fails
638     */
639    protected void createImageCacheEntry(CmsObject cms, CmsResource resource, CmsImageScaler scaler, String key)
640    throws IOException, CmsException {
641
642        try {
643            if (scaler.getType() == 8) {
644                // only need the focal point for mode 8
645                scaler.setFocalPoint(CmsPreviewService.readFocalPoint(cms, resource));
646            }
647            CmsFile file = cms.readFile(resource);
648            ByteArrayOutputStream out = new ByteArrayOutputStream();
649            scaler.scaleImageTo(file.getContents(), null, resource.getRootPath(), out);
650            m_imageCache.write(key, out.toByteArray());
651        } catch (IOException | CmsException e) {
652            throw e;
653        } catch (Exception e) {
654            throw new CmsException(
655                Messages.get().container(Messages.ERR_STORED_CONTENT_DELIVERY_2, "image-cache", key),
656                e);
657        }
658    }
659
660    /**
661     * Ensures that an image cache entry exists in the configured image cache.<p>
662     *
663     * @param cms the CMS context
664     * @param resource the image resource
665     * @param scaler the image scaler
666     *
667     * @return the image cache key
668     *
669     * @throws IOException in case scaling fails
670     * @throws CmsException in case VFS access fails
671     */
672    protected String ensureImageCacheEntry(CmsObject cms, CmsResource resource, CmsImageScaler scaler)
673    throws IOException, CmsException {
674
675        return ensureImageCacheEntry(cms, resource, scaler, false);
676    }
677
678    /**
679     * Returns the image cache key for an image/scaler pair.<p>
680     *
681     * @param resource the image resource
682     * @param scaler the image scaler
683     *
684     * @return the image cache key
685     */
686    protected String getImageCacheKey(CmsResource resource, CmsImageScaler scaler) {
687
688        String cacheName = m_vfsDiskCache.getCacheName(resource, scaler.toString());
689        String repositoryPath = m_vfsDiskCache.getRepositoryPath();
690        if (cacheName.startsWith(repositoryPath)) {
691            return cacheName.substring(repositoryPath.length());
692        }
693        return cacheName;
694    }
695
696    /**
697     * Returns a scaled version of the given OpenCms VFS image resource.<p>
698     *
699     * All results are cached in disk.
700     * If the scaled version does not exist in the cache, it is created.
701     * Unscaled versions of the images are also stored in the cache.<p>
702     *
703     * @param cms the current users OpenCms context
704     * @param resource the base VFS resource for the image
705     * @param scaler the configured image scaler
706     *
707     * @return a scaled version of the given OpenCms VFS image resource
708     *
709     * @throws IOException in case of errors accessing the disk based cache
710     * @throws CmsException in case of errors accessing the OpenCms VFS
711     */
712    protected CmsFile getScaledImage(CmsObject cms, CmsResource resource, CmsImageScaler scaler)
713    throws IOException, CmsException {
714
715        File cacheFile = getScaledImageFile(cms, resource, scaler);
716        CmsFile file;
717        if (resource instanceof CmsFile) {
718            // the original file content must be modified (required e.g. for static export)
719            file = (CmsFile)resource;
720        } else {
721            // this is no file, but we don't want to use "upgrade" since we don't need to read the content from the VFS
722            file = new CmsFile(resource);
723        }
724        file.setContents(org.opencms.util.CmsFileUtil.readFile(cacheFile));
725        return file;
726    }
727
728    /**
729     * Returns the RFS cache file for a scaled version of the given OpenCms VFS image resource.<p>
730     *
731     * @param cms the current users OpenCms context
732     * @param resource the base VFS resource for the image
733     * @param scaler the configured image scaler
734     *
735     * @return the scaled image cache file
736     *
737     * @throws IOException in case of errors accessing the disk based cache
738     * @throws CmsException in case of errors accessing the OpenCms VFS
739     */
740    protected File getScaledImageFile(CmsObject cms, CmsResource resource, CmsImageScaler scaler)
741    throws IOException, CmsException {
742
743        if ((m_imageCache instanceof CmsFsImageCache) && scaler.isValid()) {
744            String key = ensureImageCacheEntry(cms, resource, scaler);
745            return ((CmsFsImageCache)m_imageCache).getPath(key).toFile();
746        }
747        String cacheParam = scaler.isValid() ? scaler.toString() : null;
748        String cacheName = m_vfsDiskCache.getCacheName(resource, cacheParam);
749        File cacheFile = m_vfsDiskCache.getCacheFile(cacheName);
750        if (cacheFile == null) {
751            try (FileOutputStream out = new FileOutputStream(m_vfsDiskCache.createCacheFile(cacheName))) {
752                if (scaler.isValid()) {
753                    if (scaler.getType() == 8) {
754                        // only need the focal point for mode 8
755                        scaler.setFocalPoint(CmsPreviewService.readFocalPoint(cms, resource));
756                    }
757                    CmsFile file = cms.readFile(resource);
758                    scaler.scaleImageTo(file.getContents(), null, resource.getRootPath(), out);
759                } else {
760                    cms.readFileContentTo(resource, out);
761                }
762            }
763            cacheFile = m_vfsDiskCache.getCacheFile(cacheName);
764        }
765        return cacheFile;
766    }
767
768    /**
769     * Initializes the classic RFS image cache used when no external image cache backend is selected.<p>
770     */
771    protected void initDefaultImageCache() {
772
773        if (CmsStringUtil.isEmpty(m_imageRepositoryFolder)) {
774            m_imageRepositoryFolder = IMAGE_REPOSITORY_DEFAULT;
775        }
776        m_imageCache = null;
777        m_imageCacheAccessRenewal = null;
778        m_vfsDiskCache = new CmsVfsNameBasedDiskCache(
779            OpenCms.getSystemInfo().getWebApplicationRfsPath(),
780            m_imageRepositoryFolder);
781        m_imageRepositoryPath = m_vfsDiskCache.getRepositoryPath();
782    }
783
784    /**
785     * Initializes the optional image cache.<p>
786     */
787    protected void initImageCache(CmsParameterConfiguration propertyConfiguration) throws CmsConfigurationException {
788
789        m_imageRepositoryPath = null;
790        if (m_imageCacheAccessRenewal != null) {
791            m_imageCacheAccessRenewal.close();
792            m_imageCacheAccessRenewal = null;
793        }
794        try {
795            I_CmsImageCache imageCache = CmsImageCacheFactory.create(propertyConfiguration);
796            if (imageCache == null) {
797                initDefaultImageCache();
798                return;
799            }
800            validateExternalImageCacheConfiguration(imageCache, OpenCms.getImageCacheConfiguration());
801            if (imageCache instanceof CmsS3ImageCache) {
802                m_vfsDiskCache = new CmsVfsNameBasedDiskCache("", "");
803                m_imageRepositoryPath = null;
804            } else if (imageCache instanceof CmsFsImageCache) {
805                m_vfsDiskCache = new CmsVfsNameBasedDiskCache("", ((CmsFsImageCache)imageCache).getRepositoryPath());
806                m_imageRepositoryPath = m_vfsDiskCache.getRepositoryPath();
807            } else {
808                throw new CmsConfigurationException(
809                    Messages.get().container(Messages.ERR_IMAGE_CACHE_INIT_1, imageCache.getClass().getName()));
810            }
811            m_imageCache = imageCache;
812            m_imageCacheAccessRenewal = CmsImageCacheAccessRenewal.create(
813                imageCache,
814                OpenCms.getImageCacheConfiguration());
815        } catch (CmsConfigurationException e) {
816            m_imageCache = null;
817            m_imageRepositoryPath = null;
818            throw e;
819        } catch (Exception e) {
820            m_imageCache = null;
821            m_imageRepositoryPath = null;
822            throw new CmsConfigurationException(
823                Messages.get().container(Messages.ERR_IMAGE_CACHE_INIT_1, "opencms.properties"),
824                e);
825        }
826    }
827
828    /**
829     * Loads an image cache entry from the configured storage to the response.<p>
830     *
831     * @param resource the original resource
832     * @param key the image cache key
833     * @param req the request
834     * @param res the response
835     *
836     * @throws IOException in case writing fails
837     * @throws CmsException in case store access fails
838     */
839    protected void loadImageCacheEntry(
840        CmsResource resource,
841        String key,
842        HttpServletRequest req,
843        HttpServletResponse res)
844    throws IOException, CmsException {
845
846        String etag = getImageCacheETag(key);
847        if (trySendImageCacheNotModified(resource, req, res, etag)) {
848            recordImageCacheAccess(key);
849            return;
850        }
851        loadImageCacheEntry(resource, key, req, res, etag);
852    }
853
854    /**
855     * Ensures that an image cache entry exists, optionally bypassing locally cached positive metadata.<p>
856     *
857     * @param cms the CMS context
858     * @param resource the image resource
859     * @param scaler the image scaler
860     * @param authoritative whether to perform an authoritative existence check
861     * @return the image cache key
862     * @throws IOException in case scaling fails
863     * @throws CmsException in case VFS or cache access fails
864     */
865    private String ensureImageCacheEntry(
866        CmsObject cms,
867        CmsResource resource,
868        CmsImageScaler scaler,
869        boolean authoritative)
870    throws IOException, CmsException {
871
872        String key = getImageCacheKey(resource, scaler);
873        try {
874            boolean exists = authoritative ? m_imageCache.existsAuthoritatively(key) : m_imageCache.exists(key);
875            if (!exists) {
876                createImageCacheEntry(cms, resource, scaler, key);
877            }
878            return key;
879        } catch (IOException | CmsException e) {
880            throw e;
881        } catch (Exception e) {
882            throw new CmsException(
883                Messages.get().container(Messages.ERR_STORED_CONTENT_DELIVERY_2, "image-cache", key),
884                e);
885        }
886    }
887
888    /**
889     * Returns the ETag for an image cache entry.<p>
890     *
891     * @param key the image cache key
892     * @return the ETag
893     */
894    private String getImageCacheETag(String key) {
895
896        return "\"" + DigestUtils.sha256Hex(key) + "\"";
897    }
898
899    /**
900     * Returns if the given value is an entity tag.<p>
901     *
902     * @param value the value
903     * @return <code>true</code> if the value is an entity tag
904     */
905    private boolean isEntityTag(String value) {
906
907        String tag = value.trim();
908        return tag.startsWith("\"") || tag.startsWith(WEAK_ETAG_PREFIX + "\"");
909    }
910
911    /** Returns whether the request uses the HEAD method. */
912    private boolean isHeadImageCacheRequest(HttpServletRequest req) {
913
914        return "HEAD".equalsIgnoreCase(req.getMethod());
915    }
916
917    /**
918     * Returns if the image cache entry was not modified since the client request.<p>
919     *
920     * @param resource the resource
921     * @param req the request
922     * @param res the response
923     * @param etag the image cache entry ETag
924     * @return if the image cache entry was not modified
925     */
926    private boolean isImageCacheNotModified(
927        CmsResource resource,
928        HttpServletRequest req,
929        HttpServletResponse res,
930        String etag) {
931
932        boolean hasIfNoneMatch = req.getHeader(CmsRequestUtil.HEADER_IF_NONE_MATCH) != null;
933        boolean notModified = hasIfNoneMatch
934        ? matchesImageCacheIfNoneMatch(req, etag)
935        : org.opencms.flex.CmsFlexController.isNotModifiedSince(req, resource.getDateLastModified());
936        if (resource.getState().isUnchanged()
937            && !org.opencms.workplace.CmsWorkplaceManager.isWorkplaceUser(req)
938            && notModified) {
939            long now = System.currentTimeMillis();
940            if ((resource.getDateReleased() < now) && (resource.getDateExpired() > now)) {
941                setImageCacheHeaders(resource, req, res, etag);
942                res.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
943                return true;
944            }
945        }
946        return false;
947    }
948
949    /**
950     * Loads an image cache entry and regenerates it once if an external cache reports that it disappeared.<p>
951     *
952     * @param cms the CMS context
953     * @param resource the original resource
954     * @param scaler the image scaler
955     * @param key the image cache key
956     * @param req the request
957     * @param res the response
958     * @throws IOException in case writing fails
959     * @throws CmsException in case VFS or cache access fails
960     */
961    private void loadImageCacheEntry(
962        CmsObject cms,
963        CmsResource resource,
964        CmsImageScaler scaler,
965        String key,
966        HttpServletRequest req,
967        HttpServletResponse res)
968    throws IOException, CmsException {
969
970        String etag = getImageCacheETag(key);
971        if (trySendImageCacheNotModified(resource, req, res, etag)) {
972            recordImageCacheAccess(key);
973            return;
974        }
975        loadImageCacheEntry(cms, resource, scaler, key, req, res, etag);
976    }
977
978    /**
979     * Loads an image cache entry after conditional request handling and retries once after regeneration.<p>
980     *
981     * @param cms the CMS context
982     * @param resource the original resource
983     * @param scaler the image scaler
984     * @param key the image cache key
985     * @param req the request
986     * @param res the response
987     * @param etag the image cache entry ETag
988     * @throws IOException in case writing fails
989     * @throws CmsException in case VFS or cache access fails
990     */
991    private void loadImageCacheEntry(
992        CmsObject cms,
993        CmsResource resource,
994        CmsImageScaler scaler,
995        String key,
996        HttpServletRequest req,
997        HttpServletResponse res,
998        String etag)
999    throws IOException, CmsException {
1000
1001        try {
1002            loadImageCacheEntry(resource, key, req, res, etag);
1003        } catch (CmsImageCacheEntryNotFoundException e) {
1004            if (res.isCommitted()) {
1005                throw e;
1006            }
1007            res.resetBuffer();
1008            if (LOG.isDebugEnabled()) {
1009                LOG.debug("Regenerating missing image cache entry '" + key + "'.");
1010            }
1011            createImageCacheEntry(cms, resource, scaler, key);
1012            loadImageCacheEntry(resource, key, req, res, etag);
1013        }
1014    }
1015
1016    /**
1017     * Loads an image cache entry from the configured storage after conditional request handling.<p>
1018     *
1019     * @param resource the original resource
1020     * @param key the image cache key
1021     * @param req the request
1022     * @param res the response
1023     * @param etag the image cache entry ETag
1024     *
1025     * @throws IOException in case writing fails
1026     * @throws CmsException in case store access fails
1027     */
1028    private void loadImageCacheEntry(
1029        CmsResource resource,
1030        String key,
1031        HttpServletRequest req,
1032        HttpServletResponse res,
1033        String etag)
1034    throws IOException, CmsException {
1035
1036        try {
1037            long length = m_imageCache.getLength(key);
1038            String rangeHeader = req.getHeader(CmsRequestUtil.HEADER_RANGE);
1039            boolean rangeRequest = (rangeHeader != null)
1040                && "GET".equalsIgnoreCase(req.getMethod())
1041                && m_imageCache.supportsRangeDelivery()
1042                && matchesImageCacheIfRange(req, resource, etag);
1043            if (rangeRequest) {
1044                CmsByteRange range = CmsByteRange.parse(rangeHeader, length);
1045                if (range.isInvalid()) {
1046                    res.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
1047                    res.setHeader(CmsRequestUtil.HEADER_CONTENT_RANGE, CmsByteRange.RANGE_UNIT_BYTES + " */" + length);
1048                    res.setHeader(CmsRequestUtil.HEADER_ETAG, etag);
1049                    return;
1050                }
1051                if (!range.isIgnored()) {
1052                    res.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
1053                    setContentLength(res, range.m_length);
1054                    res.setHeader(
1055                        CmsRequestUtil.HEADER_CONTENT_RANGE,
1056                        CmsByteRange.RANGE_UNIT_BYTES
1057                            + " "
1058                            + range.m_start
1059                            + "-"
1060                            + (range.m_start + range.m_length - 1)
1061                            + "/"
1062                            + length);
1063                    setImageCacheHeaders(resource, req, res, etag);
1064                    if (!"HEAD".equalsIgnoreCase(req.getMethod())) {
1065                        m_imageCache.writeRangeTo(key, range.m_start, range.m_length, res.getOutputStream());
1066                        recordImageCacheAccess(key);
1067                    }
1068                    return;
1069                }
1070            }
1071            res.setStatus(HttpServletResponse.SC_OK);
1072            setContentLength(res, length);
1073            setImageCacheHeaders(resource, req, res, etag);
1074            if (!"HEAD".equalsIgnoreCase(req.getMethod())) {
1075                writeImageCacheEntryTo(key, res.getOutputStream());
1076            }
1077        } catch (IOException | CmsException e) {
1078            throw e;
1079        } catch (Exception e) {
1080            throw new CmsException(
1081                Messages.get().container(Messages.ERR_STORED_CONTENT_DELIVERY_2, "image-cache", key),
1082                e);
1083        }
1084    }
1085
1086    /**
1087     * Returns if the If-None-Match header matches the image cache entry ETag.<p>
1088     *
1089     * @param req the request
1090     * @param etag the image cache entry ETag
1091     * @return if the entity tag matches
1092     */
1093    private boolean matchesImageCacheIfNoneMatch(HttpServletRequest req, String etag) {
1094
1095        String ifNoneMatch = req.getHeader(CmsRequestUtil.HEADER_IF_NONE_MATCH);
1096        if (ifNoneMatch == null) {
1097            return false;
1098        }
1099        String imageCacheETag = stripWeakPrefix(etag);
1100        String[] tags = ifNoneMatch.split(",");
1101        for (int i = 0; i < tags.length; i++) {
1102            String tag = stripWeakPrefix(tags[i].trim());
1103            if ("*".equals(tag) || imageCacheETag.equals(tag)) {
1104                return true;
1105            }
1106        }
1107        return false;
1108    }
1109
1110    /**
1111     * Checks the If-Range header for image cache delivery.<p>
1112     *
1113     * @param req the request
1114     * @param resource the resource
1115     * @param etag the image cache entry ETag
1116     * @return if a range request should be processed
1117     */
1118    private boolean matchesImageCacheIfRange(HttpServletRequest req, CmsResource resource, String etag) {
1119
1120        String ifRange = req.getHeader(CmsRequestUtil.HEADER_IF_RANGE);
1121        if (ifRange == null) {
1122            return true;
1123        }
1124        if (isEntityTag(ifRange)) {
1125            return etag.equals(ifRange.trim());
1126        }
1127        try {
1128            long ifRangeDate = req.getDateHeader(CmsRequestUtil.HEADER_IF_RANGE);
1129            return (ifRangeDate >= 0) && ((resource.getDateLastModified() / 1000) * 1000 == ifRangeDate);
1130        } catch (IllegalArgumentException e) {
1131            return false;
1132        }
1133    }
1134
1135    /** Records a successful external image cache access without delaying the response. */
1136    private void recordImageCacheAccess(String key) {
1137
1138        CmsImageCacheAccessRenewal accessRenewal = m_imageCacheAccessRenewal;
1139        if (accessRenewal != null) {
1140            accessRenewal.recordAccess(key);
1141        }
1142    }
1143
1144    /**
1145     * Sets the content length header.<p>
1146     *
1147     * @param res the response
1148     * @param length the content length
1149     */
1150    private void setContentLength(HttpServletResponse res, long length) {
1151
1152        if (length <= Integer.MAX_VALUE) {
1153            res.setContentLength((int)length);
1154        } else {
1155            res.setHeader(CmsRequestUtil.HEADER_CONTENT_LENGTH, String.valueOf(length));
1156        }
1157    }
1158
1159    /**
1160     * Sets date and cache headers for a loaded image.<p>
1161     *
1162     * @param resource the original resource
1163     * @param req the current request
1164     * @param res the current response
1165     */
1166    private void setDateHeaders(CmsResource resource, HttpServletRequest req, HttpServletResponse res) {
1167
1168        if (org.opencms.workplace.CmsWorkplaceManager.isWorkplaceUser(req)) {
1169            res.setDateHeader(org.opencms.util.CmsRequestUtil.HEADER_LAST_MODIFIED, System.currentTimeMillis());
1170            org.opencms.util.CmsRequestUtil.setNoCacheHeaders(res);
1171        } else {
1172            res.setDateHeader(org.opencms.util.CmsRequestUtil.HEADER_LAST_MODIFIED, resource.getDateLastModified());
1173            if (!res.containsHeader(org.opencms.util.CmsRequestUtil.HEADER_CACHE_CONTROL)) {
1174                long expireTime = resource.getDateExpired();
1175                if (expireTime == CmsResource.DATE_EXPIRED_DEFAULT) {
1176                    expireTime--;
1177                }
1178                org.opencms.flex.CmsFlexController.setDateExpiresHeader(res, expireTime, getClientCacheMaxAge());
1179            }
1180        }
1181    }
1182
1183    /**
1184     * Sets HTTP headers for an image cache entry.<p>
1185     *
1186     * @param resource the original resource
1187     * @param req the current request
1188     * @param res the current response
1189     * @param etag the image cache entry ETag
1190     */
1191    private void setImageCacheHeaders(
1192        CmsResource resource,
1193        HttpServletRequest req,
1194        HttpServletResponse res,
1195        String etag) {
1196
1197        res.setHeader(CmsRequestUtil.HEADER_ETAG, etag);
1198        setDateHeaders(resource, req, res);
1199    }
1200
1201    /**
1202     * Removes a weak entity tag prefix from the given value.<p>
1203     *
1204     * @param value the value
1205     * @return the value without weak prefix
1206     */
1207    private String stripWeakPrefix(String value) {
1208
1209        return value.startsWith(WEAK_ETAG_PREFIX) ? value.substring(WEAK_ETAG_PREFIX.length()) : value;
1210    }
1211
1212    /**
1213     * Tries to answer a conditional image cache request without accessing the configured image cache storage.<p>
1214     *
1215     * @param resource the original resource
1216     * @param req the request
1217     * @param res the response
1218     * @param etag the image cache entry ETag
1219     * @return if the response was answered with 304
1220     */
1221    private boolean trySendImageCacheNotModified(
1222        CmsResource resource,
1223        HttpServletRequest req,
1224        HttpServletResponse res,
1225        String etag) {
1226
1227        if (m_imageCache.supportsRangeDelivery()) {
1228            res.setHeader(CmsRequestUtil.HEADER_ACCEPT_RANGES, CmsByteRange.RANGE_UNIT_BYTES);
1229        }
1230        return isImageCacheNotModified(resource, req, res, etag);
1231    }
1232
1233    /**
1234     * Writes an image cache entry and regenerates it once if it disappeared from an external cache.<p>
1235     *
1236     * @param cms the CMS context
1237     * @param resource the original resource
1238     * @param scaler the image scaler
1239     * @param key the image cache key
1240     * @param out the output stream
1241     * @throws IOException in case writing fails
1242     * @throws CmsException in case VFS or cache access fails
1243     */
1244    private void writeImageCacheEntryTo(
1245        CmsObject cms,
1246        CmsResource resource,
1247        CmsImageScaler scaler,
1248        String key,
1249        OutputStream out)
1250    throws IOException, CmsException {
1251
1252        try {
1253            writeImageCacheEntryTo(key, out);
1254        } catch (CmsImageCacheEntryNotFoundException e) {
1255            if (LOG.isDebugEnabled()) {
1256                LOG.debug("Regenerating missing image cache entry '" + key + "'.");
1257            }
1258            createImageCacheEntry(cms, resource, scaler, key);
1259            writeImageCacheEntryTo(key, out);
1260        }
1261    }
1262
1263    /**
1264     * Writes an image cache entry to an output stream.<p>
1265     *
1266     * @param key the image cache key
1267     * @param out the output stream
1268     *
1269     * @throws IOException in case writing fails
1270     * @throws CmsException in case store access fails
1271     */
1272    private void writeImageCacheEntryTo(String key, OutputStream out) throws IOException, CmsException {
1273
1274        try {
1275            m_imageCache.writeTo(key, out);
1276            recordImageCacheAccess(key);
1277        } catch (IOException | CmsException e) {
1278            throw e;
1279        } catch (Exception e) {
1280            throw new CmsException(
1281                Messages.get().container(Messages.ERR_STORED_CONTENT_DELIVERY_2, "image-cache", key),
1282                e);
1283        }
1284    }
1285}