001/**
002 * Copyright 2005-2018 The Kuali Foundation
003 *
004 * Licensed under the Educational Community 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.opensource.org/licenses/ecl2.php
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 */
016package org.kuali.rice.krad.uif.freemarker;
017
018import freemarker.core.Environment;
019import freemarker.template.TemplateDirectiveBody;
020import freemarker.template.TemplateDirectiveModel;
021import freemarker.template.TemplateException;
022import freemarker.template.TemplateModel;
023import freemarker.template.TemplateModelException;
024
025import java.io.IOException;
026import java.io.Writer;
027import java.util.Map;
028
029/**
030 * A custom FreeMarker directive that adds escapes to nested content to make it valid for enclosure within a JSON
031 * string.
032 *
033 * <p>In other words, the content that is generated within this tag should be able to be enclosed in quotes within
034 * a JSON document without breaking strict JSON parsers.  Note that this doesn't presently handle a wide variety of
035 * cases, just enough to properly escape basic html.</p>
036 *
037 * <p>
038 *     There are three types of replacements this performs:
039 *     <ul>
040 *         <li>the quote character '"' is prefixed with a backslash</li>
041 *         <li>newline characters are replaced with backslash followed by 'n'</li>
042 *         <li>carriage return characters are replaced with backslash followed by 'r'</li>
043 *     </ul>
044 * </p>
045 *
046 * @author Kuali Rice Team (rice.collab@kuali.org)
047 */
048public class JsonStringEscapeDirective implements TemplateDirectiveModel {
049
050    @Override
051    public void execute(Environment env, Map params, TemplateModel[] loopVars,
052            TemplateDirectiveBody body) throws TemplateException, IOException {
053        // Check if no parameters were given:
054        if (!params.isEmpty()) {
055            throw new TemplateModelException(
056                    getClass().getSimpleName() + " doesn't allow parameters.");
057        }
058        if (loopVars.length != 0) {
059                throw new TemplateModelException(
060                        getClass().getSimpleName() + " doesn't allow loop variables.");
061        }
062
063        // If there is non-empty nested content:
064        if (body != null) {
065            // Executes the nested body. Same as <#nested> in FTL, except
066            // that we use our own writer instead of the current output writer.
067            body.render(new JsonEscapingFilterWriter(env.getOut()));
068        } else {
069            throw new RuntimeException("missing body");
070        }
071    }
072
073    /**
074     * A {@link Writer} that does escaping of nested content to make it valid for enclosure within a JSON string.
075     */
076    private static class JsonEscapingFilterWriter extends Writer {
077
078        private final Writer out;
079
080        /**
081         * Constructs a JsonEscapingFilterWriter which decorates the passed in Writer
082         *
083         * @param out the Writer to decorate
084         */
085        JsonEscapingFilterWriter(Writer out) {
086            this.out = out;
087        }
088
089        @Override
090        public void write(char[] cbuf, int off, int len) throws IOException {
091
092            // We need to allocate a buffer big enough to hold the escapes too, which take up extra chars
093            int needsEscapingCount = 0; // count up how many chars needing escapes are in the buffer
094
095            for (int i=0; i<len; i++) {
096                if (isNeedsEscaping(cbuf[i + off])) { needsEscapingCount += 1; }
097            }
098
099            char[] transformedCbuf = new char[len + needsEscapingCount]; // allocate additional space for escapes
100            int escapesAddedCount = 0; // the count of how many chars we've had to escape so far
101
102            for (int i = 0; i < len; i++) {
103                if (isNeedsEscaping(cbuf[i + off])) {
104                    transformedCbuf[i+escapesAddedCount] = '\\';
105                    escapesAddedCount += 1;
106                }
107
108                if (cbuf[i + off] == '\n') {
109                    // newlines need to be replaced with literal "\n" <-- two chars
110                    transformedCbuf[i+escapesAddedCount] = 'n';
111                } else if (cbuf[i + off] == '\r') {
112                    // carriage returns need to be replaced with literal "\r" <-- two chars
113                    transformedCbuf[i+escapesAddedCount] = 'r';
114                } else {
115                    // standard escaping where we still use the original char
116                    transformedCbuf[i+escapesAddedCount] = cbuf[i + off];
117                }
118            }
119
120            out.write(transformedCbuf);
121        }
122
123        @Override
124        public void flush() throws IOException {
125            out.flush();
126        }
127
128        @Override
129        public void close() throws IOException {
130            out.close();
131        }
132
133        /**
134         * Does the given character need escaping to be rendered as part of a JSON string?
135         *
136         * @param c the character to test
137         * @return true if the character needs escaping for inclusion in a JSON string.
138         */
139        private static boolean isNeedsEscaping(char c) {
140            return (c == '"' || c == '\n' || c == '\r');
141        }
142    }
143}