/*
* (C) Copyright 2006-2015 Nuxeo SA (http://nuxeo.com/) and others.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* Contributors:
* Nuxeo - initial API and implementation
* bstefanescu, jcarsique
* Anahide Tchertchian
*
*/
package org.nuxeo.common.utils;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.FileWriter;
import java.io.FilterWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Writer;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.StringTokenizer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.io.Charsets;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.nuxeo.common.codec.Crypto;
import org.nuxeo.common.codec.CryptoProperties;
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateException;
/**
* Text template processing.
* <p>
* Copy files or directories replacing parameters matching pattern '${[a-zA-Z_0-9\-\.]+}' with values from a
* {@link CryptoProperties}.
* <p>
* If the value of a variable is encrypted:
*
* <pre>
* setVariable("var", Crypto.encrypt(value.getBytes))
* </pre>
*
* then "<code>${var}</code>" will be replaced with:
* <ul>
* <li>its decrypted value by default: "<code>value</code>"</li>
* <li>"<code>${var}</code>" after a call to "<code>setKeepEncryptedAsVar(true)}</code>"
* </ul>
* and "<code>${#var}</code>" will always be replaced with its decrypted value.
* <p>
* Since 5.7.2, variables can have a default value using syntax ${parameter:=defaultValue}. The default value will be
* used if parameter is null or unset.
* <p>
* Methods {@link #setTextParsingExtensions(String)} and {@link #setFreemarkerParsingExtensions(String)} allow to set
* the list of files being processed when using {@link #processDirectory(File, File)}, based on their extension; others
* being simply copied.
*
* @author <a href="mailto:bs@nuxeo.com">Bogdan Stefanescu</a>
* @see CryptoProperties
* @see #setKeepEncryptedAsVar(boolean)
* @see #setFreemarkerParsingExtensions(String)
* @see #setTextParsingExtensions(String)
*/
public class TextTemplate {
private static final Log log = LogFactory.getLog(TextTemplate.class);
private static final int MAX_RECURSION_LEVEL = 10;
private static final String PATTERN_GROUP_DECRYPT = "decrypt";
private static final String PATTERN_GROUP_VAR = "var";
private static final String PATTERN_GROUP_DEFAULT = "default";
/**
* matches variables of the form "${[#]embeddedVar[:=defaultValue]}" but not those starting with "$${"
*/
private static final Pattern PATTERN = Pattern.compile("(?<!\\$)\\$\\{(?<" + PATTERN_GROUP_DECRYPT + ">#)?" //
+ "(?<" + PATTERN_GROUP_VAR + ">[a-zA-Z_0-9\\-\\.]+)" // embeddedVar
+ "(:=(?<" + PATTERN_GROUP_DEFAULT + ">.*))?\\}"); // defaultValue
private final CryptoProperties vars;
private Properties processedVars;
private boolean trim = false;
private List<String> plainTextExtensions;
private List<String> freemarkerExtensions = new ArrayList<>();
private Configuration freemarkerConfiguration = null;
private Map<String, Object> freemarkerVars = null;
private boolean keepEncryptedAsVar;
public boolean isTrim() {
return trim;
}
/**
* Set to true in order to trim invisible characters (spaces) from values.
*/
public void setTrim(boolean trim) {
this.trim = trim;
}
public TextTemplate() {
vars = new CryptoProperties();
}
/**
* {@link #TextTemplate(Properties)} provides an additional default values behavior
*
* @see #TextTemplate(Properties)
*/
public TextTemplate(Map<String, String> vars) {
this.vars = new CryptoProperties();
this.vars.putAll(vars);
}
/**
* @param vars Properties containing keys and values for template processing
*/
public TextTemplate(Properties vars) {
if (vars instanceof CryptoProperties) {
this.vars = (CryptoProperties) vars;
} else {
this.vars = new CryptoProperties(vars);
}
}
public void setVariables(Map<String, String> vars) {
this.vars.putAll(vars);
freemarkerConfiguration = null;
}
/**
* If adding multiple variables, prefer use of {@link #setVariables(Map)}
*/
public void setVariable(String name, String value) {
vars.setProperty(name, value);
freemarkerConfiguration = null;
}
public String getVariable(String name) {
return vars.getProperty(name, keepEncryptedAsVar);
}
public Properties getVariables() {
return vars;
}
/**
* @deprecated Since 7.4. Use {@link #processText(CharSequence)} instead.
*/
@Deprecated
public String process(CharSequence text) {
return processText(text);
}
/**
* @deprecated Since 7.4. Use {@link #processText(InputStream)} instead.
*/
@Deprecated
public String process(InputStream in) throws IOException {
return processText(in);
}
/**
* @deprecated Since 7.4. Use {@link #processText(InputStream, OutputStream)} instead.
*/
@Deprecated
public void process(InputStream in, OutputStream out) throws IOException {
processText(in, out);
}
/**
* @param processText if true, text is processed for parameters replacement
* @deprecated Since 7.4. Use {@link #processText(InputStream, OutputStream)} (if {@code processText}) or
* {@link IOUtils#copy(InputStream, OutputStream)}
*/
@Deprecated
public void process(InputStream is, OutputStream os, boolean processText) throws IOException {
if (processText) {
String text = IOUtils.toString(is, Charsets.UTF_8);
text = processText(text);
os.write(text.getBytes());
} else {
IOUtils.copy(is, os);
}
}
/**
* That method is not recursive. It processes the given text only once.
*
* @param props CryptoProperties containing the variable values
* @param text Text to process
* @return the processed text
* @since 7.4
*/
protected String processString(CryptoProperties props, String text) {
Matcher m = PATTERN.matcher(text);
StringBuffer sb = new StringBuffer();
while (m.find()) {
// newVarsValue == ${[#]embeddedVar[:=default]}
String embeddedVar = m.group(PATTERN_GROUP_VAR);
String value = props.getProperty(embeddedVar, keepEncryptedAsVar);
if (value == null) {
value = m.group(PATTERN_GROUP_DEFAULT);
}
if (value != null) {
if (trim) {
value = value.trim();
}
if (Crypto.isEncrypted(value)) {
if (keepEncryptedAsVar && m.group(PATTERN_GROUP_DECRYPT) == null) {
value = "${" + embeddedVar + "}";
} else {
value = new String(vars.getCrypto().decrypt(value));
}
}
// Allow use of backslash and dollars characters
value = Matcher.quoteReplacement(value);
m.appendReplacement(sb, value);
}
}
m.appendTail(sb);
return sb.toString();
}
/**
* unescape variables
*/
protected Properties unescape(Properties props) {
for (Object key : props.keySet()) {
props.put(key, unescape((String) props.get(key)));
}
return props;
}
protected String unescape(String value) {
// unescape doubled $ characters, only if in front of a {
return value.replaceAll("\\$\\$\\{", "\\${");
}
private void preprocessVars() {
processedVars = preprocessVars(vars);
}
public Properties preprocessVars(Properties unprocessedVars) {
CryptoProperties newVars = new CryptoProperties(unprocessedVars);
boolean doneProcessing = false;
int recursionLevel = 0;
while (!doneProcessing) {
doneProcessing = true;
for (String newVarsKey : newVars.stringPropertyNames()) {
String newVarsValue = newVars.getProperty(newVarsKey, keepEncryptedAsVar);
if (newVarsValue == null) {
continue;
}
if (Crypto.isEncrypted(newVarsValue)) {
// newVarsValue == {$[...]$...}
assert (keepEncryptedAsVar);
newVarsValue = "${" + newVarsKey + "}";
newVars.put(newVarsKey, newVarsValue);
continue;
}
String replacementValue = processString(newVars, newVarsValue);
if (!replacementValue.equals(newVarsValue)) {
doneProcessing = false;
newVars.put(newVarsKey, replacementValue);
}
}
recursionLevel++;
// Avoid infinite replacement loops
if ((!doneProcessing) && (recursionLevel > MAX_RECURSION_LEVEL)) {
log.warn("Detected potential infinite loop when processing the following properties\n" + newVars);
break;
}
}
return unescape(newVars);
}
/**
* @deprecated Since 7.4. Use {@link #processText(String)}
*/
@Deprecated
public String processText(CharSequence text) {
return processText(text.toString());
}
/**
* @since 7.4
*/
public String processText(String text) {
if (text == null) {
return null;
}
boolean doneProcessing = false;
int recursionLevel = 0;
while (!doneProcessing) {
doneProcessing = true;
String processedText = processString(vars, text);
if (!processedText.equals(text)) {
doneProcessing = false;
text = processedText;
}
recursionLevel++;
// Avoid infinite replacement loops
if ((!doneProcessing) && (recursionLevel > MAX_RECURSION_LEVEL)) {
log.warn("Detected potential infinite loop when processing the following text\n" + text);
break;
}
}
return unescape(text);
}
public String processText(InputStream in) throws IOException {
String text = IOUtils.toString(in, Charsets.UTF_8);
return processText(text);
}
public void processText(InputStream is, OutputStream os) throws IOException {
String text = IOUtils.toString(is, Charsets.UTF_8);
text = processText(text);
os.write(text.getBytes(Charsets.UTF_8));
}
/**
* Initialize FreeMarker data model from Java properties.
* <p>
* Variables in the form "{@code foo.bar}" (String with dots) are transformed to "{@code foo[bar]}" (arrays).<br>
* So there will be conflicts if a variable name is equal to the prefix of another variable. For instance, "
* {@code foo.bar}" and "{@code foo.bar.qux}" will conflict.<br>
* When a conflict occurs, the conflicting variable is ignored and a warning is logged. The ignored variable will
* usually be the shortest one (without any contract on this behavior).
*/
@SuppressWarnings("unchecked")
public void initFreeMarker() {
freemarkerConfiguration = new Configuration(Configuration.getVersion());
preprocessVars();
freemarkerVars = new HashMap<>();
Map<String, Object> currentMap;
String currentString;
KEYS: for (String key : processedVars.stringPropertyNames()) {
String value = processedVars.getProperty(key);
String[] keyparts = key.split("\\.");
currentMap = freemarkerVars;
currentString = "";
for (int i = 0; i < (keyparts.length - 1); i++) {
currentString = currentString + ("".equals(currentString) ? "" : ".") + keyparts[i];
if (!currentMap.containsKey(keyparts[i])) {
Map<String, Object> nextMap = new HashMap<>();
currentMap.put(keyparts[i], nextMap);
currentMap = nextMap;
} else if (currentMap.get(keyparts[i]) instanceof Map<?, ?>) {
currentMap = (Map<String, Object>) currentMap.get(keyparts[i]);
} else {
// silently ignore known conflicts between Java properties and FreeMarker model
if (!key.startsWith("java.vendor") && !key.startsWith("file.encoding")
&& !key.startsWith("audit.elasticsearch")) {
log.warn(String.format("FreeMarker variables: ignored '%s' conflicting with '%s'", key,
currentString));
}
continue KEYS;
}
}
if (!currentMap.containsKey(keyparts[keyparts.length - 1])) {
currentMap.put(keyparts[keyparts.length - 1], value);
} else if (!key.startsWith("java.vendor") && !key.startsWith("file.encoding")
&& !key.startsWith("audit.elasticsearch")) {
Map<String, Object> currentValue = (Map<String, Object>) currentMap.get(keyparts[keyparts.length - 1]);
log.warn(String.format("FreeMarker variables: ignored '%2$s' conflicting with '%2$s.%1$s'",
currentValue.keySet(), key));
}
}
}
public void processFreemarker(File in, File out) throws IOException, TemplateException {
if (freemarkerConfiguration == null) {
initFreeMarker();
}
freemarkerConfiguration.setDirectoryForTemplateLoading(in.getParentFile());
Template nxtpl = freemarkerConfiguration.getTemplate(in.getName());
try (Writer writer = new EscapeVariableFilter(new FileWriter(out))) {
nxtpl.process(freemarkerVars, writer);
}
}
protected static class EscapeVariableFilter extends FilterWriter {
protected static final int DOLLAR_SIGN = "$".codePointAt(0);
protected int last;
public EscapeVariableFilter(Writer out) {
super(out);
}
public @Override void write(int b) throws IOException {
if (b == DOLLAR_SIGN && last == DOLLAR_SIGN) {
return;
}
last = b;
super.write(b);
}
@Override
public void write(char[] cbuf, int off, int len) throws IOException {
for (int i = 0; i < len; ++i) {
write(cbuf[off + i]);
}
}
@Override
public void write(char[] cbuf) throws IOException {
write(cbuf, 0, cbuf.length);
}
}
/**
* Recursively process each file from "in" directory to "out" directory.
*
* @param in Directory to read files from
* @param out Directory to write files to
* @return copied files list
* @see TextTemplate#processText(InputStream, OutputStream)
* @see TextTemplate#processFreemarker(File, File)
*/
public List<String> processDirectory(File in, File out) throws FileNotFoundException, IOException,
TemplateException {
List<String> newFiles = new ArrayList<>();
if (in.isFile()) {
if (out.isDirectory()) {
out = new File(out, in.getName());
}
if (!out.getParentFile().exists()) {
out.getParentFile().mkdirs();
}
boolean processAsText = false;
boolean processAsFreemarker = false;
// Check for each extension if it matches end of filename
String filename = in.getName().toLowerCase();
for (String ext : freemarkerExtensions) {
if (filename.endsWith(ext)) {
processAsFreemarker = true;
out = new File(out.getCanonicalPath().replaceAll("\\.*" + Pattern.quote(ext) + "$", ""));
break;
}
}
if (!processAsFreemarker) {
for (String ext : plainTextExtensions) {
if (filename.endsWith(ext)) {
processAsText = true;
break;
}
}
}
// Backup existing file if not already done
if (out.exists()) {
File backup = new File(out.getPath() + ".bak");
if (!backup.exists()) {
log.debug("Backup " + out);
FileUtils.copyFile(out, backup);
newFiles.add(backup.getPath());
}
} else {
newFiles.add(out.getPath());
}
try {
if (processAsFreemarker) {
log.debug("Process as FreeMarker " + in.getPath());
processFreemarker(in, out);
} else if (processAsText) {
log.debug("Process as Text " + in.getPath());
InputStream is = null;
OutputStream os = null;
try {
is = new FileInputStream(in);
os = new FileOutputStream(out);
processText(is, os);
} finally {
IOUtils.closeQuietly(is);
IOUtils.closeQuietly(os);
}
} else {
log.debug("Process as copy " + in.getPath());
FileUtils.copyFile(in, out);
}
} catch (IOException | TemplateException e) {
log.error("Failure on " + in.getPath());
throw e;
}
} else if (in.isDirectory()) {
if (!out.exists()) {
// allow renaming destination directory
out.mkdirs();
} else if (!out.getName().equals(in.getName())) {
// allow copy over existing hierarchy
out = new File(out, in.getName());
out.mkdir();
}
for (File file : in.listFiles()) {
newFiles.addAll(processDirectory(file, out));
}
}
return newFiles;
}
/**
* @param extensionsList comma-separated list of files extensions to parse
* @deprecated Since 7.4. Use {@link #setTextParsingExtensions(String)} instead.
* @see #setTextParsingExtensions(String)
* @see #setFreemarkerParsingExtensions(String)
*/
@Deprecated
public void setParsingExtensions(String extensionsList) {
setTextParsingExtensions(extensionsList);
}
/**
* @param extensionsList comma-separated list of files extensions to parse
*/
public void setTextParsingExtensions(String extensionsList) {
StringTokenizer st = new StringTokenizer(extensionsList, ",");
plainTextExtensions = new ArrayList<>();
while (st.hasMoreTokens()) {
String extension = st.nextToken().toLowerCase();
plainTextExtensions.add(extension);
}
}
public void setFreemarkerParsingExtensions(String extensionsList) {
StringTokenizer st = new StringTokenizer(extensionsList, ",");
freemarkerExtensions = new ArrayList<>();
while (st.hasMoreTokens()) {
String extension = st.nextToken().toLowerCase();
freemarkerExtensions.add(extension);
}
}
/**
* Whether to replace or not the variables which value is encrypted.
*
* @param keepEncryptedAsVar if {@code true}, the variables which value is encrypted won't be expanded
* @since 7.4
*/
public void setKeepEncryptedAsVar(boolean keepEncryptedAsVar) {
if (this.keepEncryptedAsVar != keepEncryptedAsVar) {
this.keepEncryptedAsVar = keepEncryptedAsVar;
freemarkerConfiguration = null;
}
}
}