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)
089    {
090        return _getFileDependencies(uri, data.getOrDefault("media", null), true);
091    }
092    
093    private Set<UriData> _getFileDependencies(String cssUri, String media, boolean firstLevel) 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            Long validity = Optional.ofNullable(_dependenciesCache.get(cssUri))
112                    .map(Pair::getRight)
113                    .orElse(null);
114            
115            if (validity == null || validity != fileLastModified)
116            {
117                // cache is outdated
118                List<String> dependenciesCache = new ArrayList<>();
119                
120                for (UriData cssDependency : _getCssFileDependencies(cssUri, fileSource))
121                {
122                    if (!dependenciesCache.contains(cssDependency.getUri()))
123                    {
124                        dependenciesCache.add(cssDependency.getUri());
125                        dependencies.add(cssDependency);
126                    }
127                }
128                
129                _dependenciesCache.put(cssUri, Pair.of(dependenciesCache, fileLastModified));
130            }
131            else
132            {
133                // cache is up to date
134                Pair<List<String>, Long> dependenciesFromCache = _dependenciesCache.get(cssUri);
135                if (dependenciesFromCache != null)
136                {
137                    for (String dependencyCached : dependenciesFromCache.getLeft())
138                    {
139                        dependencies.addAll(_getFileDependencies(dependencyCached, null, false));
140                    }
141                }
142            }
143        }
144        finally 
145        {
146            _sourceResolver.release(fileSource);
147        }
148        
149        return dependencies;
150    }
151
152    private Source _getFileSource(String cssUri, Map<String, Object> resolveParameters)
153    {
154        try
155        {
156            String uriToResolve = cssUri;
157            URI uri = new URI(cssUri);
158            if (!uri.isAbsolute())
159            {
160                uriToResolve = "cocoon:/" + uriToResolve;
161            }
162            
163            _requestAttributesHelper.removeRequestAttributes();
164            
165            return _sourceResolver.resolveURI(uriToResolve, null, resolveParameters);
166        }
167        catch (Exception e)
168        {
169            throw new IllegalArgumentException("Unable to resolve the dependencies of specified uri", e);
170        }
171    }
172    
173    private List<UriData> _getCssFileDependencies(String uri, Source cssSource) throws IllegalArgumentException
174    {
175        List<UriData> cssDependencies = new ArrayList<>();
176        
177        String fileContent;
178        try (InputStream is = cssSource.getInputStream())
179        {
180            fileContent = IOUtils.toString(is, StandardCharsets.UTF_8);
181        }
182        catch (IOException e)
183        {
184            getLogger().error("Unable to retrieve css file dependencies for '" + uri + "'", e);
185            return Collections.EMPTY_LIST;
186        }
187        
188        String contextPath = _proxiedContextPathProvider.getContextPath();
189        
190        Matcher urlMatcher = CSSFileHelper.IMPORT_PATTERN.matcher(fileContent);
191        
192        while (urlMatcher.find()) 
193        {
194            String cssUrl = urlMatcher.group(1);
195            
196            Matcher externalMatcher = __EXTERNAL_URL.matcher(cssUrl);
197            
198            if (!externalMatcher.find())
199            {
200                try
201                {
202                    cssUrl = StringUtils.removeStart(cssUrl, contextPath);
203                    URI cssUri = new URI(cssUrl);
204                    if (!cssUri.isAbsolute() && !cssUrl.startsWith("/"))
205                    {
206                        cssUri = new URI(FilenameUtils.getFullPath(uri) + cssUrl);
207                    }
208                    cssDependencies.addAll(_getFileDependencies(cssUri.normalize().toString(), null, false));
209                }
210                catch (URISyntaxException e)
211                {
212                    // Invalid URI inside a file, but should not be blocking
213                    getLogger().warn("Invalid URI in a file, could not calculate dependancies for file : " + uri , e);
214                }
215            }
216        }
217        
218        return cssDependencies;
219    }
220
221}