001/*
002 *  Copyright 2010 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.cms.transformation.htmledition;
017
018import java.awt.image.BufferedImage;
019import java.io.ByteArrayInputStream;
020import java.io.IOException;
021import java.io.InputStream;
022import java.net.HttpURLConnection;
023import java.net.MalformedURLException;
024import java.net.URI;
025import java.net.URISyntaxException;
026import java.net.URL;
027import java.time.ZoneOffset;
028import java.time.ZonedDateTime;
029import java.util.HashSet;
030import java.util.Map;
031import java.util.Set;
032import java.util.regex.Matcher;
033import java.util.regex.Pattern;
034
035import org.apache.avalon.framework.context.ContextException;
036import org.apache.avalon.framework.service.ServiceException;
037import org.apache.avalon.framework.service.ServiceManager;
038import org.apache.cocoon.Constants;
039import org.apache.cocoon.components.ContextHelper;
040import org.apache.cocoon.environment.Context;
041import org.apache.cocoon.environment.ObjectModelHelper;
042import org.apache.cocoon.environment.Request;
043import org.apache.cocoon.xml.AttributesImpl;
044import org.apache.commons.io.IOUtils;
045import org.apache.commons.io.output.ByteArrayOutputStream;
046import org.apache.excalibur.source.Source;
047import org.apache.excalibur.source.SourceResolver;
048import org.xml.sax.Attributes;
049import org.xml.sax.SAXException;
050
051import org.ametys.cms.data.NamedResource;
052import org.ametys.cms.data.RichText;
053import org.ametys.cms.repository.Content;
054import org.ametys.core.upload.Upload;
055import org.ametys.core.upload.UploadManager;
056import org.ametys.core.user.CurrentUserProvider;
057import org.ametys.core.util.ImageHelper;
058import org.ametys.core.util.StringUtils;
059import org.ametys.plugins.explorer.resources.Resource;
060import org.ametys.plugins.repository.AmetysObjectResolver;
061import org.ametys.plugins.repository.AmetysRepositoryException;
062import org.ametys.plugins.repository.UnknownAmetysObjectException;
063
064/**
065 * This transformer extracts uploaded files' ids from the incoming HTML for further processing.
066 */
067public class UploadedDataHTMLEditionHandler extends AbstractHTMLEditionHandler
068{
069    private static final Pattern __INLINE_IMAGE_MARKER = Pattern.compile("^data:image/(png|jpeg|gif);base64,.*");
070    
071    private UploadManager _uploadManager;
072    private CurrentUserProvider _userProvider;
073    private SourceResolver _resolver;
074    private AmetysObjectResolver _ametysResolver;
075    private Context _cocoonContext;
076    
077    private boolean _tagToIgnore;
078    private Set<String> _usedLocalFiles = new HashSet<>();
079    private RichText _richText;
080    private Map _objectModel;
081
082
083    @Override
084    public void contextualize(org.apache.avalon.framework.context.Context context) throws ContextException
085    {
086        super.contextualize(context);
087        _cocoonContext = (Context) _context.get(Constants.CONTEXT_ENVIRONMENT_CONTEXT);
088    }
089    
090    @Override
091    public void service(ServiceManager sManager) throws ServiceException
092    {
093        super.service(sManager);
094        _uploadManager = (UploadManager) sManager.lookup(UploadManager.ROLE);
095        _userProvider = (CurrentUserProvider) sManager.lookup(CurrentUserProvider.ROLE);
096        _resolver = (SourceResolver) sManager.lookup(SourceResolver.ROLE);
097        _ametysResolver = (AmetysObjectResolver) sManager.lookup(AmetysObjectResolver.ROLE);
098    }
099    
100    @Override
101    public void startDocument() throws SAXException
102    {
103        _tagToIgnore = false;
104        _objectModel = ContextHelper.getObjectModel(_context);
105        Map parentContextParameters = (Map) _objectModel.get(ObjectModelHelper.PARENT_CONTEXT);
106        _richText = (RichText) parentContextParameters.get("richText");
107        
108        super.startDocument();
109    }
110    
111    @Override
112    public void startElement(String uri, String loc, String raw, Attributes attrs) throws SAXException
113    {
114        if ("img".equals(raw))
115        {
116            String type = attrs.getValue("data-ametys-type");
117            
118            if ("temp".equals(type))
119            {
120                Attributes newAttrs = _getAttributesForTemp(attrs);
121                super.startElement(uri, loc, raw, newAttrs);
122                return;
123            }
124            else if ("explorer".equals(type))
125            {
126                Attributes newAttrs = _processResource(attrs);
127                super.startElement(uri, loc, raw, newAttrs);
128                return;
129            }
130            else if ("local".equals(type))
131            {
132                Attributes newAttrs = _processLocal(attrs);
133                super.startElement(uri, loc, raw, newAttrs);
134                return;
135            }
136            else if (type == null && !"marker".equals(attrs.getValue("marker")))
137            {
138                // image is copied from elsewhere, fetch it in the content
139                String src = attrs.getValue("src");
140                if (src == null)
141                {
142                    _tagToIgnore = true;
143                    getLogger().warn("Don't know how to fetch image with no src attribute. Image is ignored.");
144                    return;
145                }
146                
147                String ametys_src = attrs.getValue("data-ametys-src");
148
149                // The final filename
150                String fileName = null;
151                // The new attributes, will be filled with image width and height.
152                AttributesImpl newAttrs = new AttributesImpl();
153
154                Matcher m = __INLINE_IMAGE_MARKER.matcher(src);
155                if (m.matches())
156                {
157                    String mimetype = m.group(1);
158                    String imageAsBase64 = src.substring(19 + mimetype.length());
159                    byte[] imageAsBytes = org.apache.commons.codec.binary.Base64.decodeBase64(imageAsBase64);
160                    
161                    String generateKey = StringUtils.generateKey();
162                    fileName = _storeFile("paste-" + generateKey + "." + mimetype, new ByteArrayInputStream(imageAsBytes), null, null);
163                    
164                    try (InputStream is = new ByteArrayInputStream(imageAsBytes))
165                    {
166                        _addDimensionAttributes(is, newAttrs);
167                    }
168                    catch (IOException e)
169                    {
170                        // Ignore
171                    }
172                }
173                else
174                {
175                    
176                    String initialFileName = _getInitialFileName(src);
177                    
178                    if (src.startsWith("/"))
179                    {
180                        try
181                        {
182                            fileName = _handleInternalFile(src, newAttrs, initialFileName);
183                        }
184                        catch (Exception e)
185                        {
186                            // unable to fetch image, do not keep the img tag
187                            _tagToIgnore = true;
188                            getLogger().warn("Unable to fetch internal image from URL '" + src + "'. Image is ignored.", e);
189                            return;
190                        }
191                    }
192                    else if (src.startsWith("http://") || src.startsWith("https://"))
193                    {
194                        try
195                        {
196                            fileName = _handleRemoteFile(src, newAttrs, initialFileName);
197                        }
198                        catch (Exception e)
199                        {
200                            // unable to fetch image, do not keep the img tag
201                            _tagToIgnore = true;
202                            getLogger().warn("Unable to fetch external image from URL '" + src + "'. Image is ignored.", e);
203                            return;
204                        }
205                    }
206                    else
207                    {
208                        _tagToIgnore = true;
209                        getLogger().warn("Don't know how to fetch image at '" + src + "'. Image is ignored.");
210                        return;
211                    }
212                }
213                    
214                _copyAttributes(attrs, newAttrs);
215                
216                newAttrs.addAttribute("", "data-ametys-src", "data-ametys-src", "CDATA", ametys_src.replaceAll("\\.", "/") + ";" + fileName);
217                newAttrs.addAttribute("", "data-ametys-type", "data-ametys-type", "CDATA", "local");
218                
219                super.startElement(uri, loc, raw, newAttrs);
220                return;
221            }
222        }
223        
224        super.startElement(uri, loc, raw, attrs);
225    }
226    
227    private String _getInitialFileName(String src)
228    {
229        int j = src.lastIndexOf('/');
230        int k = src.indexOf('?', j);
231        String initialFileName;
232        
233        if (k == -1)
234        {
235            initialFileName = src.substring(j + 1);
236        }
237        else
238        {
239            initialFileName = src.substring(j + 1, k);
240        }
241        
242        // FIXME CMS-3090 A uploaded image can not contain '_max' or '_crop', replace it by '_Max', '_Crop'
243        return initialFileName.replaceAll("_max", "_Max").replaceAll("_crop", "_Crop");
244    }
245
246    private String _handleInternalFile(String src, AttributesImpl newAttrs, String initialFileName) throws MalformedURLException, IOException, URISyntaxException
247    {
248        // it may be an internal URL
249        Request request = ContextHelper.getRequest(_context);
250        String contextPath = request.getContextPath();
251        Source source = null;
252        
253        try
254        {
255            String modifiedSrc = src;
256            
257            if (src.startsWith(contextPath))
258            {
259                // it is an Ametys URL
260                // first decode it
261                modifiedSrc = new URI(modifiedSrc).getPath();
262                
263                modifiedSrc = "cocoon:/" + src.substring(contextPath.length());
264            }
265            else
266            {
267                StringBuilder sb = _getRequestURI(request);
268                
269                modifiedSrc = sb.toString() + modifiedSrc;
270            }
271            
272            source = _resolver.resolveURI(src);
273            
274            try (ByteArrayOutputStream bos = new ByteArrayOutputStream())
275            {
276                try (InputStream is = source.getInputStream())
277                {
278                    IOUtils.copy(is, bos);
279                }
280                
281                String fileName = _storeFile(initialFileName, new ByteArrayInputStream(bos.toByteArray()), null, null);
282                
283                try (InputStream is = new ByteArrayInputStream(bos.toByteArray()))
284                {
285                    _addDimensionAttributes(is, newAttrs);
286                }
287                
288                return fileName;
289            }
290        }
291        finally
292        {
293            if (source != null)
294            {
295                _resolver.release(source);
296            }
297        }
298
299    }
300    
301    private String _handleRemoteFile(String src, AttributesImpl newAttrs, String initialFileName) throws MalformedURLException, IOException
302    {
303        String fileName;
304        URL url = new URL(src);
305        HttpURLConnection connection = (HttpURLConnection) url.openConnection();
306        connection.setConnectTimeout(1000);
307        connection.setReadTimeout(2000);
308        
309        try (ByteArrayOutputStream bos = new ByteArrayOutputStream())
310        {
311            try (InputStream is = connection.getInputStream())
312            {
313                IOUtils.copy(is, bos);
314            }
315            
316            fileName = _storeFile(initialFileName, new ByteArrayInputStream(bos.toByteArray()), null, null);
317            
318            try (InputStream is = new ByteArrayInputStream(bos.toByteArray()))
319            {
320                _addDimensionAttributes(is, newAttrs);
321            }
322        }
323        return fileName;
324    }
325    
326    /**
327     * Copy the attributes.
328     * @param attrs the attributes to copy.
329     * @param newAttrs the attributes to copy to.
330     */
331    private void _copyAttributes(Attributes attrs, AttributesImpl newAttrs)
332    {
333        for (int i = 0; i < attrs.getLength(); i++)
334        {
335            String name = attrs.getQName(i);
336            
337            if (!"data-ametys-src".equals(name) && !"data-ametys-type".equals(name))
338            {
339                newAttrs.addAttribute(attrs.getURI(i), attrs.getLocalName(i), name, attrs.getType(i), attrs.getValue(i));
340            }
341        }
342    }
343
344    /**
345     * Get the cms uri
346     * @param request The request
347     * @return the uri without context path
348     */
349    private StringBuilder _getRequestURI(Request request)
350    {
351        StringBuilder sb = new StringBuilder();
352        sb.append(request.getScheme());
353        sb.append("://");
354        sb.append(request.getServerName());
355        
356        if (request.isSecure())
357        {
358            if (request.getServerPort() != 443)
359            {
360                sb.append(":");
361                sb.append(request.getServerPort());
362            }
363        }
364        else
365        {
366            if (request.getServerPort() != 80)
367            {
368                sb.append(":");
369                sb.append(request.getServerPort());
370            }
371        }
372        return sb;
373    }
374
375    private Attributes _getAttributesForTemp(Attributes attrs)
376    {
377        // data has just been uploaded, must change the value, and store the id for further processing
378        String id = attrs.getValue("data-ametys-temp-src");
379        String src = attrs.getValue("data-ametys-src");
380        
381        Upload upload = _uploadManager.getUpload(_userProvider.getUser(), id);
382        
383        String initialFileName = upload.getFilename();
384        // FIXME CMS-3090 A uploaded image can not contain '_max', replace it by '_Max'
385        initialFileName = initialFileName.replaceAll("_max", "_Max").replaceAll("_crop", "_Crop");
386        String fileName = _storeFile(initialFileName, upload.getInputStream(), upload.getMimeType(), upload.getUploadedDate());
387        
388        AttributesImpl newAttrs = new AttributesImpl();
389        
390        _copyAttributes(attrs, newAttrs);
391        
392        if (!"marker".equals(attrs.getValue("marker")))
393        {
394            try (InputStream is = upload.getInputStream())
395            {
396                _addDimensionAttributes(is, newAttrs);
397            }
398            catch (IOException e)
399            {
400                // Ignore
401            }
402        }
403        
404        newAttrs.addAttribute("", "data-ametys-src", "data-ametys-src", "CDATA", src.replaceAll("\\.", "/") + ";" + fileName);
405        newAttrs.addAttribute("", "data-ametys-type", "data-ametys-type", "CDATA", "local");
406        
407        return newAttrs;
408    }
409    
410    /**
411     * Store a file as rich text data.
412     * @param initialFileName the initial file name.
413     * @param is an input stream on the file.
414     * @param mimeType the file mime type.
415     * @param lastModified the last modification date.
416     * @return the final file name.
417     */
418    protected String _storeFile(String initialFileName, InputStream is, String mimeType, ZonedDateTime lastModified)
419    {
420        String fileName = initialFileName;
421        int count = 2;
422        
423        while (_richText.hasAttachment(fileName))
424        {
425            int i = initialFileName.lastIndexOf('.');
426            fileName = i == -1 ? initialFileName + '-' + (count++) : initialFileName.substring(0, i) + '-' + (count++) + initialFileName.substring(i);
427        }
428        
429        NamedResource resource = new NamedResource();
430
431        String finalMimeType = mimeType != null ? mimeType : _cocoonContext.getMimeType(fileName.toLowerCase());
432        resource.setMimeType(finalMimeType != null ? finalMimeType : "application/unknown");
433        
434        resource.setFilename(fileName);
435        resource.setLastModificationDate(lastModified != null ? lastModified : ZonedDateTime.now(ZoneOffset.UTC));
436        
437        try
438        {
439            resource.setInputStream(is);
440        }
441        catch (IOException e)
442        {
443            throw new AmetysRepositoryException("Unable to save attachment " + initialFileName, e);
444        }
445        
446        _richText.addAttachment(resource);
447        
448        // store the file usage, so that it won't be deleted immediately
449        _usedLocalFiles.add(fileName);
450        
451        return fileName;
452    }
453    
454    /**
455     * Process a local file.
456     * @param attrs the img tag attributes.
457     * @return the new img tag attributes.
458     */
459    protected Attributes _processLocal(Attributes attrs)
460    {
461        // src is of the form contentId@attributePath;fileName
462        String ametys_src = attrs.getValue("data-ametys-src");
463        int i = ametys_src.indexOf('@');
464        int j = ametys_src.indexOf(';', i);
465        String id = ametys_src.substring(0, i);
466        String attributePath = ametys_src.substring(i + 1, j);
467        String filename = ametys_src.substring(j + 1);
468        
469        if (j == -1)
470        {
471            throw new IllegalArgumentException("A local image from inline editor should have an data-ametys-src attribute of the form <protocol>://<protocol-specific-part>;<filename> : " + ametys_src);
472        }
473        
474        _usedLocalFiles.add(filename);
475        
476        Content content = _ametysResolver.resolveById(id);
477        org.ametys.cms.data.RichText richText = content.getValue(attributePath);
478        NamedResource file = richText.getAttachment(filename);
479        
480        AttributesImpl newAttrs = new AttributesImpl(attrs);
481        if (!"marker".equals(attrs.getValue("marker")))
482        {
483            try (InputStream is = file.getInputStream())
484            {
485                _addDimensionAttributes(is, newAttrs);
486            }
487            catch (IOException e)
488            {
489                // Ignore
490            }
491        }
492        
493        return newAttrs;
494    }
495
496    /**
497     * Process a resource.
498     * @param attrs the img tag attributes.
499     * @return the new img tag attributes.
500     */
501    protected Attributes _processResource(Attributes attrs)
502    {
503        String ametys_src = attrs.getValue("data-ametys-src");
504        
505        Resource resource = null;
506        try
507        {
508            resource = _ametysResolver.resolveById(ametys_src);
509        }
510        catch (UnknownAmetysObjectException ex)
511        {
512            getLogger().warn("Link to unexisting resource image " + ametys_src, ex);
513            return attrs;
514        }
515        
516        AttributesImpl newAttrs = new AttributesImpl(attrs);
517        if (!"marker".equals(attrs.getValue("marker")))
518        {
519            try (InputStream is = resource.getInputStream())
520            {
521                _addDimensionAttributes(is, newAttrs);
522            }
523            catch (IOException e)
524            {
525                // Ignore
526            }
527        }
528        
529        return newAttrs;
530    }
531
532    /**
533     * Add an image's width and height to the XML attributes.
534     * @param inputStream an input stream on the image.
535     * @param attrs the attributes to fill.
536     * @throws IOException if an error occurs during reading dimension
537     */
538    protected void _addDimensionAttributes(InputStream inputStream, AttributesImpl attrs) throws IOException
539    {
540        // We need to call Thumbnail to get image dimension with EXIF orientation tag
541        BufferedImage img = ImageHelper.read(inputStream);
542        if (img != null && attrs.getValue("width") == null)
543        {
544            attrs.addCDATAAttribute("width", Integer.toString(img.getWidth()));
545        }
546        if (img != null && attrs.getValue("height") == null)
547        {
548            attrs.addCDATAAttribute("height", Integer.toString(img.getHeight()));
549        }
550    }
551        
552    @Override
553    public void endElement(String uri, String loc, String raw) throws SAXException
554    {
555        if ("img".equals(raw) && _tagToIgnore)
556        {
557            // ignore img tag
558            _tagToIgnore = false;
559            return;
560        }
561        
562        super.endElement(uri, loc, raw);
563    }
564    
565    @Override
566    public void endDocument() throws SAXException
567    {
568        // removing unused files
569        for (NamedResource file : _richText.getAttachments())
570        {
571            String fileName = file.getFilename();
572            
573            if (!_usedLocalFiles.contains(fileName))
574            {
575                _richText.removeAttachment(fileName);
576            }
577        }
578        
579        super.endDocument();
580    }
581}