001/*
002 *  Copyright 2019 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;
017
018import java.io.InputStream;
019import java.util.Collections;
020import java.util.HashMap;
021import java.util.Map;
022import java.util.Optional;
023
024import javax.jcr.RepositoryException;
025import javax.jcr.Session;
026
027import org.apache.avalon.framework.context.Context;
028import org.apache.avalon.framework.context.ContextException;
029import org.apache.avalon.framework.context.Contextualizable;
030import org.apache.avalon.framework.logger.AbstractLogEnabled;
031import org.apache.avalon.framework.service.ServiceException;
032import org.apache.avalon.framework.service.ServiceManager;
033import org.apache.avalon.framework.service.Serviceable;
034import org.apache.cocoon.components.ContextHelper;
035import org.apache.cocoon.environment.Request;
036
037import org.ametys.cms.URIPrefixHandler;
038import org.ametys.cms.data.Resource;
039import org.ametys.cms.data.RichText;
040import org.ametys.cms.repository.Content;
041import org.ametys.cms.transformation.ConsistencyChecker.CHECK;
042import org.ametys.core.util.FilenameUtils;
043import org.ametys.core.util.ImageResolverHelper;
044import org.ametys.core.util.URIUtils;
045import org.ametys.plugins.repository.AmetysObjectResolver;
046import org.ametys.plugins.repository.AmetysRepositoryException;
047import org.ametys.plugins.repository.UnknownAmetysObjectException;
048import org.ametys.plugins.repository.version.VersionAwareAmetysObject;
049import org.ametys.runtime.i18n.I18nizableText;
050
051/**
052 * {@link URIResolver} for resources local to a rich text.
053 */
054public class LocalURIResolver extends AbstractLogEnabled implements URIResolver, Contextualizable, Serviceable
055{
056    /** The context */
057    protected Context _context;
058    /** The ametys object resolver */
059    protected AmetysObjectResolver _ametysObjectResolver;
060    
061    private URIPrefixHandler _prefixHandler;
062
063    @Override
064    public void service(ServiceManager manager) throws ServiceException
065    {
066        _ametysObjectResolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
067        _prefixHandler = (URIPrefixHandler) manager.lookup(URIPrefixHandler.ROLE);
068    }
069    
070    @Override
071    public void contextualize(Context context) throws ContextException
072    {
073        _context = context;
074    }
075    
076    @Override
077    public String getType()
078    {
079        return "local";
080    }
081
082    @Override
083    public String resolve(String uri, boolean download, boolean absolute, boolean internal)
084    {
085        URIInfo infos = getInfos(uri, false, null);
086        
087        StringBuilder resultPath = new StringBuilder();
088        
089        resultPath.append(_prefixHandler.computeUriPrefix(absolute, internal))
090                  .append("/plugins/cms/richText-file/")
091                  .append(FilenameUtils.encodeName(infos.getFilename()));
092        
093        Map<String, String> params = new HashMap<>();
094        params.put("contentId", infos.getContentId());
095        params.put("attribute", infos.getAttribute());
096        
097        if (download)
098        {
099            params.put("download", "true");
100        }
101        
102        infos.getContentVersion()
103             .ifPresent(version -> params.put("contentVersion", version));
104        
105        // Encode twice
106        return URIUtils.encodeURI(resultPath.toString(), params);
107    }
108    
109    /**
110     * Parses the uri.
111     * @param uri the incoming uri.
112     * @param resolveContent true if the Content should be actually resolved if not found in the request.
113     * @param session the JCR {@link Session} to use, or null to use the current Session.
114     * @return an object containing all parsed infos.
115     */
116    protected URIInfo getInfos(String uri, boolean resolveContent, Session session)
117    {
118        // uri are like <contentId>[|<contentVersion>]@<attribute>;<file.ext>
119        int i = Math.max(uri.indexOf('|'), 0);
120        int j = uri.indexOf('@', i);
121        int k = uri.indexOf(';', j);
122        String id = uri.substring(0, i > 0 ? i : j);
123        Optional<String> contentVersion = i > 0 ? Optional.of(uri.substring(i + 1, j)) : Optional.empty();
124        String attribute = uri.substring(j + 1, k);
125        String filename = uri.substring(k + 1);
126
127        Content content = null;
128        
129        try
130        {
131            Request request  = ContextHelper.getRequest(_context);
132            content = (Content) request.getAttribute(Content.class.getName());
133        }
134        catch (Exception e)
135        {
136            // there's no request, thus no "current" content 
137        }
138
139        if (content == null || !id.equals(content.getId()))
140        {
141            // Some time (such as frontoffice edition) the image is rendered with no content in attribute
142            // The content should be resolved against the given id, but in that case, getRevision will be always the head on
143            try
144            {
145                content = resolveContent ? session == null ? _ametysObjectResolver.resolveById(id) : _ametysObjectResolver.resolveById(id, session) : null;
146            }
147            catch (RepositoryException e)
148            {
149                throw new AmetysRepositoryException(e);
150            }
151        }
152        
153        URIInfo infos = new URIInfo();
154        infos.setContentId(id);
155        infos.setContentVersion(contentVersion);
156        infos.setContent(content);
157        infos.setAttribute(attribute);
158        infos.setFilename(filename);
159        
160        return infos;
161    }
162    
163    @Override
164    public String resolveImage(String uri, int height, int width, boolean download, boolean absolute, boolean internal)
165    {
166        return resolve(uri, download, absolute, internal);
167    }
168    
169    @Override
170    public String resolveImageAsBase64(String uri, int height, int width)
171    {
172        return resolveImageAsBase64(uri, height, width, 0, 0, 0, 0);
173    }
174    
175    @Override
176    public String resolveBoundedImage(String uri, int maxHeight, int maxWidth, boolean download, boolean absolute, boolean internal)
177    {
178        return resolve(uri, download, absolute, internal);
179    }
180    
181    @Override
182    public String resolveBoundedImageAsBase64(String uri, int maxHeight, int maxWidth)
183    {
184        return resolveImageAsBase64(uri, 0, 0, maxHeight, maxWidth, 0, 0);
185    }
186    
187    @Override
188    public String resolveCroppedImage(String uri, int cropHeight, int cropWidth, boolean download, boolean absolute, boolean internal)
189    {
190        return resolve(uri, download, absolute, internal);
191    }
192    
193    @Override
194    public String resolveCroppedImageAsBase64(String uri, int cropHeight, int cropWidth)
195    {
196        return resolveImageAsBase64(uri, 0, 0, 0, 0, cropHeight, cropWidth);
197    }
198
199    public String getMimeType(String uri)
200    {
201        URIInfo infos = getInfos(uri, true, null);
202        
203        ContentAndRevision contentAndRevision = _getContentAndRevisionFromAttributeInfo(infos);
204        Content content = contentAndRevision.content();
205        
206        try
207        {
208            RichText richText = content.getValue(infos.getAttribute());
209            Resource resource = richText.getAttachment(infos.getFilename());
210            return resource.getMimeType();
211        }
212        catch (Exception e)
213        {
214            throw new IllegalStateException(e);
215        }
216        finally
217        {
218            _resetContentRevisionIfNeeded(contentAndRevision);
219        }
220    }
221    
222    private record ContentAndRevision (Content content, boolean hasSwitched, Optional<String> revision) { /* empty */ }
223    
224    /**
225     * Get an image's bytes encoded as base64, optionally resized. 
226     * @param uri the image URI.
227     * @param height the specified height. Ignored if negative.
228     * @param width the specified width. Ignored if negative.
229     * @param maxHeight the maximum image height. Ignored if height or width is specified.
230     * @param maxWidth the maximum image width. Ignored if height or width is specified.
231     * @param cropHeight The cropping height. Ignored if negative.
232     * @param cropWidth The cropping width. Ignored if negative.
233     * @return the image bytes encoded as base64.
234     */
235    protected String resolveImageAsBase64(String uri, int height, int width, int maxHeight, int maxWidth, int cropHeight, int cropWidth)
236    {
237        URIInfo infos = getInfos(uri, true, null);
238        
239        ContentAndRevision contentAndRevision = _getContentAndRevisionFromAttributeInfo(infos);
240        Content content = contentAndRevision.content();
241        
242        try
243        {
244            RichText richText = content.getValue(infos.getAttribute());
245            Resource resource = richText.getAttachment(infos.getFilename());
246            
247            try (InputStream dataIs = resource.getInputStream())
248            {
249                return ImageResolverHelper.resolveImageAsBase64(dataIs, resource.getMimeType(), height, width, maxHeight, maxWidth, cropHeight, cropWidth);
250            }
251        }
252        catch (Exception e)
253        {
254            throw new IllegalStateException(e);
255        }
256        finally
257        {
258            _resetContentRevisionIfNeeded(contentAndRevision);
259        }
260    }
261    
262    @Override
263    public CHECK checkLink(String uri, boolean shortTest)
264    {
265        URIInfo infos = getInfos(uri, true, null);
266        
267        ContentAndRevision contentAndRevision = _getContentAndRevisionFromAttributeInfo(infos);
268        Content content = contentAndRevision.content();
269
270        try
271        {
272            RichText richText = content.getValue(infos.getAttribute());
273            return richText != null
274                 ? richText.hasAttachment(infos.getFilename())
275                         ? CHECK.SUCCESS
276                         : CHECK.NOT_FOUND
277                 : CHECK.NOT_FOUND;
278        }
279        catch (Exception e)
280        {
281            throw new RuntimeException("Cannot check the uri '" + uri + "'", e);
282        }
283        finally
284        {
285            _resetContentRevisionIfNeeded(contentAndRevision);
286        }
287    }
288    
289    private ContentAndRevision _getContentAndRevisionFromAttributeInfo(URIInfo info)
290    {
291        Content content = info.getContent();
292        
293        boolean hasToSwitch = _hasToSwitchRevision(info);
294        Optional<String> currentRevision = Optional.empty();
295        if (hasToSwitch && content instanceof VersionAwareAmetysObject versionAwareAO)
296        {
297            // Keep current revision to switch again later
298            currentRevision = Optional.ofNullable(versionAwareAO.getRevision());
299            
300            // Switch to targeted revision
301            versionAwareAO.switchToRevision(info.getContentVersion().orElse(null));
302        }
303        
304        return new ContentAndRevision(content, hasToSwitch, currentRevision);
305    }
306    
307    /**
308     * Checks in {@link URIInfo} if the content has the same revision than the targeted one 
309     * @param info the {@link URIInfo} containing the content and the targeted revision (=contentVersion) 
310     * @return <code>true</code> if a switch is needed, <code>false</code> otherwise
311     */
312    private boolean _hasToSwitchRevision(URIInfo info)
313    {
314        Content content = info.getContent();
315        
316        if (content instanceof VersionAwareAmetysObject versionAwareAO)
317        {
318            String currentRevision = versionAwareAO.getRevision();
319            Optional<String> targetRevision = info.getContentVersion();
320            
321            return currentRevision == null && targetRevision.isPresent() // content is at latest revision but another one has been targeted 
322                || targetRevision.isEmpty()                              // content is at specific revision but latest has been targeted
323                || !targetRevision.get().equals(currentRevision);        // content is at specific revision but another one has been targeted
324        }
325        
326        return false;
327    }
328    
329    private void _resetContentRevisionIfNeeded(ContentAndRevision contentAndRevision)
330    {
331        if (contentAndRevision.hasSwitched() && contentAndRevision.content() instanceof VersionAwareAmetysObject versionAwareAO)
332        {
333            // Switch again the content revision
334            Optional<String> oldRevision = contentAndRevision.revision();
335            versionAwareAO.switchToRevision(oldRevision.orElse(null));
336        }
337    }
338    
339    @Override
340    public I18nizableText getLabel(String uri)
341    {
342        try
343        {
344            URIInfo infos = getInfos(uri, false, null);
345            
346            return new I18nizableText("plugin.cms", "PLUGINS_CMS_LINK_LOCAL_LABEL", Collections.singletonList(infos.getFilename()));
347        }
348        catch (UnknownAmetysObjectException e)
349        {
350            return new I18nizableText("plugin.cms", "PLUGINS_CMS_LINK_LOCAL_UNKNOWN");
351        }
352    }
353    
354    /**
355     * Helper class containg all infos parsed from URI.
356     */
357    protected static class URIInfo
358    {
359        /** The content id. */
360        private String _contentId;
361        /** The relevant attribute */
362        private String _attribute;
363        /** The resource name */
364        private String _filename;
365        /** The content version, if any. */
366        private Optional<String> _contentVersion = Optional.empty();
367        /** The resolved content, if any. */
368        private Content _content;
369        
370        /**
371         * Returns the content id.
372         * @return the content id.
373         */
374        public String getContentId()
375        {
376            return _contentId;
377        }
378        
379        /**
380         * Set the content id.
381         * @param contentId the content id.
382         */
383        public void setContentId(String contentId)
384        {
385            _contentId = contentId;
386        }
387        
388        /**
389         * Returns the attribute.
390         * @return the attribute.
391         */
392        public String getAttribute()
393        {
394            return _attribute;
395        }
396        
397        /**
398         * Set the attribute.
399         * @param attribute the attribute.
400         */
401        public void setAttribute(String attribute)
402        {
403            _attribute = attribute;
404        }
405        
406        /**
407         * Returns the resource name.
408         * @return the name
409         */
410        public String getFilename()
411        {
412            return _filename;
413        }
414        
415        /**
416         * Set the resource name.
417         * @param name the name.
418         */
419        public void setFilename(String name)
420        {
421            _filename = name;
422        }
423        
424        /**
425         * Returns the content version, if any.
426         * @return the content version.
427         */
428        public Optional<String> getContentVersion()
429        {
430            return _contentVersion;
431        }
432        
433        /**
434         * Set the content version.
435         * @param contentVersion the content version.
436         */
437        public void setContentVersion(Optional<String> contentVersion)
438        {
439            _contentVersion = contentVersion;
440        }
441        
442        /**
443         * Returns the resolved content, if any.
444         * @return the content.
445         */
446        public Content getContent()
447        {
448            return _content;
449        }
450        
451        /**
452         * Set the content.
453         * @param content the content.
454         */
455        public void setContent(Content content)
456        {
457            _content = content;
458        }
459    }
460}