001/* 002 * (C) Copyright 2006-2015 Nuxeo SA (http://nuxeo.com/) and others. 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 * 016 * Contributors: 017 * Nuxeo - initial API and implementation 018 * bstefanescu, jcarsique 019 * Anahide Tchertchian 020 * 021 */ 022 023package org.nuxeo.common.utils; 024 025import static java.nio.charset.StandardCharsets.UTF_8; 026 027import java.io.File; 028import java.io.FileInputStream; 029import java.io.FileNotFoundException; 030import java.io.FileOutputStream; 031import java.io.FileWriter; 032import java.io.FilterWriter; 033import java.io.IOException; 034import java.io.InputStream; 035import java.io.OutputStream; 036import java.io.OutputStreamWriter; 037import java.io.Writer; 038import java.util.ArrayList; 039import java.util.HashMap; 040import java.util.List; 041import java.util.Map; 042import java.util.Properties; 043import java.util.StringTokenizer; 044import java.util.regex.Matcher; 045import java.util.regex.Pattern; 046 047import org.apache.commons.io.FileUtils; 048import org.apache.commons.io.IOUtils; 049import org.apache.commons.logging.Log; 050import org.apache.commons.logging.LogFactory; 051import org.nuxeo.common.codec.Crypto; 052import org.nuxeo.common.codec.CryptoProperties; 053 054import freemarker.template.Configuration; 055import freemarker.template.Template; 056import freemarker.template.TemplateException; 057 058/** 059 * Text template processing. 060 * <p> 061 * Copy files or directories replacing parameters matching pattern '${[a-zA-Z_0-9\-\.]+}' with values from a 062 * {@link CryptoProperties}. 063 * <p> 064 * If the value of a variable is encrypted: 065 * 066 * <pre> 067 * setVariable("var", Crypto.encrypt(value.getBytes)) 068 * </pre> 069 * 070 * then "<code>${var}</code>" will be replaced with: 071 * <ul> 072 * <li>its decrypted value by default: "<code>value</code>"</li> 073 * <li>"<code>${var}</code>" after a call to "<code>setKeepEncryptedAsVar(true)}</code>" 074 * </ul> 075 * and "<code>${#var}</code>" will always be replaced with its decrypted value. 076 * <p> 077 * Since 5.7.2, variables can have a default value using syntax ${parameter:=defaultValue}. The default value will be 078 * used if parameter is null or unset. 079 * <p> 080 * Methods {@link #setTextParsingExtensions(String)} and {@link #setFreemarkerParsingExtensions(String)} allow to set 081 * the list of files being processed when using {@link #processDirectory(File, File)}, based on their extension; others 082 * being simply copied. 083 * 084 * @author <a href="mailto:[email protected]">Bogdan Stefanescu</a> 085 * @see CryptoProperties 086 * @see #setKeepEncryptedAsVar(boolean) 087 * @see #setFreemarkerParsingExtensions(String) 088 * @see #setTextParsingExtensions(String) 089 */ 090public class TextTemplate { 091 092 private static final Log log = LogFactory.getLog(TextTemplate.class); 093 094 private static final int MAX_RECURSION_LEVEL = 10; 095 096 private static final String PATTERN_GROUP_DECRYPT = "decrypt"; 097 098 private static final String PATTERN_GROUP_VAR = "var"; 099 100 private static final String PATTERN_GROUP_DEFAULT = "default"; 101 102 /** 103 * matches variables of the form "${[#]embeddedVar[:=defaultValue]}" but not those starting with "$${" 104 */ 105 private static final Pattern PATTERN = Pattern.compile("(?<!\\$)\\$\\{(?<" + PATTERN_GROUP_DECRYPT + ">#)?" // 106 + "(?<" + PATTERN_GROUP_VAR + ">[a-zA-Z_0-9\\-\\.]+)" // embeddedVar 107 + "(:=(?<" + PATTERN_GROUP_DEFAULT + ">.*))?\\}"); // defaultValue 108 109 private final CryptoProperties vars; 110 111 private Properties processedVars; 112 113 private boolean trim = false; 114 115 private List<String> plainTextExtensions; 116 117 private List<String> freemarkerExtensions = new ArrayList<>(); 118 119 private Configuration freemarkerConfiguration = null; 120 121 private Map<String, Object> freemarkerVars = null; 122 123 private boolean keepEncryptedAsVar; 124 125 public boolean isTrim() { 126 return trim; 127 } 128 129 /** 130 * Set to true in order to trim invisible characters (spaces) from values. 131 */ 132 public void setTrim(boolean trim) { 133 this.trim = trim; 134 } 135 136 public TextTemplate() { 137 vars = new CryptoProperties(); 138 } 139 140 /** 141 * {@link #TextTemplate(Properties)} provides an additional default values behavior 142 * 143 * @see #TextTemplate(Properties) 144 */ 145 public TextTemplate(Map<String, String> vars) { 146 this.vars = new CryptoProperties(); 147 this.vars.putAll(vars); 148 } 149 150 /** 151 * @param vars Properties containing keys and values for template processing 152 */ 153 public TextTemplate(Properties vars) { 154 if (vars instanceof CryptoProperties) { 155 this.vars = (CryptoProperties) vars; 156 } else { 157 this.vars = new CryptoProperties(vars); 158 } 159 } 160 161 public void setVariables(Map<String, String> vars) { 162 this.vars.putAll(vars); 163 freemarkerConfiguration = null; 164 } 165 166 /** 167 * If adding multiple variables, prefer use of {@link #setVariables(Map)} 168 */ 169 public void setVariable(String name, String value) { 170 vars.setProperty(name, value); 171 freemarkerConfiguration = null; 172 } 173 174 public String getVariable(String name) { 175 return vars.getProperty(name, keepEncryptedAsVar); 176 } 177 178 public Properties getVariables() { 179 return vars; 180 } 181 182 /** 183 * That method is not recursive. It processes the given text only once. 184 * 185 * @param props CryptoProperties containing the variable values 186 * @param text Text to process 187 * @return the processed text 188 * @since 7.4 189 */ 190 protected String processString(CryptoProperties props, String text) { 191 Matcher m = PATTERN.matcher(text); 192 StringBuffer sb = new StringBuffer(); 193 while (m.find()) { 194 String embeddedVar = m.group(PATTERN_GROUP_VAR); 195 String value = props.getProperty(embeddedVar, keepEncryptedAsVar); 196 if (value == null) { 197 value = m.group(PATTERN_GROUP_DEFAULT); 198 } 199 if (value != null) { 200 if (trim) { 201 value = value.trim(); 202 } 203 if (Crypto.isEncrypted(value)) { 204 if (keepEncryptedAsVar && m.group(PATTERN_GROUP_DECRYPT) == null) { 205 value = "${" + embeddedVar + "}"; 206 } else { 207 value = new String(vars.getCrypto().decrypt(value)); 208 } 209 } 210 211 // Allow use of backslash and dollars characters 212 value = Matcher.quoteReplacement(value); 213 m.appendReplacement(sb, value); 214 } 215 } 216 m.appendTail(sb); 217 return sb.toString(); 218 } 219 220 /** 221 * unescape variables 222 */ 223 protected Properties unescape(Properties props) { 224 props.replaceAll((k, v) -> unescape((String) v)); 225 return props; 226 } 227 228 protected String unescape(String value) { 229 // unescape doubled $ characters, only if in front of a { 230 return value.replaceAll("\\$\\$\\{", "\\${"); 231 } 232 233 private void preprocessVars() { 234 processedVars = preprocessVars(vars); 235 } 236 237 public Properties preprocessVars(Properties unprocessedVars) { 238 CryptoProperties newVars = new CryptoProperties(unprocessedVars); 239 boolean doneProcessing = false; 240 int recursionLevel = 0; 241 while (!doneProcessing) { 242 doneProcessing = true; 243 for (String newVarsKey : newVars.stringPropertyNames()) { 244 String newVarsValue = newVars.getProperty(newVarsKey, keepEncryptedAsVar); 245 if (newVarsValue == null) { 246 continue; 247 } 248 if (Crypto.isEncrypted(newVarsValue)) { 249 // newVarsValue == {$[...]$...} 250 assert keepEncryptedAsVar; 251 newVarsValue = "${" + newVarsKey + "}"; 252 newVars.put(newVarsKey, newVarsValue); 253 continue; 254 } 255 256 String replacementValue = processString(newVars, newVarsValue); 257 if (!replacementValue.equals(newVarsValue)) { 258 doneProcessing = false; 259 newVars.put(newVarsKey, replacementValue); 260 } 261 } 262 recursionLevel++; 263 // Avoid infinite replacement loops 264 if (!doneProcessing && recursionLevel > MAX_RECURSION_LEVEL) { 265 log.warn("Detected potential infinite loop when processing the following properties\n" + newVars); 266 break; 267 } 268 } 269 return unescape(newVars); 270 } 271 272 /** 273 * @since 7.4 274 */ 275 public String processText(String text) { 276 if (text == null) { 277 return null; 278 } 279 boolean doneProcessing = false; 280 int recursionLevel = 0; 281 while (!doneProcessing) { 282 doneProcessing = true; 283 String processedText = processString(vars, text); 284 if (!processedText.equals(text)) { 285 doneProcessing = false; 286 text = processedText; 287 } 288 recursionLevel++; 289 // Avoid infinite replacement loops 290 if (!doneProcessing && recursionLevel > MAX_RECURSION_LEVEL) { 291 log.warn("Detected potential infinite loop when processing the following text\n" + text); 292 break; 293 } 294 } 295 return unescape(text); 296 } 297 298 public String processText(InputStream in) throws IOException { 299 String text = IOUtils.toString(in, UTF_8); 300 return processText(text); 301 } 302 303 public void processText(InputStream is, OutputStreamWriter os) throws IOException { 304 String text = IOUtils.toString(is, UTF_8); 305 text = processText(text); 306 os.write(text); 307 } 308 309 /** 310 * Initialize FreeMarker data model from Java properties. 311 * <p> 312 * Variables in the form "{@code foo.bar}" (String with dots) are transformed to "{@code foo[bar]}" (arrays).<br> 313 * So there will be conflicts if a variable name is equal to the prefix of another variable. For instance, " 314 * {@code foo.bar}" and "{@code foo.bar.qux}" will conflict.<br> 315 * When a conflict occurs, the conflicting variable is ignored and a warning is logged. The ignored variable will 316 * usually be the shortest one (without any contract on this behavior). 317 */ 318 @SuppressWarnings("unchecked") 319 public void initFreeMarker() { 320 freemarkerConfiguration = new Configuration(Configuration.getVersion()); 321 preprocessVars(); 322 freemarkerVars = new HashMap<>(); 323 Map<String, Object> currentMap; 324 String currentString; 325 KEYS: for (String key : processedVars.stringPropertyNames()) { 326 String value = processedVars.getProperty(key); 327 if (value.startsWith("${") && value.endsWith("}")) { 328 // crypted variables have to be decrypted in freemarker vars 329 value = vars.getProperty(key, false); 330 } 331 String[] keyparts = key.split("\\."); 332 currentMap = freemarkerVars; 333 currentString = ""; 334 for (int i = 0; i < keyparts.length - 1; i++) { 335 currentString = currentString + ("".equals(currentString) ? "" : ".") + keyparts[i]; 336 if (!currentMap.containsKey(keyparts[i])) { 337 Map<String, Object> nextMap = new HashMap<>(); 338 currentMap.put(keyparts[i], nextMap); 339 currentMap = nextMap; 340 } else if (currentMap.get(keyparts[i]) instanceof Map<?, ?>) { 341 currentMap = (Map<String, Object>) currentMap.get(keyparts[i]); 342 } else { 343 // silently ignore known conflicts between Java properties and FreeMarker model 344 if (!key.startsWith("java.vendor") && !key.startsWith("file.encoding") 345 && !key.startsWith("audit.elasticsearch")) { 346 log.warn(String.format("FreeMarker variables: ignored '%s' conflicting with '%s'", key, 347 currentString)); 348 } 349 continue KEYS; 350 } 351 } 352 if (!currentMap.containsKey(keyparts[keyparts.length - 1])) { 353 currentMap.put(keyparts[keyparts.length - 1], value); 354 } else if (!key.startsWith("java.vendor") && !key.startsWith("file.encoding") 355 && !key.startsWith("audit.elasticsearch")) { 356 Map<String, Object> currentValue = (Map<String, Object>) currentMap.get(keyparts[keyparts.length - 1]); 357 log.warn(String.format("FreeMarker variables: ignored '%2$s' conflicting with '%2$s.%1$s'", 358 currentValue.keySet(), key)); 359 } 360 } 361 } 362 363 public void processFreemarker(File in, File out) throws IOException, TemplateException { 364 if (freemarkerConfiguration == null) { 365 initFreeMarker(); 366 } 367 freemarkerConfiguration.setDirectoryForTemplateLoading(in.getParentFile()); 368 Template nxtpl = freemarkerConfiguration.getTemplate(in.getName()); 369 try (Writer writer = new EscapeVariableFilter(new FileWriter(out))) { 370 nxtpl.process(freemarkerVars, writer); 371 } 372 } 373 374 protected static class EscapeVariableFilter extends FilterWriter { 375 376 protected static final int DOLLAR_SIGN = "$".codePointAt(0); 377 378 protected int last; 379 380 public EscapeVariableFilter(Writer out) { 381 super(out); 382 } 383 384 public @Override void write(int b) throws IOException { 385 if (b == DOLLAR_SIGN && last == DOLLAR_SIGN) { 386 return; 387 } 388 last = b; 389 super.write(b); 390 } 391 392 @Override 393 public void write(char[] cbuf, int off, int len) throws IOException { 394 for (int i = 0; i < len; ++i) { 395 write(cbuf[off + i]); 396 } 397 } 398 399 @Override 400 public void write(char[] cbuf) throws IOException { 401 write(cbuf, 0, cbuf.length); 402 } 403 404 } 405 406 /** 407 * Recursively process each file from "in" directory to "out" directory. 408 * 409 * @param in Directory to read files from 410 * @param out Directory to write files to 411 * @return copied files list 412 * @see TextTemplate#processText(InputStream, OutputStreamWriter) 413 * @see TextTemplate#processFreemarker(File, File) 414 */ 415 public List<String> processDirectory(File in, File out) throws FileNotFoundException, IOException, 416 TemplateException { 417 List<String> newFiles = new ArrayList<>(); 418 if (in.isFile()) { 419 if (out.isDirectory()) { 420 out = new File(out, in.getName()); 421 } 422 if (!out.getParentFile().exists()) { 423 out.getParentFile().mkdirs(); 424 } 425 426 boolean processAsText = false; 427 boolean processAsFreemarker = false; 428 // Check for each extension if it matches end of filename 429 String filename = in.getName().toLowerCase(); 430 for (String ext : freemarkerExtensions) { 431 if (filename.endsWith(ext)) { 432 processAsFreemarker = true; 433 out = new File(out.getCanonicalPath().replaceAll("\\.*" + Pattern.quote(ext) + "$", "")); 434 if (filename.equals("." + ext.toLowerCase())) { 435 throw new IOException("Extension only as a filename is not allowed: " + in.getAbsolutePath()); 436 } 437 break; 438 } 439 } 440 if (!processAsFreemarker) { 441 for (String ext : plainTextExtensions) { 442 if (filename.endsWith(ext)) { 443 processAsText = true; 444 break; 445 } 446 } 447 } 448 449 // Backup existing file if not already done 450 if (out.exists()) { 451 File backup = new File(out.getPath() + ".bak"); 452 if (!backup.exists()) { 453 log.debug("Backup " + out); 454 FileUtils.copyFile(out, backup); 455 newFiles.add(backup.getPath()); 456 } 457 } else { 458 newFiles.add(out.getPath()); 459 } 460 try { 461 if (processAsFreemarker) { 462 log.debug("Process as FreeMarker " + in.getPath()); 463 processFreemarker(in, out); 464 } else if (processAsText) { 465 log.debug("Process as Text " + in.getPath()); 466 try (InputStream is = new FileInputStream(in); 467 OutputStreamWriter os = new OutputStreamWriter(new FileOutputStream(out), "UTF-8")) { 468 processText(is, os); 469 } 470 } else { 471 log.debug("Process as copy " + in.getPath()); 472 FileUtils.copyFile(in, out); 473 } 474 } catch (IOException | TemplateException e) { 475 log.error("Failure on " + in.getPath()); 476 throw e; 477 } 478 } else if (in.isDirectory()) { 479 if (!out.exists()) { 480 // allow renaming destination directory 481 out.mkdirs(); 482 } else if (!out.getName().equals(in.getName())) { 483 // allow copy over existing hierarchy 484 out = new File(out, in.getName()); 485 out.mkdir(); 486 } 487 for (File file : in.listFiles()) { 488 newFiles.addAll(processDirectory(file, out)); 489 } 490 } 491 return newFiles; 492 } 493 494 /** 495 * @param extensionsList comma-separated list of files extensions to parse 496 */ 497 public void setTextParsingExtensions(String extensionsList) { 498 StringTokenizer st = new StringTokenizer(extensionsList, ","); 499 plainTextExtensions = new ArrayList<>(); 500 while (st.hasMoreTokens()) { 501 String extension = st.nextToken().toLowerCase(); 502 plainTextExtensions.add(extension); 503 } 504 } 505 506 public void setFreemarkerParsingExtensions(String extensionsList) { 507 StringTokenizer st = new StringTokenizer(extensionsList, ","); 508 freemarkerExtensions = new ArrayList<>(); 509 while (st.hasMoreTokens()) { 510 String extension = st.nextToken().toLowerCase(); 511 freemarkerExtensions.add(extension); 512 } 513 } 514 515 /** 516 * Whether to replace or not the variables which value is encrypted. 517 * 518 * @param keepEncryptedAsVar if {@code true}, the variables which value is encrypted won't be expanded 519 * @since 7.4 520 */ 521 public void setKeepEncryptedAsVar(boolean keepEncryptedAsVar) { 522 if (this.keepEncryptedAsVar != keepEncryptedAsVar) { 523 this.keepEncryptedAsVar = keepEncryptedAsVar; 524 freemarkerConfiguration = null; 525 } 526 } 527 528}