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