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