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.core.minimize;
017
018import java.io.IOException;
019import java.io.InputStream;
020import java.io.OutputStream;
021import java.net.URI;
022import java.net.URISyntaxException;
023import java.nio.charset.StandardCharsets;
024import java.util.List;
025import java.util.regex.Matcher;
026import java.util.regex.Pattern;
027
028import org.apache.avalon.framework.service.ServiceException;
029import org.apache.avalon.framework.service.ServiceManager;
030import org.apache.avalon.framework.service.Serviceable;
031import org.apache.commons.io.IOUtils;
032import org.apache.commons.lang3.StringUtils;
033import org.apache.excalibur.source.Source;
034import org.apache.excalibur.source.SourceResolver;
035
036import org.ametys.core.resources.ProxiedContextPathProvider;
037import org.ametys.plugins.core.ui.minimize.HashCache.UriData;
038import org.ametys.runtime.plugin.component.AbstractLogEnabled;
039
040import com.google.debugging.sourcemap.SourceMapFormat;
041import com.google.debugging.sourcemap.SourceMapGeneratorFactory;
042import com.google.debugging.sourcemap.SourceMapGeneratorV3;
043
044/**
045 * Abstract minimize manager for js and css
046 */
047public abstract class AbstractMinimizeManager extends AbstractLogEnabled implements Serviceable
048{
049    // regex that matches the "sources" property of a sourcemap
050    private static final Pattern __SOURCEMAP_SOURCE_NAME = Pattern.compile("\\s*\"sources\"\\s*:" // Matches the literal '"file" :' with any whitespace
051                                                                         + "\\s*"
052                                                                         + "\\[\\s*" // Matches the bracket that starts the list of sources
053                                                                         + "([^\\]]+)" // Captures the sources
054                                                                         + "\\]", // Matches the closing bracket at the end of sources 
055                                                                         Pattern.MULTILINE | Pattern.DOTALL);
056    
057    // Captures a string in between double quotes
058    private static final Pattern SOURCE_MAP_SOURCE = Pattern.compile("\"([^\"]+)\"");
059    
060    /** The source map cache component */
061    protected SourceMapCache _sourceMapCache;
062
063    /** The proxied context path provider */
064    protected ProxiedContextPathProvider _proxiedContextPathProvider;
065
066    /** Ametys source resolver */
067    protected SourceResolver _resolver;
068
069    @Override
070    public void service(ServiceManager smanager) throws ServiceException
071    {
072        _sourceMapCache = (SourceMapCache) smanager.lookup(SourceMapCache.ROLE);
073        _proxiedContextPathProvider = (ProxiedContextPathProvider) smanager.lookup(ProxiedContextPathProvider.ROLE);
074        _resolver = (SourceResolver) smanager.lookup(SourceResolver.ROLE);
075    }
076    
077    /**
078     * Compile a list of css URI, store the generated source map and returns the minimized concatenated result
079     * @param uris The URIs to compile, e.g a list of mixed .min.css and .css URIs
080     * @param fileName The name of the result without extension, e.g. HASH
081     * @param generateSourceMap True to generate the source map
082     * @return The minimized file
083     */
084    public String minimizeAndAggregateURIs(List<UriData> uris, String fileName, boolean generateSourceMap)
085    {
086        SourceMapGeneratorV3 sourceMapGenerator = generateSourceMap ? (SourceMapGeneratorV3) SourceMapGeneratorFactory.getInstance(SourceMapFormat.V3) : null;
087        int totalLineCount = 0;
088        
089        StringBuffer sb = new StringBuffer();
090        for (UriData uri : uris)
091        {
092            String content = getMinimizedContent(uri.getUri(), "");
093            if (!content.endsWith("\n") && !content.endsWith("\n\r"))
094            {
095                content += "\n";
096            }
097
098            String[] lines = content.split("\r\n|\r|\n", -1);
099            int lineCount = lines.length;
100            
101            while (lineCount > 0 && StringUtils.isEmpty(lines[lineCount - 1]))
102            {
103                lineCount--;
104            }
105            
106            boolean hasMedia = StringUtils.isNotEmpty(uri.getMedia());
107            if (hasMedia)
108            {
109                totalLineCount += 1; // offset source map by one to let the line for the media
110            }
111            
112            // files with a sourceMappingURL should end with an empty line after
113            String lastLine = lineCount > 0 ? lines[lineCount - 1] : null;
114            if (isSourceMappingURLLine(lastLine)) 
115            {
116                String mapURL = getSourceMappingURL(lastLine);
117                content = removeSourceMappingURLLine(content);
118                if (generateSourceMap)
119                {
120                    addSourceMap(sourceMapGenerator, totalLineCount, content, uri.getUri(), mapURL);
121                }
122                lineCount--; // last line of current file and first line of next file are on the same merged line
123            }
124            else if (lastLine != null && lines.length > lineCount)
125            {
126                // Trim empty lines at the end of the file
127                content = content.substring(0, content.lastIndexOf(lastLine) + lastLine.length()).trim() + "\n";
128            }
129            
130            totalLineCount += lineCount;
131            sb.append(hasMedia ? applyMediaToContent(content, uri.getMedia()) : content);
132        }
133        
134        _generateSourceMap(sourceMapGenerator, sb, fileName, fileName + ".map", generateSourceMap);
135        
136        return sb.toString();
137    }
138    
139    /**
140     * Defaut implementation to apply a media to a content.
141     * @param content The content
142     * @param media The media
143     * @return The content with the media
144     */
145    protected String applyMediaToContent(String content, String media)
146    {
147        return "\n" + content; // do nothing, but add an empty line for consistency
148    }
149
150    private void _generateSourceMap(SourceMapGeneratorV3 sourceMapGenerator, StringBuffer sb, String fileName, String sourceMapName, boolean generateSourceMap)
151    {
152        try
153        {
154            if (generateSourceMap)
155            {
156                StringBuilder sbMap = new StringBuilder();
157                sourceMapGenerator.appendTo(sbMap, fileName);
158                _sourceMapCache.put(sourceMapName, sbMap.toString(), (long) 0);
159            }
160
161            sb.append("\n" + formatSourceMappingURL(sourceMapName));
162        }
163        catch (IOException e)
164        {
165            getLogger().error("Unable to create final source map for minimized file", e);
166        }
167    }
168    
169    /**
170     * Convert the source map "sources" attribute by correcting the path of those values
171     * @param content The source map content
172     * @param uri The URI (without context path)
173     * @return The converted source map
174     * @throws URISyntaxException If an error occurred
175     */
176    protected String convertSourceMapURIs(String content, String uri) throws URISyntaxException
177    {
178        String sourceMapContent = content;
179        
180        Matcher matcher = __SOURCEMAP_SOURCE_NAME.matcher(sourceMapContent);
181        if (matcher.find())
182        {
183            String originalSources = matcher.group(1);
184            
185            // Parse each source and transform to absolute path if necessary
186            StringBuffer sb = new StringBuffer();
187            Matcher sourcesMatcher = SOURCE_MAP_SOURCE.matcher(originalSources);
188            while (sourcesMatcher.find())
189            {
190                String source = sourcesMatcher.group(1);
191                if (source.equals("unused"))
192                {
193                    sourcesMatcher.appendReplacement(sb, "\"\"");
194                }
195                else if (source.startsWith("sources-unavailable://"))
196                {
197                    // keep as is
198                    sourcesMatcher.appendReplacement(sb, "\"" + source + "\"");
199                }
200                else if (source.indexOf("/") != 0)
201                {
202                    String realSourceUri = _proxiedContextPathProvider.getContextPath() + new URI(StringUtils.substringBeforeLast(uri, "/") + "/" + source).normalize().toString();
203                    sourcesMatcher.appendReplacement(sb, Matcher.quoteReplacement(sourcesMatcher.group(0).replace(source, realSourceUri)));
204                }
205            }
206            sourcesMatcher.appendTail(sb);
207            
208            sourceMapContent = sourceMapContent.replace(originalSources, sb.toString());
209        }
210        return sourceMapContent;
211    }
212    
213    /**
214     * Validate a source, fix it if required, and output the result to the output stream.
215     * @param source The source
216     * @param out The output
217     * @param sourceUri The source uri
218     * @throws IOException If an error occurred while reading the source
219     */
220    public void validateAndOutputMinimizedFile(Source source, OutputStream out, String sourceUri) throws IOException
221    {
222        String fileContent;
223        try (InputStream is = source.getInputStream())
224        {
225            fileContent = IOUtils.toString(is, StandardCharsets.UTF_8);
226        }
227        
228        String[] lines = fileContent.split("\r\n|\r|\n", -1);
229        int lineCount = lines.length;
230        
231        while (lineCount > 0 && StringUtils.isEmpty(lines[lineCount - 1]))
232        {
233            lineCount--;
234        }
235        
236        // files with a sourceMappingURL should end with an empty line after
237        String lastLine = lineCount > 0 ? lines[lineCount - 1] : null;
238        
239        if (isSourceMappingURLLine(lastLine))
240        {
241            String mapURL = getSourceMappingURL(lastLine);
242            String uriToResolve = sourceUri.indexOf('/') > -1 ? sourceUri.substring(0, sourceUri.lastIndexOf("/") + 1) + mapURL : mapURL;
243            
244            Source mapSource = null;
245            try
246            {
247                mapSource = _resolver.resolveURI(uriToResolve);
248            }
249            catch (IOException e)
250            {
251                // Nothing
252            }
253            
254            if (mapSource == null || !mapSource.exists())
255            {
256                fileContent = removeSourceMappingURLLine(fileContent);
257            }
258        }
259
260        out.write(fileContent.getBytes(StandardCharsets.UTF_8));
261    }
262
263    /**
264     * Test if the line contains a source mapping URL
265     * @param line The line
266     * @return True if a source mapping url is found
267     */
268    protected abstract boolean isSourceMappingURLLine(String line);
269    
270    /**
271     * Get the source mapping URL value from the line
272     * @param line The line
273     * @return The source mapping URL
274     */
275    protected abstract String getSourceMappingURL(String line);
276    
277    /**
278     * Remove the source mapping url from the content
279     * @param content The content
280     * @return The content without the mention of the source mapping URL
281     */
282    protected abstract String removeSourceMappingURLLine(String content);
283    
284    /**
285     * Format a source mapping URL to be added at the end of a minimized file
286     * @param sourceMapName The map name 
287     * @return The source mapping URL line
288     */
289    protected abstract String formatSourceMappingURL(String sourceMapName);
290    
291    /**
292     * Get the minimized content at the specified URI
293     * @param uri The uri 
294     * @param nestedParentFilesName The parents file name, can be an empty string if there are no parents
295     * @return The minimized content of the specified URI
296     */
297    protected abstract String getMinimizedContent(String uri, String nestedParentFilesName);
298    
299    /**
300     * Aggregate the source map of the single file with the others 
301     * @param sourceMapGenerator The aggregator helper
302     * @param lineCount The current line count
303     * @param fileContent The content of the file
304     * @param fileUri The uri of the file
305     * @param sourceMapUri The sourceMappingURL found at the end of the file content
306     */
307    protected abstract void addSourceMap(SourceMapGeneratorV3 sourceMapGenerator, int lineCount, String fileContent, String fileUri, String sourceMapUri);
308    
309}