001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.rest.server.view.thymeleaf;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020import static org.apache.juneau.commons.utils.StringUtils.*;
021
022import java.io.*;
023
024import org.apache.juneau.commons.utils.*;
025import org.apache.juneau.http.response.*;
026import org.apache.juneau.rest.server.*;
027import org.apache.juneau.rest.server.view.*;
028import org.thymeleaf.*;
029import org.thymeleaf.templatemode.*;
030import org.thymeleaf.templateresolver.*;
031
032/**
033 * Flavor-neutral worker bean carrying the Thymeleaf raw-template-dispatch logic shared by the
034 * {@link ThymeleafMixin} mixin, the {@link ThymeleafServlet} servlet, and the
035 * {@link ThymeleafResource} child flavors.
036 *
037 * <p>
038 * This is the <i>flavor-neutral worker</i> for the Thymeleaf capability: it is <b>not</b> one
039 * of the three {@code @Rest} flavor classes &mdash; it is a plain bean that implements
040 * {@link RawTemplateDispatcher} and holds the capability state ({@code basePath} /
041 * {@code cacheTemplates} / {@code templateMode} / lazy default engine) plus the raw-serve and
042 * engine-resolution logic. Each flavor independently holds an instance of this worker and delegates
043 * to it, so no flavor is another flavor's worker.
044 *
045 * <h5 class='section'>See Also:</h5><ul>
046 *    <li class='jc'>{@link ThymeleafMixin}
047 *    <li class='jc'>{@link ThymeleafServlet}
048 *    <li class='jc'>{@link ThymeleafResource}
049 *    <li class='jic'>{@link RawTemplateDispatcher}
050 * </ul>
051 *
052 * @since 10.0.0
053 */
054@SuppressWarnings({
055   "java:S1192", // Duplicate string literals are Thymeleaf template attribute keys; intentional
056   "resource" // Closeables here are framework-managed and not owned/closed by this class; not a real leak.
057})
058public class ThymeleafDispatcher implements RawTemplateDispatcher {
059
060   /** Default base path applied when no {@link Builder#basePath(String)} call has been made. */
061   public static final String DEFAULT_BASE_PATH = "/";
062
063   /** Default template-cache flag &mdash; production-safe; opt out per-builder for dev. */
064   public static final boolean DEFAULT_CACHE_TEMPLATES = true;
065
066   /** Default template mode for the bridge's fallback engine. */
067   public static final TemplateMode DEFAULT_TEMPLATE_MODE = TemplateMode.HTML;
068
069   private final String basePath;
070   private final boolean cacheTemplates;
071   private final TemplateMode templateMode;
072
073   // Lazy bridge-default engine. Built on first call to resolveTemplateEngine(...) when no
074   // TemplateEngine bean is registered in the request's BeanStore. Volatile so the
075   // double-checked-locking idiom is safe under concurrent first-request load.
076   @SuppressWarnings({
077      "java:S3077" // volatile is required here for correct double-checked-locking safe-publication of the lazily-created defaultEngine in resolveTemplateEngine(); the reference is publish-once and never compound-mutated.
078   })
079   private volatile TemplateEngine defaultEngine;
080
081   /**
082    * Creates a new builder.
083    *
084    * @return A new builder.
085    */
086   public static Builder create() {
087      return new Builder();
088   }
089
090   /**
091    * Builder constructor.
092    *
093    * @param builder The builder. Must not be {@code null}.
094    */
095   protected ThymeleafDispatcher(Builder builder) {
096      basePath = builder.basePath;
097      cacheTemplates = builder.cacheTemplates;
098      templateMode = builder.templateMode;
099   }
100
101   /**
102    * Returns the base path under which template resources are resolved.
103    *
104    * @return The base path. Never {@code null}.
105    */
106   public String getBasePath() {
107      return basePath;
108   }
109
110   /**
111    * Returns whether the bridge's default engine caches resolved templates.
112    *
113    * @return The cache flag. Defaults to {@link #DEFAULT_CACHE_TEMPLATES}.
114    */
115   public boolean isCacheTemplates() {
116      return cacheTemplates;
117   }
118
119   /**
120    * Returns the template mode used by the bridge's default engine.
121    *
122    * @return The template mode. Defaults to {@link #DEFAULT_TEMPLATE_MODE}.
123    */
124   public TemplateMode getTemplateMode() {
125      return templateMode;
126   }
127
128   /**
129    * Resolves the active {@link TemplateEngine}.
130    *
131    * <p>
132    * Lookup order:
133    * <ol class='spaced-list'>
134    *    <li>{@code req.getContext().getBeanStore().getBean(TemplateEngine.class)} &mdash; covers
135    *       Spring Boot autoconfig (`SpringTemplateEngine` is-a `TemplateEngine`) and any
136    *       user-supplied bean.
137    *    <li>Lazy bridge default &mdash; constructed on first call when no engine bean is
138    *       registered. Carries a single {@link ClassLoaderTemplateResolver} keyed off
139    *       ({@link #getBasePath() basePath}, {@code .html}, {@link #getTemplateMode() templateMode},
140    *       {@link #isCacheTemplates() cacheTemplates}).
141    * </ol>
142    *
143    * @param req The current REST request.
144    * @return The active template engine. Never {@code null}.
145    */
146   public TemplateEngine resolveTemplateEngine(RestRequest req) {
147      var bean = req.getContext().getBeanStore().getBean(TemplateEngine.class);
148      if (bean.isPresent())
149         return bean.get();
150      var local = defaultEngine;
151      if (local == null) {
152         synchronized (this) {
153            local = defaultEngine;
154            if (local == null) {
155               local = buildDefaultEngine();
156               defaultEngine = local;
157            }
158         }
159      }
160      return local;
161   }
162
163   /**
164    * Constructs the bridge-default {@link TemplateEngine}.
165    *
166    * <p>
167    * Single {@link ClassLoaderTemplateResolver} with {@code prefix=basePath},
168    * {@code suffix=".html"}, {@code templateMode=templateMode}, and
169    * {@code cacheable=cacheTemplates}. Subclasses may override to plug in custom resolvers /
170    * dialects without registering a separate {@code @Bean TemplateEngine}.
171    *
172    * @return A new {@link TemplateEngine} instance.
173    */
174   protected TemplateEngine buildDefaultEngine() {
175      var resolver = new ClassLoaderTemplateResolver();
176      resolver.setPrefix(basePath);
177      resolver.setSuffix(".html");
178      resolver.setTemplateMode(templateMode);
179      resolver.setCacheable(cacheTemplates);
180      resolver.setCharacterEncoding("UTF-8");
181      var engine = new TemplateEngine();
182      engine.setTemplateResolver(resolver);
183      return engine;
184   }
185
186   /**
187    * Renders a raw template through the Thymeleaf engine onto the response.
188    *
189    * @param path The trailing path segment after the mount prefix (the template name relative to
190    *    the configured {@link #getBasePath() base path}; trailing {@code .html} stripped).
191    * @param req The current REST request.
192    * @param res The current REST response.
193    * @throws IOException If the underlying response writer fails.
194    * @throws BasicHttpException On boundary violation (403), missing engine (500), or render
195    *    failure (500).
196    */
197   @Override /* RawTemplateDispatcher */
198   public void render(String path, RestRequest req, RestResponse res)
199         throws IOException, BasicHttpException {
200
201      // The engine resolver re-adds the .html suffix; strip if present so callers can request
202      // /thymeleaf/hello, /thymeleaf/hello.html, or /thymeleaf/admin/dashboard.html uniformly.
203      var template = (path == null) ? "" : path;
204      if (template.endsWith(".html"))
205         template = template.substring(0, template.length() - ".html".length());
206
207      // resolveVirtualPathSafely validates that the resolved virtual path stays inside the
208      // configured basePath. The traversal check operates on basePath + template so a template
209      // name like "../../etc/passwd" or "a/b/../../../secret" is rejected before reaching the
210      // engine. We re-derive the engine-relative template name from the safe result by
211      // stripping the (already-normalized) basePath prefix.
212      String safeTemplate;
213      try {
214         var resolved = FileUtils.resolveVirtualPathSafely(basePath, template);
215         safeTemplate = stripBasePath(basePath, resolved);
216      } catch (IllegalArgumentException ex) {
217         throw new Forbidden("Path escapes configured base path.");
218      }
219
220      try {
221         var engine = resolveTemplateEngine(req);
222         var ctx = new org.thymeleaf.context.Context(req.getLocale());
223         if (! res.containsHeader("Content-Type"))
224            res.setHeader("Content-Type", ThymeleafViewRenderer.DEFAULT_CONTENT_TYPE);
225         engine.process(safeTemplate, ctx, res.getWriter());
226      } catch (LinkageError ex) {
227         throw new InternalServerError(ex, ThymeleafViewRenderer.NO_ENGINE_DIAGNOSTIC);
228      } catch (IOException | BasicHttpException ex) {
229         throw ex;
230      } catch (Exception ex) {
231         throw new InternalServerError(ex, "Thymeleaf render failed for '%s'", safeTemplate);
232      }
233   }
234
235   /**
236    * Strips the normalized {@code basePath} prefix from a {@code resolveVirtualPathSafely}
237    * result so the leftover string is engine-relative (the resolver re-adds the configured
238    * prefix + suffix).
239    *
240    * <p>
241    * Normalization mirrors {@code FileUtils.resolveVirtualPathSafely}: a {@code null} / empty
242    * base normalizes to {@code "/"}; otherwise the base is guaranteed to start with {@code "/"}
243    * and end with {@code "/"} once the helper has normalized it.
244    *
245    * @param base The configured base path (typically {@code "/templates/"}).
246    * @param resolved The output of {@code resolveVirtualPathSafely} (always starts with the
247    *    normalized base).
248    * @return The engine-relative template name (e.g. {@code "hello"} for base
249    *    {@code "/templates/"} and resolved {@code "/templates/hello"}).
250    */
251   static String stripBasePath(String base, String resolved) {
252      var bp = isEmpty(base) ? "/" : base;
253      if (! bp.endsWith("/"))
254         bp = bp + "/";
255      if (! bp.startsWith("/"))
256         bp = "/" + bp;
257      if (resolved.startsWith(bp))
258         return resolved.substring(bp.length());
259      throw iaex("Resolved path '%s' does not start with base '%s'", resolved, bp);
260   }
261
262   /**
263    * Builder for {@link ThymeleafDispatcher}.
264    */
265   public static class Builder {
266
267      String basePath = DEFAULT_BASE_PATH;
268      boolean cacheTemplates = DEFAULT_CACHE_TEMPLATES;
269      TemplateMode templateMode = DEFAULT_TEMPLATE_MODE;
270
271      /** Constructor &mdash; package access for {@link ThymeleafDispatcher#create()}. */
272      protected Builder() {}
273
274      /**
275       * Sets the classpath base path under which template resources are resolved.
276       *
277       * @param value The base path. {@code null} or blank values reset to the default
278       *    {@link ThymeleafDispatcher#DEFAULT_BASE_PATH}.
279       * @return This object.
280       */
281      public Builder basePath(String value) {
282         basePath = isBlank(value) ? DEFAULT_BASE_PATH : value;
283         return this;
284      }
285
286      /**
287       * Sets whether the bridge's default engine caches resolved templates.
288       *
289       * @param value The new value.
290       * @return This object.
291       */
292      public Builder cacheTemplates(boolean value) {
293         cacheTemplates = value;
294         return this;
295      }
296
297      /**
298       * Sets the template mode used by the bridge's default engine.
299       *
300       * @param value The new value. {@code null} resets to {@link #DEFAULT_TEMPLATE_MODE}.
301       * @return This object.
302       */
303      public Builder templateMode(TemplateMode value) {
304         templateMode = (value == null) ? DEFAULT_TEMPLATE_MODE : value;
305         return this;
306      }
307
308      /**
309       * Reads the current base path setting (test/inspection helper).
310       *
311       * @return The base path. Never {@code null}.
312       */
313      public String getBasePath() {
314         return basePath;
315      }
316
317      /**
318       * Reads the current cache-templates setting (test/inspection helper).
319       *
320       * @return The cache flag.
321       */
322      public boolean isCacheTemplates() {
323         return cacheTemplates;
324      }
325
326      /**
327       * Reads the current template-mode setting (test/inspection helper).
328       *
329       * @return The template mode. Never {@code null}.
330       */
331      public TemplateMode getTemplateMode() {
332         return templateMode;
333      }
334
335      /**
336       * Builds the {@link ThymeleafDispatcher}.
337       *
338       * @return A new {@link ThymeleafDispatcher} instance.
339       */
340      public ThymeleafDispatcher build() {
341         if (basePath == null)
342            throw iaex("basePath must not be null");
343         return new ThymeleafDispatcher(this);
344      }
345   }
346}