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}