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.indexOf("/") != 0)
192                {
193                    String realSourceUri = _proxiedContextPathProvider.getContextPath() + new URI(StringUtils.substringBeforeLast(uri, "/") + "/" + source).normalize().toString();
194                    sourcesMatcher.appendReplacement(sb, Matcher.quoteReplacement(sourcesMatcher.group(0).replace(source, realSourceUri)));
195                }
196            }
197            sourcesMatcher.appendTail(sb);
198            
199            sourceMapContent = sourceMapContent.replace(originalSources, sb.toString());
200        }
201        return sourceMapContent;
202    }
203    
204    /**
205     * Validate a source, fix it if required, and output the result to the output stream.
206     * @param source The source
207     * @param out The output
208     * @param sourceUri The source uri
209     * @throws IOException If an error occurred while reading the source
210     */
211    public void validateAndOutputMinimizedFile(Source source, OutputStream out, String sourceUri) throws IOException
212    {
213        String fileContent;
214        try (InputStream is = source.getInputStream())
215        {
216            fileContent = IOUtils.toString(is, StandardCharsets.UTF_8);
217        }
218        
219        String[] lines = fileContent.split("\r\n|\r|\n", -1);
220        int lineCount = lines.length;
221        
222        while (lineCount > 0 && StringUtils.isEmpty(lines[lineCount - 1]))
223        {
224            lineCount--;
225        }
226        
227        // files with a sourceMappingURL should end with an empty line after
228        String lastLine = lineCount > 0 ? lines[lineCount - 1] : null;
229        
230        if (isSourceMappingURLLine(lastLine))
231        {
232            String mapURL = getSourceMappingURL(lastLine);
233            String uriToResolve = sourceUri.indexOf('/') > -1 ? sourceUri.substring(0, sourceUri.lastIndexOf("/") + 1) + mapURL : mapURL;
234            
235            Source mapSource = null;
236            try
237            {
238                mapSource = _resolver.resolveURI(uriToResolve);
239            }
240            catch (IOException e)
241            {
242                // Nothing
243            }
244            
245            if (mapSource == null || !mapSource.exists())
246            {
247                fileContent = removeSourceMappingURLLine(fileContent);
248            }
249        }
250
251        out.write(fileContent.getBytes(StandardCharsets.UTF_8));
252    }
253
254    /**
255     * Test if the line contains a source mapping URL
256     * @param line The line
257     * @return True if a source mapping url is found
258     */
259    protected abstract boolean isSourceMappingURLLine(String line);
260    
261    /**
262     * Get the source mapping URL value from the line
263     * @param line The line
264     * @return The source mapping URL
265     */
266    protected abstract String getSourceMappingURL(String line);
267    
268    /**
269     * Remove the source mapping url from the content
270     * @param content The content
271     * @return The content without the mention of the source mapping URL
272     */
273    protected abstract String removeSourceMappingURLLine(String content);
274    
275    /**
276     * Format a source mapping URL to be added at the end of a minimized file
277     * @param sourceMapName The map name 
278     * @return The source mapping URL line
279     */
280    protected abstract String formatSourceMappingURL(String sourceMapName);
281    
282    /**
283     * Get the minimized content at the specified URI
284     * @param uri The uri 
285     * @param nestedParentFilesName The parents file name, can be an empty string if there are no parents
286     * @return The minimized content of the specified URI
287     */
288    protected abstract String getMinimizedContent(String uri, String nestedParentFilesName);
289    
290    /**
291     * Aggregate the source map of the single file with the others 
292     * @param sourceMapGenerator The aggregator helper
293     * @param lineCount The current line count
294     * @param fileContent The content of the file
295     * @param fileUri The uri of the file
296     * @param sourceMapUri The sourceMappingURL found at the end of the file content
297     */
298    protected abstract void addSourceMap(SourceMapGeneratorV3 sourceMapGenerator, int lineCount, String fileContent, String fileUri, String sourceMapUri);
299    
300}