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