001/*
002 *  Copyright 2016 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 */
016
017package org.ametys.plugins.core.ui.resources;
018
019import java.io.IOException;
020import java.io.Serializable;
021import java.net.MalformedURLException;
022import java.net.URI;
023import java.net.URISyntaxException;
024import java.util.HashMap;
025import java.util.List;
026import java.util.Map;
027import java.util.concurrent.ConcurrentHashMap;
028
029import org.apache.avalon.framework.component.Component;
030import org.apache.avalon.framework.parameters.Parameters;
031import org.apache.cocoon.ProcessingException;
032import org.apache.cocoon.ResourceNotFoundException;
033import org.apache.cocoon.components.source.SourceUtil;
034import org.apache.commons.io.FilenameUtils;
035import org.apache.commons.lang3.StringUtils;
036import org.apache.commons.lang3.tuple.Pair;
037import org.apache.excalibur.source.Source;
038import org.apache.excalibur.source.SourceException;
039import org.apache.excalibur.source.SourceValidity;
040import org.apache.excalibur.source.impl.validity.TimeStampValidity;
041
042import org.ametys.core.resources.AbstractResourceHandler;
043
044/**
045 * Abstract reader for resources compiled during runtime, such as SASS or LESS files compiled into CSS.
046 */
047public abstract class AbstractCompiledResourceHandler extends AbstractResourceHandler implements Component
048{
049    /* Dependencies cache */
050    private static Map<String, Pair<List<String>, Long>> _dependenciesCache = new ConcurrentHashMap<>();
051    
052    /**
053     * Calculate the list of dependencies for the given source, for validity calculations.
054     * @param inputSource The source
055     * @return The list of uri
056     */
057    protected abstract List<String> getDependenciesList(Source inputSource);
058    
059    /**
060     * Get the compiled source uri 
061     * @param location The requested location 
062     * @param additionalParameters Additional parameters to fill, that will be transmitted to the generate method
063     * @return the source
064     * @throws MalformedURLException if location is malformed.
065     * @throws IOException  If an IO error occurs
066     */
067    protected abstract Source getSourceToCompile(String location, Map<String, Object> additionalParameters) throws MalformedURLException, IOException;
068    
069    /**
070     * Is the source supported upon its uri
071     * @param source The location to analyse
072     * @return true if supported
073     */
074    protected boolean _isBasicallySupported(String source)
075    {
076        return super.isSupported(source); 
077    }
078    
079    @Override
080    public boolean isSupported(String source)
081    {
082        if (!_isBasicallySupported(source))
083        {
084            return false;
085        }
086        
087        // If the requested source ends with '.css', this handler supports it only if the CSS file does not exist.
088        Source src = null;
089        try
090        {
091            src = _resolver.resolveURI(source);
092            if (src.exists())
093            {
094                return false;
095            }
096        }
097        catch (IOException e)
098        {
099            // Nothing
100        }
101        finally 
102        {
103            _resolver.release(src);
104        }
105        
106        return true;
107    }
108    
109    @Override
110    public Source setup(String location, Map objectModel, Parameters par, Map<String, Object> additionalParameters) throws IOException, ProcessingException
111    {
112        try 
113        {
114            Source source = getSourceToCompile(location, additionalParameters);
115            if (!source.exists())
116            {
117                throw new ResourceNotFoundException("Resource not found for URI : " + location);
118            }
119            
120            return source;
121        } 
122        catch (SourceException e) 
123        {
124            throw SourceUtil.handle("Error during resolving of '" + location + "'.", e);
125        }
126    }
127    
128    @Override
129    public Serializable getKey(Source source, Map objectModel, Parameters parameters, Map<String, Object> additionalParameters)
130    {
131        return _getDependenciesKeys(source, source.getURI(), FilenameUtils.normalize(source.getURI()), source.getLastModified(), new HashMap<String, String>());
132    }
133
134    @Override
135    public SourceValidity getValidity(Source source, Map objectModel, Parameters parameters, Map<String, Object> additionalParameters)
136    {
137        Long lastModified = _getCalculatedLastModified(source, source.getURI(), source.getLastModified(), new HashMap<String, String>());
138        return lastModified != null ? new TimeStampValidity(lastModified) : null;
139    }
140
141    private Long _getCalculatedLastModified(Source inputSource, String sourceUri, long lastModified, HashMap<String, String> knowDependencies)
142    {
143        long result = lastModified;
144        List<String> dependencies = _getDependencies(inputSource, sourceUri, lastModified);
145        
146        for (String dependency : dependencies)
147        {
148            if (dependency != null && !StringUtils.startsWith(dependency, "http://") && !StringUtils.startsWith(dependency, "https://"))
149            {
150                try
151                {
152                    String uriToResolve = _getDependencyURI(sourceUri, dependency);
153                    
154                    HashMap<String, Object> params = new HashMap<>();
155                    Source dependencySource = _resolver.resolveURI(uriToResolve, null, params);
156                    String fsURI = FilenameUtils.normalize(dependencySource.getURI());
157                    if (!knowDependencies.containsKey(fsURI))
158                    {
159                        knowDependencies.put(fsURI, sourceUri);
160
161                        Long calculatedLastModified = _getCalculatedLastModified(dependencySource, uriToResolve, dependencySource.getLastModified(), knowDependencies);
162                        if (calculatedLastModified != null && calculatedLastModified > result)
163                        {
164                            result = calculatedLastModified;
165                        }
166                    }
167                }
168                catch (Exception e)
169                {
170                    getLogger().warn("Unable to resolve the following uri : '" + dependency + "' while calculating dependencies for " + inputSource.getURI(), e);
171                    return null;
172                }
173            }
174        }
175        
176        return result;
177    }
178    
179    private String _getDependenciesKeys(Source inputSource, String sourceUri, String fileURI, long lastModified, HashMap<String, String> knowDependencies)
180    {
181        String result = fileURI;
182        List<String> dependencies = _getDependencies(inputSource, sourceUri, lastModified);
183        
184        for (String dependency : dependencies)
185        {
186            if (dependency != null && !StringUtils.startsWith(dependency, "http://") && !StringUtils.startsWith(dependency, "https://"))
187            {
188                try
189                {
190                    String uriToResolve = _getDependencyURI(sourceUri, dependency);
191                    
192                    HashMap<String, Object> params = new HashMap<>();
193                    Source dependencySource = _resolver.resolveURI(uriToResolve, null, params);
194                    String fileDependencyURI = FilenameUtils.normalize(dependencySource.getURI());
195                    if (knowDependencies.containsKey(fileDependencyURI))
196                    {
197                        getLogger().warn("A loop import was detected in file : '" + sourceUri + "' that imports '" + fileDependencyURI 
198                                + "' but it was already previously imported by '" + knowDependencies.get(fileDependencyURI) + "'.");
199                    }
200                    else
201                    {
202                        knowDependencies.put(fileDependencyURI, sourceUri);
203                        String dependenciesKeys = _getDependenciesKeys(dependencySource, uriToResolve, fileDependencyURI, dependencySource.getLastModified(), knowDependencies);
204                        if (dependenciesKeys != null)
205                        {
206                            result = result + "*" + dependenciesKeys;
207                        }
208                    }
209                }
210                catch (Exception e)
211                {
212                    getLogger().warn("Unable to resolve the following uri : '" + dependency + "' while calculating dependencies for " + inputSource.getURI(), e);
213                    return null;
214                }
215            }
216        }
217        
218        return result;
219    }
220    
221    private String _getDependencyURI(String sourceUri, String dependency) throws URISyntaxException
222    {
223        URI uri = new URI(dependency);
224        String uriToResolve = uri.isAbsolute() ? dependency : FilenameUtils.getFullPath(sourceUri) + dependency;
225        
226        // Don't normalize the schema part of the uri
227        String schema = StringUtils.contains(uriToResolve, "://") ? uriToResolve.substring(0, uriToResolve.indexOf("://") + 3) : "";
228        uriToResolve = schema + FilenameUtils.normalize(StringUtils.removeStart(uriToResolve, schema));
229        return uriToResolve;
230    }
231
232    private List<String> _getDependencies(Source inputSource, String sourceUri, long lastModified)
233    {
234        Pair<List<String>, Long> cachedDependencies = _dependenciesCache.get(sourceUri);
235        
236        List<String> dependencies;
237        
238        if (cachedDependencies == null || !cachedDependencies.getRight().equals(lastModified))
239        {
240            // Cache is out of date
241            dependencies = getDependenciesList(inputSource);
242            _dependenciesCache.put(sourceUri, Pair.of(dependencies, lastModified));
243        }
244        else
245        {
246            dependencies = cachedDependencies.getLeft();
247        }
248        return dependencies;
249    }
250
251}