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 — 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 — 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)} — covers 135 * Spring Boot autoconfig (`SpringTemplateEngine` is-a `TemplateEngine`) and any 136 * user-supplied bean. 137 * <li>Lazy bridge default — 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 — 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}