001/*
002 *  Copyright 2018 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.plugins.core.ui.resources.css;
017
018import java.io.IOException;
019import java.io.InputStream;
020import java.net.URI;
021import java.net.URISyntaxException;
022import java.nio.charset.StandardCharsets;
023import java.util.ArrayList;
024import java.util.Collections;
025import java.util.HashMap;
026import java.util.LinkedHashSet;
027import java.util.List;
028import java.util.Map;
029import java.util.Optional;
030import java.util.Set;
031import java.util.concurrent.ConcurrentHashMap;
032import java.util.regex.Matcher;
033import java.util.regex.Pattern;
034
035import org.apache.avalon.framework.service.ServiceException;
036import org.apache.avalon.framework.service.ServiceManager;
037import org.apache.avalon.framework.service.Serviceable;
038import org.apache.commons.io.FilenameUtils;
039import org.apache.commons.io.IOUtils;
040import org.apache.commons.lang3.StringUtils;
041import org.apache.commons.lang3.tuple.Pair;
042import org.apache.excalibur.source.Source;
043import org.apache.excalibur.source.SourceResolver;
044
045import org.ametys.core.resources.ProxiedContextPathProvider;
046import org.ametys.core.resources.ResourceReader;
047import org.ametys.plugins.core.ui.minimize.HashCache.UriData;
048import org.ametys.plugins.core.ui.resources.ResourceDependenciesList;
049import org.ametys.plugins.core.ui.util.RequestAttributesHelper;
050import org.ametys.runtime.plugin.component.AbstractLogEnabled;
051
052/**
053 * Dependencies list for css files
054 */
055public class CssDependenciesList extends AbstractLogEnabled implements ResourceDependenciesList, Serviceable
056{
057    private static final Pattern __EXTERNAL_URL = Pattern.compile("^(http[s]?://[^/]+)(/.*)?$");
058
059    /** RequestAttributesHelper */
060    protected RequestAttributesHelper _requestAttributesHelper;
061
062    /* Dependencies cache */
063    private Map<String, Pair<List<String>, Long>> _dependenciesCache = new ConcurrentHashMap<>();
064
065    private SourceResolver _sourceResolver;
066    
067    private ProxiedContextPathProvider _proxiedContextPathProvider;
068
069    public void service(ServiceManager manager) throws ServiceException
070    {
071        _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE);
072        _requestAttributesHelper = (RequestAttributesHelper) manager.lookup(RequestAttributesHelper.ROLE);
073        _proxiedContextPathProvider = (ProxiedContextPathProvider) manager.lookup(ProxiedContextPathProvider.ROLE);
074    }
075    
076    @Override
077    public boolean isSupported(String uri)
078    {
079        return StringUtils.endsWith(uri, ".css");
080    }
081
082    @Override
083    public int getPriority()
084    {
085        return 100;
086    }
087
088    public Set<UriData> getDependenciesList(String uri, Map<String, String> data, boolean onlyFirstLevel)
089    {
090        return _getFileDependencies(uri, data.getOrDefault("media", null), true, onlyFirstLevel);
091    }
092    
093    private Set<UriData> _getFileDependencies(String cssUri, String media, boolean firstLevel, boolean onlyFirstLevel) throws IllegalArgumentException
094    {
095        Set<UriData> dependencies = new LinkedHashSet<>();
096        
097        Source fileSource = null;
098        Map<String, Object> resolveParameters = new HashMap<>();
099        
100        try
101        {
102            fileSource = _getFileSource(cssUri, resolveParameters);
103            
104            long fileLastModified = resolveParameters.get(ResourceReader.LAST_MODIFIED) != null ? (long) resolveParameters.get("lastModified") : -1;
105             
106            UriData fileInfos = new UriData(cssUri, firstLevel);
107            fileInfos.setLastModified(fileLastModified);
108            fileInfos.setMedia(media);
109            dependencies.add(fileInfos);
110            
111            if (onlyFirstLevel)
112            {
113                return dependencies;
114            }
115            
116            Long validity = Optional.ofNullable(_dependenciesCache.get(cssUri))
117                    .map(Pair::getRight)
118                    .orElse(null);
119            
120            if (validity == null || validity != fileLastModified)
121            {
122                // cache is outdated
123                List<String> dependenciesCache = new ArrayList<>();
124                
125                for (UriData cssDependency : _getCssFileDependencies(cssUri, fileSource))
126                {
127                    if (!dependenciesCache.contains(cssDependency.getUri()))
128                    {
129                        dependenciesCache.add(cssDependency.getUri());
130                        dependencies.add(cssDependency);
131                    }
132                }
133                
134                _dependenciesCache.put(cssUri, Pair.of(dependenciesCache, fileLastModified));
135            }
136            else
137            {
138                // cache is up to date
139                Pair<List<String>, Long> dependenciesFromCache = _dependenciesCache.get(cssUri);
140                if (dependenciesFromCache != null)
141                {
142                    for (String dependencyCached : dependenciesFromCache.getLeft())
143                    {
144                        dependencies.addAll(_getFileDependencies(dependencyCached, null, false, false));
145                    }
146                }
147            }
148        }
149        finally 
150        {
151            _sourceResolver.release(fileSource);
152        }
153        
154        return dependencies;
155    }
156
157    private Source _getFileSource(String cssUri, Map<String, Object> resolveParameters)
158    {
159        try
160        {
161            String uriToResolve = cssUri;
162            URI uri = new URI(cssUri);
163            if (!uri.isAbsolute())
164            {
165                uriToResolve = "cocoon:/" + uriToResolve;
166            }
167            
168            _requestAttributesHelper.removeRequestAttributes();
169            
170            return _sourceResolver.resolveURI(uriToResolve, null, resolveParameters);
171        }
172        catch (Exception e)
173        {
174            throw new IllegalArgumentException("Unable to resolve the dependencies of specified uri", e);
175        }
176    }
177    
178    private List<UriData> _getCssFileDependencies(String uri, Source cssSource) throws IllegalArgumentException
179    {
180        List<UriData> cssDependencies = new ArrayList<>();
181        
182        String fileContent;
183        try (InputStream is = cssSource.getInputStream())
184        {
185            fileContent = IOUtils.toString(is, StandardCharsets.UTF_8);
186        }
187        catch (IOException e)
188        {
189            getLogger().error("Unable to retrieve css file dependencies for '" + uri + "'", e);
190            return Collections.EMPTY_LIST;
191        }
192        
193        String contextPath = _proxiedContextPathProvider.getContextPath();
194        
195        Matcher urlMatcher = CSSFileHelper.IMPORT_PATTERN.matcher(fileContent);
196        
197        while (urlMatcher.find()) 
198        {
199            String cssUrl = urlMatcher.group(1);
200            
201            Matcher externalMatcher = __EXTERNAL_URL.matcher(cssUrl);
202            
203            if (!externalMatcher.find())
204            {
205                try
206                {
207                    cssUrl = StringUtils.removeStart(cssUrl, contextPath);
208                    URI cssUri = new URI(cssUrl);
209                    if (!cssUri.isAbsolute() && !cssUrl.startsWith("/"))
210                    {
211                        cssUri = new URI(FilenameUtils.getFullPath(uri) + cssUrl);
212                    }
213                    cssDependencies.addAll(_getFileDependencies(cssUri.normalize().toString(), null, false, false));
214                }
215                catch (URISyntaxException e)
216                {
217                    // Invalid URI inside a file, but should not be blocking
218                    getLogger().warn("Invalid URI in a file, could not calculate dependancies for file : " + uri , e);
219                }
220            }
221        }
222        
223        return cssDependencies;
224    }
225}