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(&quot;var&quot;, 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:bs@nuxeo.com">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}