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}