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