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;
017
018import java.io.InputStream;
019import java.nio.charset.StandardCharsets;
020import java.util.ArrayList;
021import java.util.Collections;
022import java.util.HashMap;
023import java.util.List;
024import java.util.Map;
025import java.util.Optional;
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.service.ServiceException;
031import org.apache.avalon.framework.service.ServiceManager;
032import org.apache.avalon.framework.service.Serviceable;
033import org.apache.cocoon.components.ContextHelper;
034import org.apache.cocoon.environment.Request;
035import org.apache.commons.lang3.StringUtils;
036import org.apache.http.NameValuePair;
037import org.apache.http.client.utils.URLEncodedUtils;
038
039import org.ametys.cms.data.Binary;
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.AmetysObject;
046import org.ametys.plugins.repository.AmetysObjectResolver;
047import org.ametys.plugins.repository.UnknownAmetysObjectException;
048import org.ametys.plugins.repository.data.ametysobject.ModelAwareDataAwareAmetysObject;
049import org.ametys.plugins.repository.version.VersionAwareAmetysObject;
050import org.ametys.plugins.repository.version.VersionableAmetysObject;
051import org.ametys.runtime.i18n.I18nizableText;
052import org.ametys.runtime.workspace.WorkspaceMatcher;
053
054/**
055 * {@link URIResolver} for type "attribute".<br>
056 * These links or images point to a file from the attribute of the current ametys object.
057 */
058public class AttributeURIResolver extends AbstractURIResolver implements Serviceable, Contextualizable
059{
060    private static final String __OBJECT_ID_URI_QUERY_PARAM_NAME = "objectId";
061    private static final String __CONTENT_ID_URI_QUERY_PARAM_NAME = "contentId";
062    
063    /** The ametys object resolver */
064    protected AmetysObjectResolver _resolver;
065    
066    /** The context */
067    protected Context _context;
068    
069    @Override
070    public void contextualize(Context context) throws ContextException
071    {
072        _context = context;
073    }
074    
075    @Override
076    public void service(ServiceManager manager) throws ServiceException
077    {
078        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
079    }
080    
081    @Override
082    public String getType()
083    {
084        return "attribute";
085    }
086
087    private record AmetysObjectAndRevision (ModelAwareDataAwareAmetysObject ametysObject, boolean hasSwitched, Optional<String> revision) { /* empty */ }
088    
089    @Override
090    protected String _resolve(String uri, String uriArgument, boolean download, boolean absolute, boolean internal)
091    {
092        Request request = ContextHelper.getRequest(_context);
093        AttributeInfo info = _getAttributeInfo(uri, request);
094        
095        AmetysObjectAndRevision ametysObjectAndRevision = _getAmetysObjectAndRevisionFromAttributeInfo(info);
096        ModelAwareDataAwareAmetysObject ametysObject = ametysObjectAndRevision.ametysObject();
097
098        try
099        {
100            String ametysObjectVersion = StringUtils.EMPTY;
101            if (ametysObject instanceof VersionableAmetysObject)
102            {
103                ametysObjectVersion = ((VersionableAmetysObject) ametysObject).getRevision();
104            }
105            
106            String path = info.getPath();
107            Binary binary = ametysObject.getValue(path);
108            
109            String filename = FilenameUtils.encodeName(binary.getFilename());
110            
111            String baseName = org.apache.commons.io.FilenameUtils.getBaseName(filename);
112            String extension = org.apache.commons.io.FilenameUtils.getExtension(filename);
113            
114            StringBuilder resultPath = new StringBuilder();
115            
116            resultPath.append("/_object")
117                .append(FilenameUtils.encodePath(ametysObject.getPath()))
118                .append("/_attribute/")
119                .append(path)
120                .append("/")
121                .append(baseName)
122                .append(uriArgument)
123                .append(extension.isEmpty() ? "" : "." + extension);
124      
125            String resultUri = getUri(resultPath.toString(), ametysObject, download, absolute, internal);
126    
127            Map<String, String> params = new HashMap<>();
128            params.put("objectId", ametysObject.getId());
129            
130            if (download)
131            {
132                params.put("download", "true");
133            }
134            
135            if (ametysObjectVersion != null)
136            {
137                params.put("contentVersion", ametysObjectVersion);
138            }
139            
140            return internal ? URIUtils.buildURI(resultUri, params) : URIUtils.encodeURI(resultUri, params);
141        }
142        finally
143        {
144            _resetAmetysObjectRevisionIfNeeded(ametysObjectAndRevision);
145        }
146        
147    }
148    
149    @Override
150    protected String resolveImageAsBase64(String uri, int height, int width, int maxHeight, int maxWidth, int cropHeight, int cropWidth)
151    {
152        Request request = ContextHelper.getRequest(_context);
153        AttributeInfo info = _getAttributeInfo(uri, request);
154        
155        AmetysObjectAndRevision ametysObjectAndRevision = _getAmetysObjectAndRevisionFromAttributeInfo(info);
156        ModelAwareDataAwareAmetysObject ametysObject = ametysObjectAndRevision.ametysObject();
157        
158        try
159        {
160            String path = info.getPath();
161            Binary binary = ametysObject.getValue(path);
162            
163            try (InputStream dataIs = binary.getInputStream())
164            {
165                return ImageResolverHelper.resolveImageAsBase64(dataIs, binary.getMimeType(), height, width, maxHeight, maxWidth, cropHeight, cropWidth);
166            }
167        }
168        catch (Exception e)
169        {
170            throw new IllegalStateException(e);
171        }
172        finally
173        {
174            _resetAmetysObjectRevisionIfNeeded(ametysObjectAndRevision);
175        }
176    }
177
178    public String getMimeType(String uri)
179    {
180        Request request = ContextHelper.getRequest(_context);
181        AttributeInfo info = _getAttributeInfo(uri, request);
182        
183        AmetysObjectAndRevision ametysObjectAndRevision = _getAmetysObjectAndRevisionFromAttributeInfo(info);
184        ModelAwareDataAwareAmetysObject ametysObject = ametysObjectAndRevision.ametysObject();
185        
186        try
187        {
188            String path = info.getPath();
189            Binary binary = ametysObject.getValue(path);
190            return binary.getMimeType();
191        }
192        catch (Exception e)
193        {
194            throw new IllegalStateException(e);
195        }
196        finally
197        {
198            _resetAmetysObjectRevisionIfNeeded(ametysObjectAndRevision);
199        }
200    }
201    
202    private AmetysObjectAndRevision _getAmetysObjectAndRevisionFromAttributeInfo(AttributeInfo info)
203    {
204        ModelAwareDataAwareAmetysObject ametysObject = info.getAmetysObject();
205        
206        if (ametysObject == null)
207        {
208            Request request = ContextHelper.getRequest(_context);
209            throw new IllegalStateException("Cannot resolve a local link to an unknown ametys object for uri " + request.getRequestURI());
210        }
211        
212        boolean hasToSwitch = _hasToSwitchRevision(info);
213        Optional<String> currentRevision = Optional.empty();
214        if (hasToSwitch && ametysObject instanceof VersionAwareAmetysObject versionAwareAO)
215        {
216            // Keep current revision to switch again later
217            currentRevision = Optional.ofNullable(versionAwareAO.getRevision());
218            
219            // Switch to targeted revision
220            versionAwareAO.switchToRevision(info.getAmetysObjectVersion().orElse(null));
221        }
222        
223        return new AmetysObjectAndRevision(ametysObject, hasToSwitch, currentRevision);
224    }
225    
226    /**
227     * Checks in {@link AttributeInfo} if the ametys object has the same revision than the targeted one 
228     * @param infos the {@link AttributeInfo} containing the ametys object and the targeted revision (=ametysObjectVersion) 
229     * @return <code>true</code> if a switch is needed, <code>false</code> otherwise
230     */
231    private boolean _hasToSwitchRevision(AttributeInfo infos)
232    {
233        ModelAwareDataAwareAmetysObject ametysObject = infos.getAmetysObject();
234        
235        if (ametysObject instanceof VersionAwareAmetysObject versionAwareAO)
236        {
237            String currentRevision = versionAwareAO.getRevision();
238            Optional<String> targetRevision = infos.getAmetysObjectVersion();
239            
240            return currentRevision == null && targetRevision.isPresent() // ametys object is at latest revision but another one has been targeted 
241                || targetRevision.isEmpty()                              // ametys object is at specific revision but latest has been targeted
242                || !targetRevision.get().equals(currentRevision);        // ametys object is at specific revision but another one has been targeted
243        }
244        
245        return false;
246    }
247    
248    private void _resetAmetysObjectRevisionIfNeeded(AmetysObjectAndRevision ametysObjectAndRevision)
249    {
250        if (ametysObjectAndRevision.hasSwitched() && ametysObjectAndRevision.ametysObject() instanceof VersionAwareAmetysObject versionAwareAO)
251        {
252            // Switch again the content revision
253            Optional<String> oldRevision = ametysObjectAndRevision.revision();
254            versionAwareAO.switchToRevision(oldRevision.orElse(null));
255        }
256    }
257    
258    /**
259     * Get the URI prefix
260     * @param path the resource path
261     * @param object The object
262     * @param download true if the pointed resource is to be downloaded.
263     * @param absolute true if the url must be absolute
264     * @param internal true to get an internal URI.
265     * @return the URI prefix
266     */
267    protected String getUri(String path, ModelAwareDataAwareAmetysObject object, boolean download, boolean absolute, boolean internal)
268    {
269        if (internal)
270        {
271            return "cocoon://" + path;
272        }
273        else 
274        {
275            Request request = ContextHelper.getRequest(_context);
276            String workspaceURI = (String) request.getAttribute(WorkspaceMatcher.WORKSPACE_URI);
277            String uriPrefix = request.getContextPath() + workspaceURI;
278            
279            if (absolute && !uriPrefix.startsWith(request.getScheme()))
280            {
281                uriPrefix = request.getScheme() + "://" + request.getServerName() + (request.getServerPort() != 80 ? ":" + request.getServerPort() : "") + uriPrefix;
282            }
283            
284            return uriPrefix + path;
285        }
286    }
287    
288    @Override
289    public CHECK checkLink(String uri, boolean shortTest)
290    {
291        return CHECK.SUCCESS;
292    }
293    
294    @Override
295    public I18nizableText getLabel(String uri)
296    {
297        try
298        {
299            Request request = ContextHelper.getRequest(_context);
300            
301            AttributeInfo info = _getAttributeInfo(uri, request);
302            
303            return new I18nizableText("plugin.cms", "PLUGINS_CMS_LINK_METADATA_LABEL", Collections.singletonList(info.getPath()));
304        }
305        catch (UnknownAmetysObjectException e)
306        {
307            return new I18nizableText("plugin.cms", "PLUGINS_CMS_LINK_METADATA_UNKNOWN");
308        }
309    }
310    
311    /**
312     * Get attribute path and ametys object.
313     * @param uri the attribute URI.
314     * @param request the request.
315     * @return the attribute info.
316     */
317    protected AttributeInfo _getAttributeInfo(String uri, Request request)
318    {
319        AttributeInfo info = new AttributeInfo();
320        
321        int i = uri.indexOf('?');
322
323        String attributePath = i < 0 ? uri : uri.substring(0, i);
324        info.setPath(attributePath);
325
326        List<NameValuePair> queryParams = Optional.of(i)
327                                                  .filter(index -> index >= 0)
328                                                  .map(index -> uri.substring(index + 1))
329                                                  .map(queryParamsAsString -> URLEncodedUtils.parse(queryParamsAsString, StandardCharsets.UTF_8))
330                                                  .orElseGet(ArrayList::new);
331        
332        ModelAwareDataAwareAmetysObject ametysObject = (ModelAwareDataAwareAmetysObject) request.getAttribute(Content.class.getName());
333
334        Optional<String> objectId = _getQueryParamValue(queryParams, __OBJECT_ID_URI_QUERY_PARAM_NAME)
335                          .or(() -> _getQueryParamValue(queryParams, __CONTENT_ID_URI_QUERY_PARAM_NAME));
336        if (objectId.isPresent() && (ametysObject == null || !objectId.get().equals(ametysObject.getId())))
337        {
338            ametysObject = _resolver.resolveById(objectId.get());
339        }
340
341        info.setAmetysObject(ametysObject);
342
343        _getQueryParamValue(queryParams, "contentVersion").ifPresent(info::setAmetysObjectVersion);
344        
345        return info;
346    }
347    
348    private Optional<String> _getQueryParamValue(List<NameValuePair> queryParams, String queryParamName)
349    {
350        return queryParams.stream()
351                          .filter(pair -> queryParamName.equals(pair.getName()))
352                          .map(NameValuePair::getValue)
353                          .findFirst();
354    }
355
356    /**
357     * Attribute information.
358     */
359    protected class AttributeInfo
360    {
361        private String _path;
362        private ModelAwareDataAwareAmetysObject _ametysObject;
363        private Optional<String> _ametysObjectVersion = Optional.empty();
364        
365        /**
366         * Get the attribute path.
367         * @return the attribute path
368         */
369        public String getPath()
370        {
371            return _path;
372        }
373        
374        /**
375         * Set the attribute path.
376         * @param path the attribute path to set
377         */
378        public void setPath(String path)
379        {
380            _path = path;
381        }
382        
383        /**
384         * Get the ametys object.
385         * @return the ametys object
386         */
387        public ModelAwareDataAwareAmetysObject getAmetysObject()
388        {
389            return _ametysObject;
390        }
391        
392        /**
393         * Set the ametys object.
394         * @param ametysObject the {@link AmetysObject} to set
395         */
396        public void setAmetysObject(ModelAwareDataAwareAmetysObject ametysObject)
397        {
398            _ametysObject = ametysObject;
399        }
400        
401        /**
402         * Get the ametys object's version.
403         * @return the ametys object's version
404         */
405        public Optional<String> getAmetysObjectVersion()
406        {
407            return _ametysObjectVersion;
408        }
409        
410        /**
411         * Set the ametys object's version.
412         * @param ametysObjectVersion the version to set
413         */
414        public void setAmetysObjectVersion(String ametysObjectVersion)
415        {
416            _ametysObjectVersion = Optional.ofNullable(ametysObjectVersion);
417        }
418    }
419}