001/*
002 *  Copyright 2016 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.core.resources;
017
018import java.io.IOException;
019import java.io.InputStream;
020import java.io.OutputStream;
021import java.io.Serializable;
022import java.util.Collection;
023import java.util.Map;
024import java.util.Set;
025import java.util.regex.Matcher;
026import java.util.regex.Pattern;
027
028import org.apache.avalon.framework.parameters.Parameters;
029import org.apache.cocoon.ProcessingException;
030import org.apache.cocoon.ResourceNotFoundException;
031import org.apache.commons.io.IOUtils;
032import org.apache.commons.lang3.StringUtils;
033import org.apache.excalibur.source.Source;
034import org.apache.excalibur.source.SourceNotFoundException;
035import org.apache.excalibur.source.SourceResolver;
036
037import org.ametys.core.util.ImageHelper;
038
039import io.github.borewit.sanitize.SVGSanitizer;
040
041/**
042 * Resource handler for images
043 */
044public class ImageResourceHandler extends SimpleResourceHandler
045{
046    /** url for safe svg files */
047    protected static final Pattern SAFE_SVG_PATTERN = Pattern.compile("^plugin(|:[^:]+)://resources/.+");
048    
049    private static final Pattern _SIZE_PATTERN = Pattern.compile("^(.+)_(max|crop|)(\\d+)x(\\d+)(\\.[^./]+)?$");
050
051    private static final Collection<String> __ALLOWED_OUTPUT_FORMATS = Set.of("png", "gif", "jpg", "jpeg");
052    private static final Collection<String> __UNRESIZABLE_FORMATS = Set.of("svg");
053    
054    private int _height;
055    private int _width;
056    private int _maxHeight;
057    private int _maxWidth;
058    private int _cropHeight;
059    private int _cropWidth;
060    private String _location;
061    
062    private boolean _download;
063    
064    record SizedSource(Source source, int height, int width, int maxHeight, int maxWidth, int cropHeight, int cropWidth) { /* empty */ }
065    
066    /**
067     * Default constructor
068     */
069    public ImageResourceHandler()
070    {
071        super();
072    }
073    
074    /**
075     * If the {@link Source} is already resolved by the {@link ResourceHandlerProvider},
076     * it may provide it through the constructor to avoid resolving it again.
077     * @param sizedSource the source.
078     */
079    public ImageResourceHandler(SizedSource sizedSource)
080    {
081        super(sizedSource.source);
082        _toFields(sizedSource);
083    }
084    
085    private void _toFields(SizedSource sizedSource)
086    {
087        _source = sizedSource.source;
088        _height = sizedSource.height;
089        _width = sizedSource.width;
090        _maxHeight = sizedSource.maxHeight;
091        _maxWidth = sizedSource.maxWidth;
092        _cropHeight = sizedSource.cropHeight;
093        _cropWidth = sizedSource.cropWidth;
094    }
095
096    @Override
097    public Source setup(String location, Map objectModel, Parameters par, boolean readForDownload) throws IOException, ProcessingException
098    {
099        if (_source == null)
100        {
101            // If the source has not been resolved by the provider, do it now
102            SizedSource sizedSource = _resolveSource(location, _resolver);
103            if (sizedSource != null)
104            {
105                _toFields(sizedSource);
106            }
107        }
108        
109        if (_source != null)
110        {
111            _location = location;
112            _download = readForDownload;
113            
114            return _source;
115        }
116        else
117        {
118            throw new ResourceNotFoundException("Resource not found for URI : " + location);
119        }
120    }
121    
122    /**
123     * Is the SVG file denoted by the uri potentially unsafe?
124     * @param sourceURI The svg file uri
125     * @return true if the svg is not from a trusted source
126     */
127    protected boolean isUnsafeSVG(String sourceURI)
128    {
129        return !SAFE_SVG_PATTERN.matcher(sourceURI).matches();
130    }
131
132    @Override
133    public void generate(OutputStream out) throws IOException, ProcessingException
134    {
135        String sourceURI = _source.getURI();
136        String fileExtension = StringUtils.substringAfterLast(sourceURI, ".").toLowerCase();
137        
138        try (InputStream is = _source.getInputStream())
139        {
140            if (_processImage(fileExtension))
141            {
142                String outputFormat = __ALLOWED_OUTPUT_FORMATS.contains(fileExtension) ? fileExtension : "png";
143                ImageHelper.generateThumbnail(is, out, outputFormat, _height, _width, _maxHeight, _maxWidth, _cropHeight, _cropWidth);
144            }
145            else if (StringUtils.equalsIgnoreCase(fileExtension, "svg") && isUnsafeSVG(_location))
146            {
147                try
148                {
149                    SVGSanitizer.sanitize(is, out);
150                }
151                catch (Exception e)
152                {
153                    throw new ProcessingException("Cannot sanitize the SVG " + sourceURI, e);
154                }
155            }
156            else
157            {
158                // Copy data in response
159                IOUtils.copy(is, out);
160            }
161        }
162    }
163    
164    private boolean _processImage(String fileExtension)
165    {
166        if (__UNRESIZABLE_FORMATS.contains(fileExtension))
167        {
168            return false;
169        }
170        else if (_width > 0 || _height > 0 || _maxHeight > 0 || _maxWidth > 0 || _cropHeight > 0 || _cropWidth > 0)
171        {
172            // resize or crop is required, assume this is an image
173            return true;
174        }
175        else if (!_download)
176        {
177            String mimeType = _source.getMimeType();
178                    
179            // only process image if it is for rendering purposes
180            return mimeType != null && mimeType.startsWith("image/");
181        }
182        else
183        {
184            return false;
185        }
186    }
187
188    @Override
189    public Serializable getKey()
190    {
191        return _source.getURI() + "###" + _width + "x" + _height + "x" + _maxWidth + "x" + _maxHeight + "x" + _cropWidth + "x" + _cropHeight;
192    }
193    
194    /**
195     * Resolve the source at the given location
196     * @param location the location of the source to resolve
197     * @param resolver the source resolver
198     * @return the resolved source or null
199     */
200    static SizedSource _resolveSource(String location, SourceResolver resolver)
201    {
202        try
203        {
204            Source source = null;
205            try
206            {
207                source = resolver.resolveURI(location);
208                if (source != null && source.exists())
209                {
210                    return new SizedSource(source, 0, 0, 0, 0, 0, 0);
211                }
212                else
213                {
214                    throw new SourceNotFoundException(location);
215                }
216            }
217            catch (SourceNotFoundException e)
218            {
219                resolver.release(source);
220            }
221            
222            
223            Matcher sizeMatcher = _SIZE_PATTERN.matcher(location);
224            if (sizeMatcher.matches())
225            {
226                String computedLocation = sizeMatcher.group(1);
227                String suffix = sizeMatcher.group(5);
228                if (suffix != null)
229                {
230                    computedLocation += suffix;
231                }
232                
233                try
234                {
235                    source = resolver.resolveURI(computedLocation);
236                    if (!source.exists())
237                    {
238                        throw new SourceNotFoundException(computedLocation);
239                    }
240                }
241                catch (SourceNotFoundException e)
242                {
243                    resolver.release(source);
244                    return null;
245                }
246                
247                // type is either empty (resize), max or crop.
248                String type = sizeMatcher.group(2);
249                
250                int pHeight = Integer.parseInt(sizeMatcher.group(3));
251                int pWidth = Integer.parseInt(sizeMatcher.group(4));
252
253                int height = "".equals(type) ? pHeight : 0;
254                int width = "".equals(type) ? pWidth : 0;
255                int maxHeight = "max".equals(type) ? pHeight : 0;
256                int maxWidth = "max".equals(type) ? pWidth : 0;
257                int cropHeight = "crop".equals(type) ? pHeight : 0;
258                int cropWidth = "crop".equals(type) ? pWidth : 0;
259                
260                return new SizedSource(source, height, width, maxHeight, maxWidth, cropHeight, cropWidth);
261            }
262            else
263            {
264                return null;
265            }
266        }
267        catch (IOException e)
268        {
269            return null;
270        }
271    }
272}