http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/b0e08ccb/src/main/java/org/apache/freemarker/core/templateresolver/impl/DefaultTemplateLookupStrategy.java ---------------------------------------------------------------------- diff --git a/src/main/java/org/apache/freemarker/core/templateresolver/impl/DefaultTemplateLookupStrategy.java b/src/main/java/org/apache/freemarker/core/templateresolver/impl/DefaultTemplateLookupStrategy.java new file mode 100644 index 0000000..af197ef --- /dev/null +++ b/src/main/java/org/apache/freemarker/core/templateresolver/impl/DefaultTemplateLookupStrategy.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.freemarker.core.templateresolver.impl; + +import java.io.IOException; +import java.util.Locale; + +import org.apache.freemarker.core.templateresolver.TemplateLookupContext; +import org.apache.freemarker.core.templateresolver.TemplateLookupResult; +import org.apache.freemarker.core.templateresolver.TemplateLookupStrategy; + +/** + * <p> + * The default lookup strategy of FreeMarker. + * + * <p> + * Through an example: Assuming localized lookup is enabled and that a template is requested for the name + * {@code example.ftl} and {@code Locale("es", "ES", "Traditional_WIN")}, it will try the following template names, + * in this order: {@code "foo_en_AU_Traditional_WIN.ftl"}, {@code "foo_en_AU_Traditional.ftl"}, + * {@code "foo_en_AU.ftl"}, {@code "foo_en.ftl"}, {@code "foo.ftl"}. It stops at the first variation where it finds + * a template. (If the template name contains "*" steps, finding the template for the attempted localized variation + * happens with the template acquisition mechanism.) If localized lookup is disabled, it won't try to add any locale + * strings, so it just looks for {@code "foo.ftl"}. + * + * <p> + * The generation of the localized name variation with the default lookup strategy, happens like this: It removes + * the file extension (the part starting with the <em>last</em> dot), then appends {@link Locale#toString()} after + * it, and puts back the extension. Then it starts to remove the parts from the end of the locale, considering + * {@code "_"} as the separator between the parts. It won't remove parts that are not part of the locale string + * (like if the requested template name is {@code foo_bar.ftl}, it won't remove the {@code "_bar"}). + */ +public class DefaultTemplateLookupStrategy extends TemplateLookupStrategy { + + public static final DefaultTemplateLookupStrategy INSTANCE = new DefaultTemplateLookupStrategy(); + + private DefaultTemplateLookupStrategy() { + // + } + + @Override + public <R extends TemplateLookupResult> R lookup(TemplateLookupContext<R> ctx) throws IOException { + return ctx.lookupWithLocalizedThenAcquisitionStrategy(ctx.getTemplateName(), ctx.getTemplateLocale()); + } + +} \ No newline at end of file
http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/b0e08ccb/src/main/java/org/apache/freemarker/core/templateresolver/impl/DefaultTemplateNameFormat.java ---------------------------------------------------------------------- diff --git a/src/main/java/org/apache/freemarker/core/templateresolver/impl/DefaultTemplateNameFormat.java b/src/main/java/org/apache/freemarker/core/templateresolver/impl/DefaultTemplateNameFormat.java new file mode 100644 index 0000000..448f2af --- /dev/null +++ b/src/main/java/org/apache/freemarker/core/templateresolver/impl/DefaultTemplateNameFormat.java @@ -0,0 +1,310 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.freemarker.core.templateresolver.impl; + +import org.apache.freemarker.core.Configuration; +import org.apache.freemarker.core.TemplateNotFoundException; +import org.apache.freemarker.core.Version; +import org.apache.freemarker.core.templateresolver.MalformedTemplateNameException; +import org.apache.freemarker.core.templateresolver.TemplateLoader; +import org.apache.freemarker.core.templateresolver.TemplateNameFormat; +import org.apache.freemarker.core.util.StringUtil; + +/** + * The default template name format only when {@link Configuration#Configuration(Version) incompatible_improvements} + * is set to 2.4.0 (or higher). This is not the out-of-the-box default format of FreeMarker 2.4.x, because the + * default {@code incompatible_improvements} is still 2.3.0 there. + * + * <p> + * Differences to the {@link DefaultTemplateNameFormatFM2} format: + * + * <ul> + * + * <li>The scheme and the path need not be separated with {@code "://"} anymore, only with {@code ":"}. This makes + * template names like {@code "classpath:foo.ftl"} interpreted as an absolute name with scheme {@code "classpath"} + * and absolute path "foo.ftl". The scheme name before the {@code ":"} can't contain {@code "/"}, or else it's + * treated as a malformed name. The scheme part can be separated either with {@code "://"} or just {@code ":"} from + * the path. Hence, {@code myschme:/x} is normalized to {@code myschme:x}, while {@code myschme:///x} is normalized + * to {@code myschme://x}, but {@code myschme://x} or {@code myschme:/x} aren't changed by normalization. It's up + * the {@link TemplateLoader} to which the normalized names are passed to decide which of these scheme separation + * conventions are valid (maybe both).</li> + * + * <li>{@code ":"} is not allowed in template names, except as the scheme separator (see previous point). + * + * <li>Malformed paths throw {@link MalformedTemplateNameException} instead of acting like if the template wasn't + * found. + * + * <li>{@code "\"} (backslash) is not allowed in template names, and causes {@link MalformedTemplateNameException}. + * With {@link DefaultTemplateNameFormatFM2} you would certainly end up with a {@link TemplateNotFoundException} (or + * worse, it would work, but steps like {@code ".."} wouldn't be normalized by FreeMarker). + * + * <li>Template names might end with {@code /}, like {@code "foo/"}, and the presence or lack of the terminating + * {@code /} is seen as significant. While their actual interpretation is up to the {@link TemplateLoader}, + * operations that manipulate template names assume that the last step refers to a "directory" as opposed to a + * "file" exactly if the terminating {@code /} is present. Except, the empty name is assumed to refer to the root + * "directory" (despite that it doesn't end with {@code /}). + * + * <li>{@code //} is normalized to {@code /}, except of course if it's in the scheme name terminator. Like + * {@code foo//bar///baaz.ftl} is normalized to {@code foo/bar/baaz.ftl}. (In general, 0 long step names aren't + * possible anymore.)</li> + * + * <li>The {@code ".."} bugs of the legacy normalizer are fixed: {@code ".."} steps has removed the preceding + * {@code "."} or {@code "*"} or scheme steps, not treating them specially as they should be. Now these work as + * expected. Examples: {@code "a/./../c"} has become to {@code "a/c"}, now it will be {@code "c"}; {@code "a/b/*} + * {@code /../c"} has become to {@code "a/b/c"}, now it will be {@code "a/*}{@code /c"}; {@code "scheme://.."} has + * become to {@code "scheme:/"}, now it will be {@code null} ({@link TemplateNotFoundException}) for backing out of + * the root directory.</li> + * + * <li>As now directory paths has to be handled as well, it recognizes terminating, leading, and lonely {@code ".."} + * and {@code "."} steps. For example, {@code "foo/bar/.."} now becomes to {@code "foo/"}</li> + * + * <li>Multiple consecutive {@code *} steps are normalized to one</li> + * + * </ul> + */ +public final class DefaultTemplateNameFormat extends TemplateNameFormat { + + public static DefaultTemplateNameFormat INSTANCE = new DefaultTemplateNameFormat(); + + private DefaultTemplateNameFormat() { + // + } + + @Override + public String toRootBasedName(String baseName, String targetName) { + if (findSchemeSectionEnd(targetName) != 0) { + return targetName; + } else if (targetName.startsWith("/")) { // targetName is an absolute path + final String targetNameAsRelative = targetName.substring(1); + final int schemeSectionEnd = findSchemeSectionEnd(baseName); + if (schemeSectionEnd == 0) { + return targetNameAsRelative; + } else { + // Prepend the scheme of baseName: + return baseName.substring(0, schemeSectionEnd) + targetNameAsRelative; + } + } else { // targetName is a relative path + if (!baseName.endsWith("/")) { + // Not a directory name => get containing directory name + int baseEnd = baseName.lastIndexOf("/") + 1; + if (baseEnd == 0) { + // For something like "classpath:t.ftl", must not remove the scheme part: + baseEnd = findSchemeSectionEnd(baseName); + } + baseName = baseName.substring(0, baseEnd); + } + return baseName + targetName; + } + } + + @Override + public String normalizeRootBasedName(final String name) throws MalformedTemplateNameException { + // Disallow 0 for security reasons. + checkNameHasNoNullCharacter(name); + + if (name.indexOf('\\') != -1) { + throw new MalformedTemplateNameException( + name, + "Backslash (\"\\\") is not allowed in template names. Use slash (\"/\") instead."); + } + + // Split name to a scheme and a path: + final String scheme; + String path; + { + int schemeSectionEnd = findSchemeSectionEnd(name); + if (schemeSectionEnd == 0) { + scheme = null; + path = name; + } else { + scheme = name.substring(0, schemeSectionEnd); + path = name.substring(schemeSectionEnd); + } + } + + if (path.indexOf(':') != -1) { + throw new MalformedTemplateNameException(name, + "The ':' character can only be used after the scheme name (if there's any), " + + "not in the path part"); + } + + path = removeRedundantSlashes(path); + // path now doesn't start with "/" + + path = removeDotSteps(path); + + path = resolveDotDotSteps(path, name); + + path = removeRedundantStarSteps(path); + + return scheme == null ? path : scheme + path; + } + + private int findSchemeSectionEnd(String name) { + int schemeColonIdx = name.indexOf(":"); + if (schemeColonIdx == -1 || name.lastIndexOf('/', schemeColonIdx - 1) != -1) { + return 0; + } else { + // If there's a following "//", it's treated as the part of the scheme section: + if (schemeColonIdx + 2 < name.length() + && name.charAt(schemeColonIdx + 1) == '/' && name.charAt(schemeColonIdx + 2) == '/') { + return schemeColonIdx + 3; + } else { + return schemeColonIdx + 1; + } + } + } + + private String removeRedundantSlashes(String path) { + String prevName; + do { + prevName = path; + path = StringUtil.replace(path, "//", "/"); + } while (prevName != path); + return path.startsWith("/") ? path.substring(1) : path; + } + + private String removeDotSteps(String path) { + int nextFromIdx = path.length() - 1; + findDotSteps: while (true) { + final int dotIdx = path.lastIndexOf('.', nextFromIdx); + if (dotIdx < 0) { + return path; + } + nextFromIdx = dotIdx - 1; + + if (dotIdx != 0 && path.charAt(dotIdx - 1) != '/') { + // False alarm + continue findDotSteps; + } + + final boolean slashRight; + if (dotIdx + 1 == path.length()) { + slashRight = false; + } else if (path.charAt(dotIdx + 1) == '/') { + slashRight = true; + } else { + // False alarm + continue findDotSteps; + } + + if (slashRight) { // "foo/./bar" or "./bar" + path = path.substring(0, dotIdx) + path.substring(dotIdx + 2); + } else { // "foo/." or "." + path = path.substring(0, path.length() - 1); + } + } + } + + /** + * @param name The original name, needed for exception error messages. + */ + private String resolveDotDotSteps(String path, final String name) throws MalformedTemplateNameException { + int nextFromIdx = 0; + findDotDotSteps: while (true) { + final int dotDotIdx = path.indexOf("..", nextFromIdx); + if (dotDotIdx < 0) { + return path; + } + + if (dotDotIdx == 0) { + throw newRootLeavingException(name); + } else if (path.charAt(dotDotIdx - 1) != '/') { + // False alarm + nextFromIdx = dotDotIdx + 3; + continue findDotDotSteps; + } + // Here we know that it has a preceding "/". + + final boolean slashRight; + if (dotDotIdx + 2 == path.length()) { + slashRight = false; + } else if (path.charAt(dotDotIdx + 2) == '/') { + slashRight = true; + } else { + // False alarm + nextFromIdx = dotDotIdx + 3; + continue findDotDotSteps; + } + + int previousSlashIdx; + boolean skippedStarStep = false; + { + int searchSlashBacwardsFrom = dotDotIdx - 2; // before the "/.." + scanBackwardsForSlash: while (true) { + if (searchSlashBacwardsFrom == -1) { + throw newRootLeavingException(name); + } + previousSlashIdx = path.lastIndexOf('/', searchSlashBacwardsFrom); + if (previousSlashIdx == -1) { + if (searchSlashBacwardsFrom == 0 && path.charAt(0) == '*') { + // "*/.." + throw newRootLeavingException(name); + } + break scanBackwardsForSlash; + } + if (path.charAt(previousSlashIdx + 1) == '*' && path.charAt(previousSlashIdx + 2) == '/') { + skippedStarStep = true; + searchSlashBacwardsFrom = previousSlashIdx - 1; + } else { + break scanBackwardsForSlash; + } + } + } + + // Note: previousSlashIdx is possibly -1 + // Removed part in {}: "a/{b/*/../}c" or "a/{b/*/..}" + path = path.substring(0, previousSlashIdx + 1) + + (skippedStarStep ? "*/" : "") + + path.substring(dotDotIdx + (slashRight ? 3 : 2)); + nextFromIdx = previousSlashIdx + 1; + } + } + + private String removeRedundantStarSteps(String path) { + String prevName; + removeDoubleStarSteps: do { + int supiciousIdx = path.indexOf("*/*"); + if (supiciousIdx == -1) { + break removeDoubleStarSteps; + } + + prevName = path; + + // Is it delimited on both sided by "/" or by the string boundaires? + if ((supiciousIdx == 0 || path.charAt(supiciousIdx - 1) == '/') + && (supiciousIdx + 3 == path.length() || path.charAt(supiciousIdx + 3) == '/')) { + path = path.substring(0, supiciousIdx) + path.substring(supiciousIdx + 2); + } + } while (prevName != path); + + // An initial "*" step is redundant: + if (path.startsWith("*")) { + if (path.length() == 1) { + path = ""; + } else if (path.charAt(1) == '/') { + path = path.substring(2); + } + // else: it's wasn't a "*" step. + } + + return path; + } + +} \ No newline at end of file http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/b0e08ccb/src/main/java/org/apache/freemarker/core/templateresolver/impl/DefaultTemplateNameFormatFM2.java ---------------------------------------------------------------------- diff --git a/src/main/java/org/apache/freemarker/core/templateresolver/impl/DefaultTemplateNameFormatFM2.java b/src/main/java/org/apache/freemarker/core/templateresolver/impl/DefaultTemplateNameFormatFM2.java new file mode 100644 index 0000000..c90b067 --- /dev/null +++ b/src/main/java/org/apache/freemarker/core/templateresolver/impl/DefaultTemplateNameFormatFM2.java @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.freemarker.core.templateresolver.impl; + +import org.apache.freemarker.core.Configuration; +import org.apache.freemarker.core.Version; +import org.apache.freemarker.core.templateresolver.MalformedTemplateNameException; +import org.apache.freemarker.core.templateresolver.TemplateNameFormat; + +/** + * The default template name format when {@link Configuration#Configuration(Version) incompatible_improvements} is + * below 2.4.0. As of FreeMarker 2.4.0, the default {@code incompatible_improvements} is still {@code 2.3.0}, and it + * will certainly remain so for a very long time. In new projects it's highly recommended to use + * {@link DefaultTemplateNameFormat#INSTANCE} instead. + * + * @deprecated [FM3] Remove + */ +@Deprecated +public final class DefaultTemplateNameFormatFM2 extends TemplateNameFormat { + + public static final DefaultTemplateNameFormatFM2 INSTANCE = new DefaultTemplateNameFormatFM2(); + + private DefaultTemplateNameFormatFM2() { + // + } + + @Override + public String toRootBasedName(String baseName, String targetName) { + if (targetName.indexOf("://") > 0) { + return targetName; + } else if (targetName.startsWith("/")) { + int schemeSepIdx = baseName.indexOf("://"); + if (schemeSepIdx > 0) { + return baseName.substring(0, schemeSepIdx + 2) + targetName; + } else { + return targetName.substring(1); + } + } else { + if (!baseName.endsWith("/")) { + baseName = baseName.substring(0, baseName.lastIndexOf("/") + 1); + } + return baseName + targetName; + } + } + + @Override + public String normalizeRootBasedName(final String name) throws MalformedTemplateNameException { + // Disallow 0 for security reasons. + checkNameHasNoNullCharacter(name); + + // The legacy algorithm haven't considered schemes, so the name is in effect a path. + // Also, note that `path` will be repeatedly replaced below, while `name` is final. + String path = name; + + for (; ; ) { + int parentDirPathLoc = path.indexOf("/../"); + if (parentDirPathLoc == 0) { + // If it starts with /../, then it reaches outside the template + // root. + throw newRootLeavingException(name); + } + if (parentDirPathLoc == -1) { + if (path.startsWith("../")) { + throw newRootLeavingException(name); + } + break; + } + int previousSlashLoc = path.lastIndexOf('/', parentDirPathLoc - 1); + path = path.substring(0, previousSlashLoc + 1) + + path.substring(parentDirPathLoc + "/../".length()); + } + for (; ; ) { + int currentDirPathLoc = path.indexOf("/./"); + if (currentDirPathLoc == -1) { + if (path.startsWith("./")) { + path = path.substring("./".length()); + } + break; + } + path = path.substring(0, currentDirPathLoc) + + path.substring(currentDirPathLoc + "/./".length() - 1); + } + // Editing can leave us with a leading slash; strip it. + if (path.length() > 1 && path.charAt(0) == '/') { + path = path.substring(1); + } + return path; + } + +} \ No newline at end of file http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/b0e08ccb/src/main/java/org/apache/freemarker/core/templateresolver/impl/DefaultTemplateResolver.java ---------------------------------------------------------------------- diff --git a/src/main/java/org/apache/freemarker/core/templateresolver/impl/DefaultTemplateResolver.java b/src/main/java/org/apache/freemarker/core/templateresolver/impl/DefaultTemplateResolver.java new file mode 100644 index 0000000..456f944 --- /dev/null +++ b/src/main/java/org/apache/freemarker/core/templateresolver/impl/DefaultTemplateResolver.java @@ -0,0 +1,1042 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.freemarker.core.templateresolver.impl; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.Serializable; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.StringTokenizer; + +import org.apache.freemarker.core.Configuration; +import org.apache.freemarker.core.Template; +import org.apache.freemarker.core.TemplateNotFoundException; +import org.apache.freemarker.core._CoreLogs; +import org.apache.freemarker.core._TemplateAPI; +import org.apache.freemarker.core.ast.BugException; +import org.apache.freemarker.core.ast.MarkReleaserTemplateSpecifiedEncodingHandler; +import org.apache.freemarker.core.ast.TemplateConfiguration; +import org.apache.freemarker.core.ast.TemplateSpecifiedEncodingHandler; +import org.apache.freemarker.core.templateresolver.CacheStorage; +import org.apache.freemarker.core.templateresolver.ConcurrentCacheStorage; +import org.apache.freemarker.core.templateresolver.GetTemplateResult; +import org.apache.freemarker.core.templateresolver.MalformedTemplateNameException; +import org.apache.freemarker.core.templateresolver.TemplateConfigurationFactory; +import org.apache.freemarker.core.templateresolver.TemplateConfigurationFactoryException; +import org.apache.freemarker.core.templateresolver.TemplateLoader; +import org.apache.freemarker.core.templateresolver.TemplateLoaderSession; +import org.apache.freemarker.core.templateresolver.TemplateLoadingResult; +import org.apache.freemarker.core.templateresolver.TemplateLoadingResultStatus; +import org.apache.freemarker.core.templateresolver.TemplateLoadingSource; +import org.apache.freemarker.core.templateresolver.TemplateLookupStrategy; +import org.apache.freemarker.core.templateresolver.TemplateNameFormat; +import org.apache.freemarker.core.templateresolver.TemplateResolver; +import org.apache.freemarker.core.util.NullArgumentException; +import org.apache.freemarker.core.util.StringUtil; +import org.apache.freemarker.core.util.UndeclaredThrowableException; +import org.slf4j.Logger; + +/** + * Performs caching and on-demand loading of the templates. + * The actual template "file" loading is delegated to a {@link TemplateLoader} that you can specify in the constructor. + * Some aspects of caching is delegated to a {@link CacheStorage} that you can also specify in the constructor. + * + * <p>Typically you don't instantiate or otherwise use this class directly. The {@link Configuration} embeds an + * instance of this class, that you access indirectly through {@link Configuration#getTemplate(String)} and other + * {@link Configuration} API-s. Then {@link TemplateLoader} and {@link CacheStorage} can be set with + * {@link Configuration#setTemplateLoader(TemplateLoader)} and + * {@link Configuration#setCacheStorage(CacheStorage)}. + */ +public class DefaultTemplateResolver extends TemplateResolver { + + /** + * The default template update delay; see {@link Configuration#setTemplateUpdateDelayMilliseconds(long)}. + * + * @since 2.3.23 + */ + public static final long DEFAULT_TEMPLATE_UPDATE_DELAY_MILLIS = 5000L; + + private static final String ASTERISKSTR = "*"; + private static final char ASTERISK = '*'; + private static final char SLASH = '/'; + private static final String LOCALE_PART_SEPARATOR = "_"; + private static final Logger LOG = _CoreLogs.TEMPLATE_RESOLVER; + + /** Maybe {@code null}. */ + private final TemplateLoader templateLoader; + + /** Here we keep our cached templates */ + private final CacheStorage cacheStorage; + private final TemplateLookupStrategy templateLookupStrategy; + private final TemplateNameFormat templateNameFormat; + private final TemplateConfigurationFactory templateConfigurations; + + private final boolean isCacheStorageConcurrent; + /** {@link Configuration#setTemplateUpdateDelayMilliseconds(long)} */ + private long templateUpdateDelayMilliseconds = DEFAULT_TEMPLATE_UPDATE_DELAY_MILLIS; + /** {@link Configuration#setLocalizedLookup(boolean)} */ + private boolean localizedLookup = true; + + private Configuration config; + + /** + * Same as {@link #DefaultTemplateResolver(TemplateLoader, CacheStorage, Configuration)} with a new {@link SoftCacheStorage} + * as the 2nd parameter. + * + * @since 2.3.21 + */ + public DefaultTemplateResolver(TemplateLoader templateLoader, Configuration config) { + this(templateLoader, _TemplateAPI.createDefaultCacheStorage(Configuration.VERSION_2_3_0), config); + } + + /** + * Same as + * {@link #DefaultTemplateResolver(TemplateLoader, CacheStorage, TemplateLookupStrategy, TemplateNameFormat, Configuration)} + * with {@link DefaultTemplateLookupStrategy#INSTANCE} and {@link DefaultTemplateNameFormatFM2#INSTANCE}. + * + * @since 2.3.21 + */ + public DefaultTemplateResolver(TemplateLoader templateLoader, CacheStorage cacheStorage, Configuration config) { + this(templateLoader, cacheStorage, + _TemplateAPI.getDefaultTemplateLookupStrategy(Configuration.VERSION_2_3_0), + _TemplateAPI.getDefaultTemplateNameFormat(Configuration.VERSION_2_3_0), + config); + } + + /** + * Same as + * {@link #DefaultTemplateResolver(TemplateLoader, CacheStorage, TemplateLookupStrategy, TemplateNameFormat, + * TemplateConfigurationFactory, Configuration)} with {@code null} for {@code templateConfigurations}-s. + * + * @since 2.3.22 + */ + public DefaultTemplateResolver(TemplateLoader templateLoader, CacheStorage cacheStorage, + TemplateLookupStrategy templateLookupStrategy, TemplateNameFormat templateNameFormat, + Configuration config) { + this(templateLoader, cacheStorage, templateLookupStrategy, templateNameFormat, null, config); + } + + /** + * @param templateLoader + * The {@link TemplateLoader} to use. Can be {@code null}, though then every request will result in + * {@link TemplateNotFoundException}. + * @param cacheStorage + * The {@link CacheStorage} to use. Can't be {@code null}. + * @param templateLookupStrategy + * The {@link TemplateLookupStrategy} to use. Can't be {@code null}. + * @param templateNameFormat + * The {@link TemplateNameFormat} to use. Can't be {@code null}. + * @param templateConfigurations + * The {@link TemplateConfigurationFactory} to use. Can be {@code null} (then all templates will use the + * settings coming from the {@link Configuration} as is, except in the very rare case where a + * {@link TemplateLoader} itself specifies a {@link TemplateConfiguration}). + * @param config + * The {@link Configuration} this cache will be used for. Can't be {@code null}. + * + * @since 2.3.24 + */ + public DefaultTemplateResolver(TemplateLoader templateLoader, CacheStorage cacheStorage, + TemplateLookupStrategy templateLookupStrategy, TemplateNameFormat templateNameFormat, + TemplateConfigurationFactory templateConfigurations, + Configuration config) { + super(config); + + this.templateLoader = templateLoader; + + NullArgumentException.check("cacheStorage", cacheStorage); + this.cacheStorage = cacheStorage; + isCacheStorageConcurrent = cacheStorage instanceof ConcurrentCacheStorage && + ((ConcurrentCacheStorage) cacheStorage).isConcurrent(); + + NullArgumentException.check("templateLookupStrategy", templateLookupStrategy); + this.templateLookupStrategy = templateLookupStrategy; + + NullArgumentException.check("templateNameFormat", templateNameFormat); + this.templateNameFormat = templateNameFormat; + + // Can be null + this.templateConfigurations = templateConfigurations; + + NullArgumentException.check("config", config); + this.config = config; + } + + /** + * Returns the configuration for internal usage. + */ + @Override + public Configuration getConfiguration() { + return config; + } + + public TemplateLoader getTemplateLoader() { + return templateLoader; + } + + public CacheStorage getCacheStorage() { + return cacheStorage; + } + + /** + * @since 2.3.22 + */ + public TemplateLookupStrategy getTemplateLookupStrategy() { + return templateLookupStrategy; + } + + /** + * @since 2.3.22 + */ + public TemplateNameFormat getTemplateNameFormat() { + return templateNameFormat; + } + + /** + * @since 2.3.24 + */ + public TemplateConfigurationFactory getTemplateConfigurations() { + return templateConfigurations; + } + + /** + * Retrieves the template with the given name (and according the specified further parameters) from the template + * cache, loading it into the cache first if it's missing/staled. + * + * <p> + * All parameters must be non-{@code null}, except {@code customLookupCondition}. For the meaning of the parameters + * see {@link Configuration#getTemplate(String, Locale, String, boolean)}. + * + * @return A {@link GetTemplateResult} object that contains the {@link Template}, or a + * {@link GetTemplateResult} object that contains {@code null} as the {@link Template} and information + * about the missing template. The return value itself is never {@code null}. Note that exceptions occurring + * during template loading will not be classified as a missing template, so they will cause an exception to + * be thrown by this method instead of returning a {@link GetTemplateResult}. The idea is that having a + * missing template is normal (not exceptional), providing that the backing storage mechanism could indeed + * check that it's missing. + * + * @throws MalformedTemplateNameException + * If the {@code name} was malformed according the current {@link TemplateNameFormat}. However, if the + * {@link TemplateNameFormat} is {@link DefaultTemplateNameFormatFM2#INSTANCE} and + * {@link Configuration#getIncompatibleImprovements()} is less than 2.4.0, then instead of throwing this + * exception, a {@link GetTemplateResult} will be returned, similarly as if the template were missing + * (the {@link GetTemplateResult#getMissingTemplateReason()} will describe the real error). + * + * @throws IOException + * If reading the template has failed from a reason other than the template is missing. This method + * should never be a {@link TemplateNotFoundException}, as that condition is indicated in the return + * value. + * + * @since 2.3.22 + */ + @Override + public GetTemplateResult getTemplate(String name, Locale locale, Object customLookupCondition, + String encoding, boolean parseAsFTL) + throws IOException { + NullArgumentException.check("name", name); + NullArgumentException.check("locale", locale); + NullArgumentException.check("encoding", encoding); + + try { + name = templateNameFormat.normalizeRootBasedName(name); + } catch (MalformedTemplateNameException e) { + // If we don't have to emulate backward compatible behavior, then just rethrow it: + if (templateNameFormat != DefaultTemplateNameFormatFM2.INSTANCE + || config.getIncompatibleImprovements().intValue() >= _TemplateAPI.VERSION_INT_2_4_0) { + throw e; + } + return new GetTemplateResult(null, e); + } + + if (templateLoader == null) { + return new GetTemplateResult(name, "The TemplateLoader (and TemplateLoader2) was null."); + } + + Template template = getTemplateInternal(name, locale, customLookupCondition, encoding, parseAsFTL); + return template != null ? new GetTemplateResult(template) : new GetTemplateResult(name, (String) null); + } + + @Override + public String toRootBasedName(String baseName, String targetName) throws MalformedTemplateNameException { + return templateNameFormat.toRootBasedName(baseName, targetName); + } + + @Override + public String normalizeRootBasedName(String name) throws MalformedTemplateNameException { + return templateNameFormat.normalizeRootBasedName(name); + } + + private Template getTemplateInternal( + final String name, final Locale locale, final Object customLookupCondition, + final String encoding, final boolean parseAsFTL) + throws IOException { + final boolean debug = LOG.isDebugEnabled(); + final String debugPrefix = debug + ? getDebugPrefix("getTemplate", name, locale, customLookupCondition, encoding, parseAsFTL) + : null; + final CachedResultKey cacheKey = new CachedResultKey(name, locale, customLookupCondition, encoding, parseAsFTL); + + CachedResult oldCachedResult; + if (isCacheStorageConcurrent) { + oldCachedResult = (CachedResult) cacheStorage.get(cacheKey); + } else { + synchronized (cacheStorage) { + oldCachedResult = (CachedResult) cacheStorage.get(cacheKey); + } + } + + final long now = System.currentTimeMillis(); + + boolean rethrownCachedException = false; + boolean suppressFinallyException = false; + TemplateLoaderBasedTemplateLookupResult newLookupResult = null; + CachedResult newCachedResult = null; + TemplateLoaderSession session = null; + try { + if (oldCachedResult != null) { + // If we're within the refresh delay, return the cached result + if (now - oldCachedResult.lastChecked < templateUpdateDelayMilliseconds) { + if (debug) { + LOG.debug(debugPrefix + "Cached copy not yet stale; using cached."); + } + Object t = oldCachedResult.templateOrException; + // t can be null, indicating a cached negative lookup + if (t instanceof Template || t == null) { + return (Template) t; + } else if (t instanceof RuntimeException) { + rethrowCachedException((RuntimeException) t); + } else if (t instanceof IOException) { + rethrownCachedException = true; + rethrowCachedException((IOException) t); + } + throw new BugException("Unhandled class for t: " + t.getClass().getName()); + } + // The freshness of the cache result must be checked. + + // Clone, as the instance in the cache store must not be modified to ensure proper concurrent behavior. + newCachedResult = oldCachedResult.clone(); + newCachedResult.lastChecked = now; + + session = templateLoader.createSession(); + if (debug && session != null) { + LOG.debug(debugPrefix + "Session created."); + } + + // Find the template source, load it if it doesn't correspond to the cached result. + newLookupResult = lookupAndLoadTemplateIfChanged( + name, locale, customLookupCondition, oldCachedResult.source, oldCachedResult.version, session); + + // Template source was removed (TemplateLoader2ResultStatus.NOT_FOUND, or no TemplateLoader2Result) + if (!newLookupResult.isPositive()) { + if (debug) { + LOG.debug(debugPrefix + "No source found."); + } + setToNegativeAndPutIntoCache(cacheKey, newCachedResult, null); + return null; + } + + final TemplateLoadingResult newTemplateLoaderResult = newLookupResult.getTemplateLoaderResult(); + if (newTemplateLoaderResult.getStatus() == TemplateLoadingResultStatus.NOT_MODIFIED) { + // Return the cached version. + if (debug) { + LOG.debug(debugPrefix + ": Using cached template " + + "(source: " + newTemplateLoaderResult.getSource() + ")" + + " as it hasn't been changed on the backing store."); + } + putIntoCache(cacheKey, newCachedResult); + return (Template) newCachedResult.templateOrException; + } else { + if (newTemplateLoaderResult.getStatus() != TemplateLoadingResultStatus.OPENED) { + // TemplateLoader2ResultStatus.NOT_FOUND was already handler earlier + throw new BugException("Unxpected status: " + newTemplateLoaderResult.getStatus()); + } + if (debug) { + StringBuilder debugMsg = new StringBuilder(); + debugMsg.append(debugPrefix) + .append("Reloading template instead of using the cached result because "); + if (newCachedResult.templateOrException instanceof Throwable) { + debugMsg.append("it's a cached error (retrying)."); + } else { + Object newSource = newTemplateLoaderResult.getSource(); + if (!nullSafeEquals(newSource, oldCachedResult.source)) { + debugMsg.append("the source has been changed: ") + .append("cached.source=").append(StringUtil.jQuoteNoXSS(oldCachedResult.source)) + .append(", current.source=").append(StringUtil.jQuoteNoXSS(newSource)); + } else { + Serializable newVersion = newTemplateLoaderResult.getVersion(); + if (!nullSafeEquals(oldCachedResult.version, newVersion)) { + debugMsg.append("the version has been changed: ") + .append("cached.version=").append(oldCachedResult.version) + .append(", current.version=").append(newVersion); + } else { + debugMsg.append("??? (unknown reason)"); + } + } + } + LOG.debug(debugMsg.toString()); + } + } + } else { // if there was no cached result + if (debug) { + LOG.debug(debugPrefix + "No cached result was found; will try to load template."); + } + + newCachedResult = new CachedResult(); + newCachedResult.lastChecked = now; + + session = templateLoader.createSession(); + if (debug && session != null) { + LOG.debug(debugPrefix + "Session created."); + } + + newLookupResult = lookupAndLoadTemplateIfChanged( + name, locale, customLookupCondition, null, null, session); + + if (!newLookupResult.isPositive()) { + setToNegativeAndPutIntoCache(cacheKey, newCachedResult, null); + return null; + } + } + // We have newCachedResult and newLookupResult initialized at this point. + + TemplateLoadingResult templateLoaderResult = newLookupResult.getTemplateLoaderResult(); + newCachedResult.source = templateLoaderResult.getSource(); + + // If we get here, then we need to (re)load the template + if (debug) { + LOG.debug(debugPrefix + "Reading template content (source: " + + StringUtil.jQuoteNoXSS(newCachedResult.source) + ")"); + } + + Template template = loadTemplate( + templateLoaderResult, + name, newLookupResult.getTemplateSourceName(), locale, customLookupCondition, + encoding, parseAsFTL); + if (session != null) { + session.close(); + if (debug) { + LOG.debug(debugPrefix + "Session closed."); + } + } + newCachedResult.templateOrException = template; + newCachedResult.version = templateLoaderResult.getVersion(); + putIntoCache(cacheKey, newCachedResult); + return template; + } catch (RuntimeException e) { + if (newCachedResult != null) { + setToNegativeAndPutIntoCache(cacheKey, newCachedResult, e); + } + suppressFinallyException = true; + throw e; + } catch (IOException e) { + // Rethrown cached exceptions are wrapped into IOException-s, so we only need this condition here. + if (!rethrownCachedException) { + setToNegativeAndPutIntoCache(cacheKey, newCachedResult, e); + } + suppressFinallyException = true; + throw e; + } finally { + try { + // Close streams first: + + if (newLookupResult != null && newLookupResult.isPositive()) { + TemplateLoadingResult templateLoaderResult = newLookupResult.getTemplateLoaderResult(); + Reader reader = templateLoaderResult.getReader(); + if (reader != null) { + try { + reader.close(); + } catch (IOException e) { // [FM3] Exception e + if (suppressFinallyException) { + if (LOG.isWarnEnabled()) { + LOG.warn("Failed to close template content Reader for: " + name, e); + } + } else { + suppressFinallyException = true; + throw e; + } + } + } else if (templateLoaderResult.getInputStream() != null) { + try { + templateLoaderResult.getInputStream().close(); + } catch (IOException e) { // [FM3] Exception e + if (suppressFinallyException) { + if (LOG.isWarnEnabled()) { + LOG.warn("Failed to close template content InputStream for: " + name, e); + } + } else { + suppressFinallyException = true; + throw e; + } + } + } + } + } finally { + // Then close streams: + + if (session != null && !session.isClosed()) { + try { + session.close(); + if (debug) { + LOG.debug(debugPrefix + "Session closed."); + } + } catch (IOException e) { // [FM3] Exception e + if (suppressFinallyException) { + if (LOG.isWarnEnabled()) { + LOG.warn("Failed to close template loader session for" + name, e); + } + } else { + suppressFinallyException = true; + throw e; + } + } + } + } + } + } + + + + private static final Method INIT_CAUSE = getInitCauseMethod(); + + private static final Method getInitCauseMethod() { + try { + return Throwable.class.getMethod("initCause", new Class[] { Throwable.class }); + } catch (NoSuchMethodException e) { + return null; + } + } + + /** + * Creates an {@link IOException} that has a cause exception. + */ + // [Java 6] Remove + private IOException newIOException(String message, Throwable cause) { + if (cause == null) { + return new IOException(message); + } + + IOException ioe; + if (INIT_CAUSE != null) { + ioe = new IOException(message); + try { + INIT_CAUSE.invoke(ioe, cause); + } catch (RuntimeException ex) { + throw ex; + } catch (Exception ex) { + throw new UndeclaredThrowableException(ex); + } + } else { + ioe = new IOException(message + "\nCaused by: " + cause.getClass().getName() + + ": " + cause.getMessage()); + } + return ioe; + } + + private void rethrowCachedException(Throwable e) throws IOException { + throw newIOException("There was an error loading the " + + "template on an earlier attempt; see cause exception.", e); + } + + private void setToNegativeAndPutIntoCache(CachedResultKey cacheKey, CachedResult cachedResult, Exception e) { + cachedResult.templateOrException = e; + cachedResult.source = null; + cachedResult.version = null; + putIntoCache(cacheKey, cachedResult); + } + + private void putIntoCache(CachedResultKey tk, CachedResult cachedTemplate) { + if (isCacheStorageConcurrent) { + cacheStorage.put(tk, cachedTemplate); + } else { + synchronized (cacheStorage) { + cacheStorage.put(tk, cachedTemplate); + } + } + } + + @SuppressWarnings("deprecation") + private Template loadTemplate( + TemplateLoadingResult templateLoaderResult, + final String name, final String sourceName, Locale locale, final Object customLookupCondition, + String initialEncoding, final boolean parseAsFTL) throws IOException { + TemplateConfiguration tc; + { + TemplateConfiguration cfgTC; + try { + cfgTC = templateConfigurations != null + ? templateConfigurations.get(sourceName, templateLoaderResult.getSource()) : null; + } catch (TemplateConfigurationFactoryException e) { + throw newIOException("Error while getting TemplateConfiguration; see cause exception.", e); + } + TemplateConfiguration resultTC = templateLoaderResult.getTemplateConfiguration(); + if (resultTC != null) { + TemplateConfiguration mergedTC = new TemplateConfiguration(); + if (cfgTC != null) { + mergedTC.merge(cfgTC); + } + if (resultTC != null) { + mergedTC.merge(resultTC); + } + mergedTC.setParentConfiguration(config); + + tc = mergedTC; + } else { + tc = cfgTC; + } + } + + if (tc != null) { + // TC.{encoding,locale} is stronger than the cfg.getTemplate arguments by design. + if (tc.isEncodingSet()) { + initialEncoding = tc.getEncoding(); + } + if (tc.isLocaleSet()) { + locale = tc.getLocale(); + } + } + + Template template; + { + Reader reader = templateLoaderResult.getReader(); + InputStream inputStream = templateLoaderResult.getInputStream(); + TemplateSpecifiedEncodingHandler templateSpecifiedEncodingHandler; + if (reader != null) { + if (inputStream != null) { + throw new IllegalStateException("For a(n) " + templateLoaderResult.getClass().getName() + + ", both getReader() and getInputStream() has returned non-null."); + } + initialEncoding = null; // No charset decoding has happened + templateSpecifiedEncodingHandler = TemplateSpecifiedEncodingHandler.DEFAULT; + } else if (inputStream != null) { + if (parseAsFTL) { + // We need mark support, to restart if the charset suggested by <#ftl encoding=...> differs + // from that we use initially. + if (!inputStream.markSupported()) { + inputStream = new BufferedInputStream(inputStream); + } + inputStream.mark(Integer.MAX_VALUE); // Mark is released after the 1st FTL tag + templateSpecifiedEncodingHandler = new MarkReleaserTemplateSpecifiedEncodingHandler(inputStream); + } else { + templateSpecifiedEncodingHandler = null; + } + // Regarding buffering worries: On the Reader side we should only read in chunks (like through a + // BufferedReader), so there shouldn't be a problem if the InputStream is not buffered. (Also, at least + // on Oracle JDK and OpenJDK 7 the InputStreamReader itself has an internal ~8K buffer.) + reader = new InputStreamReader(inputStream, initialEncoding); + } else { + throw new IllegalStateException("For a(n) " + templateLoaderResult.getClass().getName() + + ", both getReader() and getInputStream() has returned null."); + } + + try { + if (parseAsFTL) { + try { + template = new Template(name, sourceName, reader, config, tc, + initialEncoding, templateSpecifiedEncodingHandler); + } catch (Template.WrongEncodingException wee) { + final String templateSpecifiedEncoding = wee.getTemplateSpecifiedEncoding(); + + if (inputStream != null) { + // We restart InputStream to re-decode it with the new charset. + inputStream.reset(); + + // Don't close `reader`; it's an InputStreamReader that would close the wrapped InputStream. + reader = new InputStreamReader(inputStream, templateSpecifiedEncoding); + } else { + // Should be impossible to get here + throw new BugException(); + } + + template = new Template(name, sourceName, reader, config, tc, + templateSpecifiedEncoding, templateSpecifiedEncodingHandler); + } + } else { + // Read the contents into a StringWriter, then construct a single-text-block template from it. + final StringBuilder sb = new StringBuilder(); + final char[] buf = new char[4096]; + int charsRead; + while ((charsRead = reader.read(buf)) > 0) { + sb.append(buf, 0, charsRead); + } + template = Template.getPlainTextTemplate(name, sourceName, sb.toString(), config); + template.setEncoding(initialEncoding); + } + } finally { + reader.close(); + } + } + + if (tc != null) { + tc.apply(template); + } + + template.setLocale(locale); + template.setCustomLookupCondition(customLookupCondition); + return template; + } + + /** + * Gets the delay in milliseconds between checking for newer versions of a + * template source. + * @return the current value of the delay + */ + public long getTemplateUpdateDelayMilliseconds() { + // synchronized was moved here so that we don't advertise that it's thread-safe, as it's not. + synchronized (this) { + return templateUpdateDelayMilliseconds; + } + } + + /** + * Sets the delay in milliseconds between checking for newer versions of a + * template sources. + * @param templateUpdateDelayMilliseconds the new value of the delay + */ + public void setTemplateUpdateDelayMilliseconds(long templateUpdateDelayMilliseconds) { + // synchronized was moved here so that we don't advertise that it's thread-safe, as it's not. + synchronized (this) { + this.templateUpdateDelayMilliseconds = templateUpdateDelayMilliseconds; + } + } + + /** + * Returns if localized template lookup is enabled or not. + */ + public boolean getLocalizedLookup() { + // synchronized was moved here so that we don't advertise that it's thread-safe, as it's not. + synchronized (this) { + return localizedLookup; + } + } + + /** + * Setis if localized template lookup is enabled or not. + */ + public void setLocalizedLookup(boolean localizedLookup) { + // synchronized was moved here so that we don't advertise that it's thread-safe, as it's not. + synchronized (this) { + if (this.localizedLookup != localizedLookup) { + this.localizedLookup = localizedLookup; + clearTemplateCache(); + } + } + } + + /** + * Removes all entries from the cache, forcing reloading of templates on subsequent + * {@link #getTemplate(String, Locale, Object, String, boolean)} calls. + * + * @param resetTemplateLoader + * Whether to call {@link TemplateLoader#resetState()}. on the template loader. + */ + public void clearTemplateCache(boolean resetTemplateLoader) { + synchronized (cacheStorage) { + cacheStorage.clear(); + if (templateLoader != null && resetTemplateLoader) { + templateLoader.resetState(); + } + } + } + + /** + * Same as {@link #clearTemplateCache(boolean)} with {@code true} {@code resetTemplateLoader} argument. + */ + @Override + public void clearTemplateCache() { + synchronized (cacheStorage) { + cacheStorage.clear(); + if (templateLoader != null) { + templateLoader.resetState(); + } + } + } + + /** + * Same as {@link #removeTemplateFromCache(String, Locale, Object, String, boolean)} with {@code null} + * {@code customLookupCondition}. + */ + @Override + public void removeTemplateFromCache( + String name, Locale locale, String encoding, boolean parse) throws IOException { + removeTemplateFromCache(name, locale, null, encoding, parse); + } + + /** + * Removes an entry from the cache, hence forcing the re-loading of it when it's next time requested. (It doesn't + * delete the template file itself.) This is to give the application finer control over cache updating than + * {@link #setTemplateUpdateDelayMilliseconds(long)} alone does. + * + * For the meaning of the parameters, see + * {@link Configuration#getTemplate(String, Locale, Object, String, boolean, boolean)} + */ + public void removeTemplateFromCache( + String name, Locale locale, Object customLookupCondition, String encoding, boolean parse) + throws IOException { + if (name == null) { + throw new IllegalArgumentException("Argument \"name\" can't be null"); + } + if (locale == null) { + throw new IllegalArgumentException("Argument \"locale\" can't be null"); + } + if (encoding == null) { + throw new IllegalArgumentException("Argument \"encoding\" can't be null"); + } + name = templateNameFormat.normalizeRootBasedName(name); + if (name != null && templateLoader != null) { + boolean debug = LOG.isDebugEnabled(); + String debugPrefix = debug + ? getDebugPrefix("removeTemplate", name, locale, customLookupCondition, encoding, parse) + : null; + CachedResultKey tk = new CachedResultKey(name, locale, customLookupCondition, encoding, parse); + + if (isCacheStorageConcurrent) { + cacheStorage.remove(tk); + } else { + synchronized (cacheStorage) { + cacheStorage.remove(tk); + } + } + if (debug) { + LOG.debug(debugPrefix + "Template was removed from the cache, if it was there"); + } + } + } + + private String getDebugPrefix(String operation, String name, Locale locale, Object customLookupCondition, String encoding, + boolean parse) { + return operation + " " + StringUtil.jQuoteNoXSS(name) + "(" + + StringUtil.jQuoteNoXSS(locale) + + (customLookupCondition != null ? ", cond=" + StringUtil.jQuoteNoXSS(customLookupCondition) : "") + + ", " + encoding + + (parse ? ", parsed)" : ", unparsed]") + + ": "; + } + + /** + * Looks up according the {@link TemplateLookupStrategy} and then starts reading the template, if it was changed + * compared to the cached result, or if there was no cached result yet. + */ + private TemplateLoaderBasedTemplateLookupResult lookupAndLoadTemplateIfChanged( + String name, Locale locale, Object customLookupCondition, + TemplateLoadingSource cachedResultSource, Serializable cachedResultVersion, + TemplateLoaderSession session) throws IOException { + final TemplateLoaderBasedTemplateLookupResult lookupResult = templateLookupStrategy.lookup( + new DefaultTemplateResolverTemplateLookupContext( + name, locale, customLookupCondition, + cachedResultSource, cachedResultVersion, + session)); + if (lookupResult == null) { + throw new NullPointerException("Lookup result shouldn't be null"); + } + return lookupResult; + } + + private String concatPath(List<String> pathSteps, int from, int to) { + StringBuilder buf = new StringBuilder((to - from) * 16); + for (int i = from; i < to; ++i) { + buf.append(pathSteps.get(i)); + if (i < pathSteps.size() - 1) { + buf.append('/'); + } + } + return buf.toString(); + } + + // Replace with Objects.equals in Java 7 + private static boolean nullSafeEquals(Object o1, Object o2) { + if (o1 == o2) return true; + if (o1 == null || o2 == null) return false; + return o1.equals(o2); + } + + /** + * Used as cache key to look up a {@link CachedResult}. + */ + @SuppressWarnings("serial") + private static final class CachedResultKey implements Serializable { + private final String name; + private final Locale locale; + private final Object customLookupCondition; + private final String encoding; + private final boolean parse; + + CachedResultKey(String name, Locale locale, Object customLookupCondition, String encoding, boolean parse) { + this.name = name; + this.locale = locale; + this.customLookupCondition = customLookupCondition; + this.encoding = encoding; + this.parse = parse; + } + + @Override + public boolean equals(Object o) { + if (o instanceof CachedResultKey) { + CachedResultKey tk = (CachedResultKey) o; + return + parse == tk.parse && + name.equals(tk.name) && + locale.equals(tk.locale) && + nullSafeEquals(customLookupCondition, tk.customLookupCondition) && + encoding.equals(tk.encoding); + } + return false; + } + + @Override + public int hashCode() { + return + name.hashCode() ^ + locale.hashCode() ^ + encoding.hashCode() ^ + (customLookupCondition != null ? customLookupCondition.hashCode() : 0) ^ + Boolean.valueOf(!parse).hashCode(); + } + } + + /** + * Hold the a cached {@link #getTemplate(String, Locale, Object, String, boolean)} result and the associated + * information needed to check if the cached value is up to date. + * + * <p> + * Note: this class is Serializable to allow custom 3rd party CacheStorage implementations to serialize/replicate + * them; FreeMarker code itself doesn't rely on its serializability. + * + * @see CachedResultKey + */ + private static final class CachedResult implements Cloneable, Serializable { + private static final long serialVersionUID = 1L; + + Object templateOrException; + TemplateLoadingSource source; + Serializable version; + long lastChecked; + + @Override + public CachedResult clone() { + try { + return (CachedResult) super.clone(); + } catch (CloneNotSupportedException e) { + throw new UndeclaredThrowableException(e); + } + } + } + + private class DefaultTemplateResolverTemplateLookupContext extends TemplateLoaderBasedTemplateLookupContext { + + private final TemplateLoaderSession session; + + DefaultTemplateResolverTemplateLookupContext(String templateName, Locale templateLocale, Object customLookupCondition, + TemplateLoadingSource cachedResultSource, Serializable cachedResultVersion, + TemplateLoaderSession session) { + super(templateName, localizedLookup ? templateLocale : null, customLookupCondition, + cachedResultSource, cachedResultVersion); + this.session = session; + } + + @Override + public TemplateLoaderBasedTemplateLookupResult lookupWithAcquisitionStrategy(String path) throws IOException { + // Only one of the possible ways of making a name non-normalized, but is the easiest mistake to do: + if (path.startsWith("/")) { + throw new IllegalArgumentException("Non-normalized name, starts with \"/\": " + path); + } + + int asterisk = path.indexOf(ASTERISK); + // Shortcut in case there is no acquisition + if (asterisk == -1) { + return createLookupResult( + path, + templateLoader.load(path, getCachedResultSource(), getCachedResultVersion(), session)); + } + StringTokenizer pathTokenizer = new StringTokenizer(path, "/"); + int lastAsterisk = -1; + List<String> pathSteps = new ArrayList<String>(); + while (pathTokenizer.hasMoreTokens()) { + String pathStep = pathTokenizer.nextToken(); + if (pathStep.equals(ASTERISKSTR)) { + if (lastAsterisk != -1) { + pathSteps.remove(lastAsterisk); + } + lastAsterisk = pathSteps.size(); + } + pathSteps.add(pathStep); + } + if (lastAsterisk == -1) { // if there was no real "*" step after all + return createLookupResult( + path, + templateLoader.load(path, getCachedResultSource(), getCachedResultVersion(), session)); + } + String basePath = concatPath(pathSteps, 0, lastAsterisk); + String postAsteriskPath = concatPath(pathSteps, lastAsterisk + 1, pathSteps.size()); + StringBuilder buf = new StringBuilder(path.length()).append(basePath); + int basePathLen = basePath.length(); + while (true) { + String fullPath = buf.append(postAsteriskPath).toString(); + TemplateLoadingResult templateLoaderResult = templateLoader.load( + fullPath, getCachedResultSource(), getCachedResultVersion(), session); + if (templateLoaderResult.getStatus() == TemplateLoadingResultStatus.OPENED) { + return createLookupResult(fullPath, templateLoaderResult); + } + if (basePathLen == 0) { + return createNegativeLookupResult(); + } + basePathLen = basePath.lastIndexOf(SLASH, basePathLen - 2) + 1; + buf.setLength(basePathLen); + } + } + + @Override + public TemplateLoaderBasedTemplateLookupResult lookupWithLocalizedThenAcquisitionStrategy(final String templateName, + final Locale templateLocale) throws IOException { + + if (templateLocale == null) { + return lookupWithAcquisitionStrategy(templateName); + } + + int lastDot = templateName.lastIndexOf('.'); + String prefix = lastDot == -1 ? templateName : templateName.substring(0, lastDot); + String suffix = lastDot == -1 ? "" : templateName.substring(lastDot); + String localeName = LOCALE_PART_SEPARATOR + templateLocale.toString(); + StringBuilder buf = new StringBuilder(templateName.length() + localeName.length()); + buf.append(prefix); + tryLocaleNameVariations: while (true) { + buf.setLength(prefix.length()); + String path = buf.append(localeName).append(suffix).toString(); + TemplateLoaderBasedTemplateLookupResult lookupResult = lookupWithAcquisitionStrategy(path); + if (lookupResult.isPositive()) { + return lookupResult; + } + + int lastUnderscore = localeName.lastIndexOf('_'); + if (lastUnderscore == -1) { + break tryLocaleNameVariations; + } + localeName = localeName.substring(0, lastUnderscore); + } + return createNegativeLookupResult(); + } + + } + +} http://git-wip-us.apache.org/repos/asf/incubator-freemarker/blob/b0e08ccb/src/main/java/org/apache/freemarker/core/templateresolver/impl/FileTemplateLoader.java ---------------------------------------------------------------------- diff --git a/src/main/java/org/apache/freemarker/core/templateresolver/impl/FileTemplateLoader.java b/src/main/java/org/apache/freemarker/core/templateresolver/impl/FileTemplateLoader.java new file mode 100644 index 0000000..46998ab --- /dev/null +++ b/src/main/java/org/apache/freemarker/core/templateresolver/impl/FileTemplateLoader.java @@ -0,0 +1,384 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.freemarker.core.templateresolver.impl; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.Serializable; +import java.security.AccessController; +import java.security.PrivilegedAction; +import java.security.PrivilegedActionException; +import java.security.PrivilegedExceptionAction; +import java.util.Objects; + +import org.apache.freemarker.core.Configuration; +import org.apache.freemarker.core._CoreLogs; +import org.apache.freemarker.core.templateresolver.TemplateLoader; +import org.apache.freemarker.core.templateresolver.TemplateLoaderSession; +import org.apache.freemarker.core.templateresolver.TemplateLoadingResult; +import org.apache.freemarker.core.templateresolver.TemplateLoadingSource; +import org.apache.freemarker.core.util.SecurityUtilities; +import org.apache.freemarker.core.util.StringUtil; +import org.slf4j.Logger; + +/** + * A {@link TemplateLoader} that uses files inside a specified directory as the source of templates. By default it does + * security checks on the <em>canonical</em> path that will prevent it serving templates outside that specified + * directory. If you want symbolic links that point outside the template directory to work, you need to disable this + * feature by using {@link #FileTemplateLoader(File, boolean)} with {@code true} second argument, but before that, + * check the security implications there! + */ +public class FileTemplateLoader implements TemplateLoader { + + /** + * By setting this Java system property to {@code true}, you can change the default of + * {@code #getEmulateCaseSensitiveFileSystem()}. + */ + public static String SYSTEM_PROPERTY_NAME_EMULATE_CASE_SENSITIVE_FILE_SYSTEM + = "org.freemarker.emulateCaseSensitiveFileSystem"; + private static final boolean EMULATE_CASE_SENSITIVE_FILE_SYSTEM_DEFAULT; + static { + final String s = SecurityUtilities.getSystemProperty(SYSTEM_PROPERTY_NAME_EMULATE_CASE_SENSITIVE_FILE_SYSTEM, + "false"); + boolean emuCaseSensFS; + try { + emuCaseSensFS = StringUtil.getYesNo(s); + } catch (Exception e) { + emuCaseSensFS = false; + } + EMULATE_CASE_SENSITIVE_FILE_SYSTEM_DEFAULT = emuCaseSensFS; + } + + private static final int CASE_CHECH_CACHE_HARD_SIZE = 50; + private static final int CASE_CHECK_CACHE__SOFT_SIZE = 1000; + private static final boolean SEP_IS_SLASH = File.separatorChar == '/'; + + private static final Logger LOG = _CoreLogs.TEMPLATE_RESOLVER; + + public final File baseDir; + private final String canonicalBasePath; + private boolean emulateCaseSensitiveFileSystem; + private MruCacheStorage correctCasePaths; + + /** + * Creates a new file template loader that will use the specified directory + * as the base directory for loading templates. It will not allow access to + * template files that are accessible through symlinks that point outside + * the base directory. + * @param baseDir the base directory for loading templates + */ + public FileTemplateLoader(final File baseDir) + throws IOException { + this(baseDir, false); + } + + /** + * Creates a new file template loader that will use the specified directory as the base directory for loading + * templates. See the parameters for allowing symlinks that point outside the base directory. + * + * @param baseDir + * the base directory for loading templates + * + * @param disableCanonicalPathCheck + * If {@code true}, it will not check if the file to be loaded is inside the {@code baseDir} or not, + * according the <em>canonical</em> paths of the {@code baseDir} and the file to load. Note that + * {@link Configuration#getTemplate(String)} and (its overloads) already prevents backing out from the + * template directory with paths like {@code /../../../etc/password}, however, that can be circumvented + * with symbolic links or other file system features. If you really want to use symbolic links that point + * outside the {@code baseDir}, set this parameter to {@code true}, but then be very careful with + * template paths that are supplied by the visitor or an external system. + */ + public FileTemplateLoader(final File baseDir, final boolean disableCanonicalPathCheck) + throws IOException { + try { + Object[] retval = AccessController.doPrivileged(new PrivilegedExceptionAction<Object[]>() { + @Override + public Object[] run() throws IOException { + if (!baseDir.exists()) { + throw new FileNotFoundException(baseDir + " does not exist."); + } + if (!baseDir.isDirectory()) { + throw new IOException(baseDir + " is not a directory."); + } + Object[] retval = new Object[2]; + if (disableCanonicalPathCheck) { + retval[0] = baseDir; + retval[1] = null; + } else { + retval[0] = baseDir.getCanonicalFile(); + String basePath = ((File) retval[0]).getPath(); + // Most canonical paths don't end with File.separator, + // but some does. Like, "C:\" VS "C:\templates". + if (!basePath.endsWith(File.separator)) { + basePath += File.separatorChar; + } + retval[1] = basePath; + } + return retval; + } + }); + this.baseDir = (File) retval[0]; + this.canonicalBasePath = (String) retval[1]; + + setEmulateCaseSensitiveFileSystem(getEmulateCaseSensitiveFileSystemDefault()); + } catch (PrivilegedActionException e) { + throw (IOException) e.getException(); + } + } + + private File getFile(final String name) throws IOException { + try { + return AccessController.doPrivileged(new PrivilegedExceptionAction<File>() { + @Override + public File run() throws IOException { + File source = new File(baseDir, SEP_IS_SLASH ? name : + name.replace('/', File.separatorChar)); + if (!source.isFile()) { + return null; + } + // Security check for inadvertently returning something + // outside the template directory when linking is not + // allowed. + if (canonicalBasePath != null) { + String normalized = source.getCanonicalPath(); + if (!normalized.startsWith(canonicalBasePath)) { + throw new SecurityException(source.getAbsolutePath() + + " resolves to " + normalized + " which " + + " doesn't start with " + canonicalBasePath); + } + } + + if (emulateCaseSensitiveFileSystem && !isNameCaseCorrect(source)) { + return null; + } + + return source; + } + }); + } catch (PrivilegedActionException e) { + throw (IOException) e.getException(); + } + } + + private long getLastModified(final File templateSource) { + return (AccessController.<Long>doPrivileged(new PrivilegedAction<Long>() { + @Override + public Long run() { + return Long.valueOf((templateSource).lastModified()); + } + })).longValue(); + } + + private InputStream getInputStream(final File templateSource) + throws IOException { + try { + return AccessController.doPrivileged(new PrivilegedExceptionAction<InputStream>() { + @Override + public InputStream run() throws IOException { + return new FileInputStream(templateSource); + } + }); + } catch (PrivilegedActionException e) { + throw (IOException) e.getException(); + } + } + + /** + * Called by {@link #getFile(String)} when {@link #getEmulateCaseSensitiveFileSystem()} is {@code true}. + */ + private boolean isNameCaseCorrect(File source) throws IOException { + final String sourcePath = source.getPath(); + synchronized (correctCasePaths) { + if (correctCasePaths.get(sourcePath) != null) { + return true; + } + } + + final File parentDir = source.getParentFile(); + if (parentDir != null) { + if (!baseDir.equals(parentDir) && !isNameCaseCorrect(parentDir)) { + return false; + } + + final String[] listing = parentDir.list(); + if (listing != null) { + final String fileName = source.getName(); + + boolean identicalNameFound = false; + for (int i = 0; !identicalNameFound && i < listing.length; i++) { + if (fileName.equals(listing[i])) { + identicalNameFound = true; + } + } + + if (!identicalNameFound) { + // If we find a similarly named file that only differs in case, then this is a file-not-found. + for (int i = 0; i < listing.length; i++) { + final String listingEntry = listing[i]; + if (fileName.equalsIgnoreCase(listingEntry)) { + if (LOG.isDebugEnabled()) { + LOG.debug("Emulating file-not-found because of letter case differences to the " + + "real file, for: {}", sourcePath); + } + return false; + } + } + } + } + } + + synchronized (correctCasePaths) { + correctCasePaths.put(sourcePath, Boolean.TRUE); + } + return true; + } + + /** + * Returns the base directory in which the templates are searched. This comes from the constructor argument, but + * it's possibly a canonicalized version of that. + * + * @since 2.3.21 + */ + public File getBaseDirectory() { + return baseDir; + } + + /** + * Intended for development only, checks if the template name matches the case (upper VS lower case letters) of the + * actual file name, and if it doesn't, it emulates a file-not-found even if the file system is case insensitive. + * This is useful when developing application on Windows, which will be later installed on Linux, OS X, etc. This + * check can be resource intensive, as to check the file name the directories involved, up to the + * {@link #getBaseDirectory()} directory, must be listed. Positive results (matching case) will be cached without + * expiration time. + * + * <p>The default in {@link FileTemplateLoader} is {@code false}, but subclasses may change they by overriding + * {@link #getEmulateCaseSensitiveFileSystemDefault()}. + * + * @since 2.3.23 + */ + public void setEmulateCaseSensitiveFileSystem(boolean nameCaseChecked) { + // Ensure that the cache exists exactly when needed: + if (nameCaseChecked) { + if (correctCasePaths == null) { + correctCasePaths = new MruCacheStorage(CASE_CHECH_CACHE_HARD_SIZE, CASE_CHECK_CACHE__SOFT_SIZE); + } + } else { + correctCasePaths = null; + } + + this.emulateCaseSensitiveFileSystem = nameCaseChecked; + } + + /** + * Getter pair of {@link #setEmulateCaseSensitiveFileSystem(boolean)}. + * + * @since 2.3.23 + */ + public boolean getEmulateCaseSensitiveFileSystem() { + return emulateCaseSensitiveFileSystem; + } + + /** + * Returns the default of {@link #getEmulateCaseSensitiveFileSystem()}. In {@link FileTemplateLoader} it's + * {@code false}, unless the {@link #SYSTEM_PROPERTY_NAME_EMULATE_CASE_SENSITIVE_FILE_SYSTEM} system property was + * set to {@code true}, but this can be overridden here in custom subclasses. For example, if your environment + * defines something like developer mode, you may want to override this to return {@code true} on Windows. + * + * @since 2.3.23 + */ + protected boolean getEmulateCaseSensitiveFileSystemDefault() { + return EMULATE_CASE_SENSITIVE_FILE_SYSTEM_DEFAULT; + } + + /** + * Show class name and some details that are useful in template-not-found errors. + * + * @since 2.3.21 + */ + @Override + public String toString() { + // We don't StringUtil.jQuote paths here, because on Windows there will be \\-s then that some may find + // confusing. + return _TemplateLoaderUtils.getClassNameForToString(this) + "(" + + "baseDir=\"" + baseDir + "\"" + + (canonicalBasePath != null ? ", canonicalBasePath=\"" + canonicalBasePath + "\"" : "") + + (emulateCaseSensitiveFileSystem ? ", emulateCaseSensitiveFileSystem=true" : "") + + ")"; + } + + @Override + public TemplateLoaderSession createSession() { + return null; + } + + @Override + public TemplateLoadingResult load(String name, TemplateLoadingSource ifSourceDiffersFrom, + Serializable ifVersionDiffersFrom, TemplateLoaderSession session) throws IOException { + File file = getFile(name); + if (file == null) { + return TemplateLoadingResult.NOT_FOUND; + } + + FileTemplateLoadingSource source = new FileTemplateLoadingSource(file); + + long lmd = getLastModified(file); + Long version = lmd != -1 ? lmd : null; + + if (ifSourceDiffersFrom != null && ifSourceDiffersFrom.equals(source) + && Objects.equals(ifVersionDiffersFrom, version)) { + return TemplateLoadingResult.NOT_MODIFIED; + } + + return new TemplateLoadingResult(source, version, getInputStream(file), null); + } + + @Override + public void resetState() { + // Does nothing + } + + @SuppressWarnings("serial") + private static class FileTemplateLoadingSource implements TemplateLoadingSource { + + private final File file; + + FileTemplateLoadingSource(File file) { + this.file = file; + } + + @Override + public int hashCode() { + return file.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null) return false; + if (getClass() != obj.getClass()) return false; + return file.equals(((FileTemplateLoadingSource) obj).file); + } + + } + +}
