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