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(&quot;var&quot;, 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}