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.commons.lang3.Strings;
034import org.apache.excalibur.source.Source;
035import org.apache.excalibur.source.SourceNotFoundException;
036import org.apache.excalibur.source.SourceResolver;
037
038import org.ametys.core.util.ImageHelper;
039import org.ametys.core.util.POIHolder;
040import org.ametys.core.util.POIHolder.Point;
041
042import io.github.borewit.sanitize.SVGSanitizer;
043
044/**
045 * Resource handler for images
046 */
047public class ImageResourceHandler extends SimpleResourceHandler
048{
049    /** url for safe svg files */
050    protected static final Pattern SAFE_SVG_PATTERN = Pattern.compile("^plugin(|:[^:]+)://resources/.+");
051    
052    private static final Pattern _SIZE_PATTERN = Pattern.compile("^(.+)_(max|crop|)(\\d+)x(\\d+)(\\.[^./]+)?$");
053
054    private static final Collection<String> __ALLOWED_OUTPUT_FORMATS = Set.of("png", "gif", "jpg", "jpeg");
055    private static final Collection<String> __UNRESIZABLE_FORMATS = Set.of("svg");
056    
057    private int _height;
058    private int _width;
059    private int _maxHeight;
060    private int _maxWidth;
061    private int _cropHeight;
062    private int _cropWidth;
063    private Point _poi;
064    private String _location;
065    
066    private boolean _download;
067
068    
069    record SizedSource(Source source, int height, int width, int maxHeight, int maxWidth, int cropHeight, int cropWidth, Point poi) { /* empty */ }
070    
071    /**
072     * Default constructor
073     */
074    public ImageResourceHandler()
075    {
076        super();
077    }
078    
079    /**
080     * If the {@link Source} is already resolved by the {@link ResourceHandlerProvider},
081     * it may provide it through the constructor to avoid resolving it again.
082     * @param sizedSource the source.
083     */
084    public ImageResourceHandler(SizedSource sizedSource)
085    {
086        super(sizedSource.source);
087        _toFields(sizedSource);
088    }
089    
090    private void _toFields(SizedSource sizedSource)
091    {
092        _source = sizedSource.source;
093        _height = sizedSource.height;
094        _width = sizedSource.width;
095        _maxHeight = sizedSource.maxHeight;
096        _maxWidth = sizedSource.maxWidth;
097        _cropHeight = sizedSource.cropHeight;
098        _cropWidth = sizedSource.cropWidth;
099        _poi = sizedSource.poi;
100    }
101
102    @Override
103    public Source setup(String location, Map objectModel, Parameters par, boolean readForDownload) throws IOException, ProcessingException
104    {
105        if (_source == null)
106        {
107            // If the source has not been resolved by the provider, do it now
108            SizedSource sizedSource = _resolveSource(location, _resolver);
109            if (sizedSource != null)
110            {
111                _toFields(sizedSource);
112            }
113        }
114        
115        if (_source != null)
116        {
117            _location = location;
118            _download = readForDownload;
119            
120            return _source;
121        }
122        else
123        {
124            throw new ResourceNotFoundException("Resource not found for URI : " + location);
125        }
126    }
127    
128    /**
129     * Is the SVG file denoted by the uri potentially unsafe?
130     * @param sourceURI The svg file uri
131     * @return true if the svg is not from a trusted source
132     */
133    protected boolean isUnsafeSVG(String sourceURI)
134    {
135        return !SAFE_SVG_PATTERN.matcher(sourceURI).matches();
136    }
137
138    @Override
139    public void generate(OutputStream out) throws IOException, ProcessingException
140    {
141        String sourceURI = _source.getURI();
142        String fileExtension = StringUtils.substringAfterLast(sourceURI, ".").toLowerCase();
143        
144        try (InputStream is = _source.getInputStream())
145        {
146            if (_processImage(fileExtension))
147            {
148                String outputFormat = __ALLOWED_OUTPUT_FORMATS.contains(fileExtension) ? fileExtension : "png";
149                ImageHelper.generateThumbnail(is, out, outputFormat, _height, _width, _maxHeight, _maxWidth, _cropHeight, _cropWidth, _poi);
150            }
151            else if (Strings.CI.equals(fileExtension, "svg") && isUnsafeSVG(_location))
152            {
153                try
154                {
155                    SVGSanitizer.sanitize(is, out);
156                }
157                catch (Exception e)
158                {
159                    throw new ProcessingException("Cannot sanitize the SVG " + sourceURI, e);
160                }
161            }
162            else
163            {
164                // Copy data in response
165                IOUtils.copy(is, out);
166            }
167        }
168    }
169    
170    private boolean _processImage(String fileExtension)
171    {
172        if (__UNRESIZABLE_FORMATS.contains(fileExtension))
173        {
174            return false;
175        }
176        else if (_width > 0 || _height > 0 || _maxHeight > 0 || _maxWidth > 0 || _cropHeight > 0 || _cropWidth > 0)
177        {
178            // resize or crop is required, assume this is an image
179            return true;
180        }
181        else if (!_download)
182        {
183            String mimeType = _source.getMimeType();
184                    
185            // only process image if it is for rendering purposes
186            return mimeType != null && mimeType.startsWith("image/");
187        }
188        else
189        {
190            return false;
191        }
192    }
193
194    @Override
195    public Serializable getKey()
196    {
197        return _source.getURI() + "###" + _width + "x" + _height + "x" + _maxWidth + "x" + _maxHeight + "x" + _cropWidth + "x" + _cropHeight;
198    }
199    
200    private static SizedSource _getBasicSizedSource(String location, SourceResolver resolver) throws IOException
201    {
202        Source source = null;
203        try
204        {
205            source = resolver.resolveURI(location);
206            if (source != null && source.exists())
207            {
208                return new SizedSource(source, 0, 0, 0, 0, 0, 0, null);
209            }
210            else
211            {
212                throw new SourceNotFoundException(location);
213            }
214        }
215        catch (SourceNotFoundException e)
216        {
217            resolver.release(source);
218            return null;
219        }
220    }
221    
222    /**
223     * Resolve the source at the given location
224     * @param location the location of the source to resolve
225     * @param resolver the source resolver
226     * @return the resolved source or null
227     */
228    static SizedSource _resolveSource(String location, SourceResolver resolver)
229    {
230        try
231        {
232            SizedSource sizedSource = _getBasicSizedSource(location, resolver);
233            if (sizedSource != null)
234            {
235                return sizedSource;
236            }
237            
238            Source source = null;
239            
240            Matcher sizeMatcher = _SIZE_PATTERN.matcher(location);
241            if (sizeMatcher.matches())
242            {
243                String computedLocation = sizeMatcher.group(1);
244                String suffix = sizeMatcher.group(5);
245                if (suffix != null)
246                {
247                    computedLocation += suffix;
248                }
249                
250                try
251                {
252                    source = resolver.resolveURI(computedLocation);
253                    if (!source.exists())
254                    {
255                        throw new SourceNotFoundException(computedLocation);
256                    }
257                }
258                catch (SourceNotFoundException e)
259                {
260                    resolver.release(source);
261                    return null;
262                }
263                
264                // type is either empty (resize), max or crop.
265                String type = sizeMatcher.group(2);
266                
267                int pHeight = Integer.parseInt(sizeMatcher.group(3));
268                int pWidth = Integer.parseInt(sizeMatcher.group(4));
269
270                int height = "".equals(type) ? pHeight : 0;
271                int width = "".equals(type) ? pWidth : 0;
272                int maxHeight = "max".equals(type) ? pHeight : 0;
273                int maxWidth = "max".equals(type) ? pWidth : 0;
274                int cropHeight = "crop".equals(type) ? pHeight : 0;
275                int cropWidth = "crop".equals(type) ? pWidth : 0;
276                
277                Point poi = null;
278                if (source instanceof POIHolder)
279                {
280                    poi = ((POIHolder) source).getPOI();
281                }
282                
283                return new SizedSource(source, height, width, maxHeight, maxWidth, cropHeight, cropWidth, poi);
284            }
285            else
286            {
287                return null;
288            }
289        }
290        catch (IOException e)
291        {
292            return null;
293        }
294    }
295}