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.console.freemarker; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.io.*; 022import java.util.*; 023import java.util.logging.*; 024 025import org.apache.juneau.rest.server.*; 026import org.apache.juneau.console.*; 027import org.apache.juneau.rest.server.filter.*; 028import org.apache.juneau.rest.server.view.freemarker.*; 029 030import freemarker.core.*; 031import freemarker.template.*; 032 033/** 034 * The {@code <@console>} document-shell FreeMarker directive. 035 * 036 * <p> 037 * Renders its body into the per-render {@link PageCapture} (theme, navigation, header/footer slots, the banner region 038 * before {@code <@main/>}), then writes the document: the server-rendered {@code <head>}, the {@code <body>} tag, the 039 * {@code #juneau-page} contract island, one {@code <template>} per markup body, the {@code juneau-console.js} shell, 040 * and the server-emitted scripts. The shell renders the header, nav, cards and footer from the contract. 041 * 042 * <h5 class='section'>Example:</h5> 043 * <p class='bcode'> 044 * <@console brand="Release Manager" icon="/app/logo.svg" title="Releases"> 045 * <@navigation> 046 * <@node id="home" label="Home" href="/home"/> 047 * </@navigation> 048 * <@main/> 049 * <@footer text="ACME"/> 050 * </@console> 051 * </p> 052 * 053 * @since 10.0.0 054 */ 055@SuppressWarnings({ 056 "java:S1192" // Duplicated literals read more clearly inline than as constants 057}) 058public final class ConsoleDirectiveModel implements TemplateDirectiveModel { 059 060 /** The shared-variable name this directive registers under. */ 061 public static final String NAME = "console"; 062 063 private static final Logger LOG = Logger.getLogger(ConsoleDirectiveModel.class.getName()); 064 065 static final Set<String> ATTRS = Set.of("theme", "icon", "favicon", "brand", "title", "chrome", "facts", "scheme", "schemeToggle"); 066 067 private final boolean devMode; 068 private final CardTypeRegistry cardTypes; 069 private final ToolkitPackRegistry packs; 070 071 ConsoleDirectiveModel(boolean devMode, CardTypeRegistry cardTypes, ToolkitPackRegistry packs) { 072 this.devMode = devMode; 073 this.cardTypes = cardTypes; 074 this.packs = packs; 075 } 076 077 @Override 078 @SuppressWarnings({ 079 "java:S3776", // Attribute validation and document emission read best as one straight-line method. 080 "resource", // FreeMarker owns env.getOut(); closing it would close the HTTP response. 081 "unchecked" // FreeMarker's raw params Map is String-keyed by contract. 082 }) 083 public void execute(Environment env, @SuppressWarnings("rawtypes") Map params, TemplateModel[] loopVars, 084 TemplateDirectiveBody body) throws TemplateException, IOException { 085 Map<String, TemplateModel> p = params; 086 if (p.containsKey("format")) 087 throw FtlAttrLists.reject("<@console> has no format= attribute."); 088 FtlAttrLists.rejectUnknown(p, NAME, ATTRS); 089 090 var req = FreemarkerRenderScope.request(); 091 if (n(req)) 092 throw FtlAttrLists.reject("<@console> needs FreemarkerRenderScope.request() (renderer wrap)."); 093 094 var cap = PageCapture.of(env); 095 if (cap.consoleOpen || cap.consoleDone) 096 throw FtlAttrLists.reject("<@console> cannot be nested inside another <@console>."); 097 098 // Fail-closed validate an explicit theme= attribute up front; a nested <@theme> (or, absent both, a theme seeded 099 // by a page spec) decides the final name below. 100 var themeAttr = FtlAttrLists.scalar(p, "theme"); 101 if (! themeAttr.isEmpty() && ! ConsoleChromeMixin.BUILTIN_THEME_NAMES.contains(themeAttr)) 102 throw FtlAttrLists.reject("<@console> unknown theme name '" + themeAttr + "'. Built-in themes: " 103 + String.join(", ", ConsoleChromeMixin.BUILTIN_THEME_NAMES) + "."); 104 105 // Colour scheme: an explicit attribute wins over a page-spec value, which wins over the default. 106 var schemeAttr = FtlAttrLists.scalar(p, "scheme"); 107 if (! schemeAttr.isEmpty() && ! ConsoleChromeMixin.SCHEMES.contains(schemeAttr)) 108 throw FtlAttrLists.reject("<@console> unknown scheme '" + schemeAttr + "'. Schemes: " 109 + String.join(", ", ConsoleChromeMixin.SCHEMES) + "."); 110 var capScheme = nn(cap.scheme()) ? cap.scheme() : ConsoleChromeMixin.SCHEMES.get(0); 111 var scheme = schemeAttr.isEmpty() ? capScheme : schemeAttr; 112 var schemeToggle = FtlAttrLists.strictBoolean(p, NAME, "schemeToggle", cap.schemeToggle()); 113 114 var icon = FtlAttrLists.scalar(p, "icon"); 115 var favicon = FtlAttrLists.scalar(p, "favicon"); 116 var brand = FtlAttrLists.scalar(p, "brand"); 117 var titleAttr = FtlAttrLists.scalar(p, "title"); 118 var chrome = FtlAttrLists.strictBoolean(p, NAME, "chrome", false); 119 var factsAttr = FtlAttrLists.map(p, NAME, "facts"); 120 121 // Document <title>: a title seeded by a page spec wins; else the title= attribute, else brand=; omitted when none. 122 var docTitle = titleAttr.isEmpty() ? brand : titleAttr; 123 cap.titleDefault(docTitle); 124 cap.header(h -> { 125 if (! brand.isEmpty()) 126 h.title(brand); 127 if (! icon.isEmpty()) 128 h.logo(icon, req.getUriResolver().resolve("context:/"), brand.isEmpty() ? null : brand); 129 h.chrome(chrome); 130 }); 131 132 // Chrome-side facts: last-wins against <@facts>; the one strict check against PageSpec.facts runs at </@console>. 133 if (! factsAttr.isEmpty()) 134 cap.mergeChromeFacts(factsAttr); 135 136 cap.consoleTheme = themeAttr.isEmpty() ? null : themeAttr; 137 cap.consoleOpen = true; 138 try { 139 if (nn(body)) 140 body.render(cap.consoleBuffer()); 141 } finally { 142 cap.consoleOpen = false; 143 } 144 cap.checkConsoleClose(); 145 try { 146 cap.checkWiring(cardTypes); 147 } catch (IllegalArgumentException e) { 148 throw FtlAttrLists.reject(e.getMessage()); 149 } 150 cap.resolveActiveNav(); 151 152 // A page that never reached <@page> (a PageSpec rendered straight through the chrome) has not had its seeded 153 // toolkits and card-required packs resolved yet; <@page> does it itself, so never twice. 154 // The guard is a pure optimisation: toolkitAssets overwrites, and nothing after <@page> can change its inputs 155 // (<@card> is only legal inside <@page>), so a second resolve would be identical and no test can observe it. 156 if (! cap.assetsResolved()) 157 PageDirectiveModel.resolveAssets(env, cap, packs); 158 159 if (n(cap.themeCssUrl)) { 160 // No <@theme> ran: an explicit theme= wins, else a theme seeded by a page spec is inherited, else the default. 161 var themeName = themeAttr.isEmpty() ? cap.themeOrNull() : themeAttr; 162 if (themeName == null) 163 themeName = ConsoleChromeMixin.BUILTIN_THEME_NAMES.get(0); 164 cap.themeCssUrl = ConsoleChromeMixin.themeAssetUrl(req, themeName); 165 cap.theme(themeName); 166 } 167 168 // Each embed asks for its own value, so a masking boundary never repeats the same masked token in one page. 169 var metaToken = csrfToken(req); 170 var bodyToken = csrfToken(req); 171 var csrfHeader = req.getAttribute(RequestBoundaryFilter.HEADER_ATTRIBUTE).asString().orElse(null); 172 var hasCsrf = inb(metaToken) && inb(bodyToken); 173 174 // A page that carries a token must not be cached, and the header has to be set before any of the body is 175 // written. A response that is already committed can no longer be marked, so it never carries the token: both 176 // automatic embeds and any spec- or <@body>-authored csrf attributes are dropped, and the page's own writes then 177 // fail visibly instead of leaking the secret. 178 var bodyAttrs = cap.bodyAttrs; 179 if (req.getResponse().getHttpServletResponse().isCommitted()) { 180 var omitted = hasCsrf; 181 hasCsrf = false; 182 bodyAttrs = cap.bodyAttrsWithout("data-juneau-csrf", "data-juneau-csrf-header"); 183 if (bodyAttrs != null && ! bodyAttrs.equals(cap.bodyAttrs.strip())) 184 omitted = true; 185 if (omitted) 186 LOG.fine("Response already committed; the CSRF token is not embedded in the page."); 187 } else if (hasCsrf || cap.hasBodyAttr("data-juneau-csrf")) { 188 RequestBoundaryFilter.markTokenBearing(req.getResponse().getHttpServletResponse()); 189 } 190 191 if (devMode) { 192 var findings = PageContractSchema.get().validate(cap.toContractJson(), cap.templates().keySet()); 193 if (! findings.isEmpty()) 194 throw FtlAttrLists.reject(f("Page contract failed schema validation: '%s'.", String.join("; ", findings))); 195 } 196 197 var out = env.getOut(); 198 var nonce = req.getAttribute(RestRequest.CSP_NONCE_ATTR).as(String.class).orElse(null); 199 var htmlAttrs = ConsoleChromeMixin.schemeHtmlAttributes(scheme, schemeToggle); 200 out.write("<!DOCTYPE html>\n<html lang=\"en\"" + (htmlAttrs.isEmpty() ? "" : " " + htmlAttrs) + ">\n<head>\n"); 201 // The pre-paint scheme script runs before any stylesheet so the first paint already has the right scheme. 202 out.write(ConsoleChromeMixin.schemeScriptElement(nonce) + "\n"); 203 out.write("<meta charset=\"utf-8\">\n"); 204 out.write("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"); 205 if (! cap.title().isEmpty()) 206 out.write("<title>" + attrEscape(cap.title()) + "</title>\n"); 207 if (! cap.tab().isEmpty()) 208 out.write("<meta name=\"page-tab\" content=\"" + attrEscape(cap.tab()) + "\">\n"); 209 if (hasCsrf) 210 out.write("<meta name=\"csrf-token\" content=\"" + attrEscape(metaToken) + "\">\n"); 211 if (! favicon.isEmpty()) 212 out.write("<link rel=\"icon\" href=\"" + attrEscape(favicon) + "\">\n"); 213 out.write("<link rel=\"stylesheet\" href=\"" + attrEscape(ConsoleChromeMixin.chromeCssUrl(req)) + "\">\n"); 214 out.write("<link rel=\"stylesheet\" href=\"" + attrEscape(ConsoleChromeMixin.themeAssetUrl(req, "dark")) + "\">\n"); 215 out.write("<link rel=\"stylesheet\" href=\"" + attrEscape(cap.themeCssUrl) + "\">\n"); 216 if (nn(cap.themeOverrideBlock)) 217 out.write("<style" + ConsoleChromeMixin.nonceAttr(nonce) + ">" + cap.themeOverrideBlock + "</style>\n"); 218 // App CSS that must lose to page-local css=: emitted BEFORE the page css so a page rule still wins. 219 if (nn(cap.headBeforePageCss)) 220 out.write(cap.headBeforePageCss); 221 for (var href : cap.vendorCss()) 222 out.write("<link rel=\"stylesheet\" href=\"" + attrEscape(href) + "\" data-toolkit-css>\n"); 223 for (var href : cap.cssHrefs()) 224 out.write("<link rel=\"stylesheet\" href=\"" + attrEscape(href) + "\">\n"); 225 for (var href : cap.runtimeCss()) 226 out.write("<link rel=\"stylesheet\" href=\"" + attrEscape(href) + "\" data-toolkit-css>\n"); 227 if (nn(cap.head)) 228 out.write(cap.head); 229 out.write("\n</head>\n"); 230 231 out.write("<body"); 232 // A spec- or <@body>-authored csrf attribute (merged by checkConsoleClose above) wins over the auto-detection. 233 // Either name suppresses both auto attributes, so a token and its header never come from different sources. 234 if (hasCsrf && ! cap.hasBodyAttr("data-juneau-csrf") && ! cap.hasBodyAttr("data-juneau-csrf-header")) { 235 out.write(" data-juneau-csrf=\"" + attrEscape(bodyToken) + "\""); 236 if (inb(csrfHeader)) 237 out.write(" data-juneau-csrf-header=\"" + attrEscape(csrfHeader) + "\""); 238 } 239 if (inb(bodyAttrs)) 240 out.write(" " + bodyAttrs.trim()); 241 out.write(">\n"); 242 243 cap.shellUrl(ConsoleChromeMixin.consoleJsUrl(req)); 244 cap.writeBody(out); 245 out.write("\n"); 246 247 for (var src : cap.vendorJs()) 248 out.write("<script src=\"" + attrEscape(src) + "\" data-toolkit-js></script>\n"); 249 for (var src : cap.runtimeJs()) 250 out.write("<script src=\"" + attrEscape(src) + "\" data-toolkit-js></script>\n"); 251 // The badge runtime mounts header.badges from the contract on load. Not a toolkit pack: a chrome-template 252 // <@badge> is only known here, after the page's packs were resolved. 253 if (cap.hasBadges()) 254 out.write("<script src=\"" + attrEscape(ConsoleChromeMixin.badgesJsUrl(req)) + "\" data-toolkit-js></script>\n"); 255 // App scripts that must run after the toolkit pack but before the page-local init= scripts. 256 if (nn(cap.scriptsAfterToolkit)) 257 out.write(cap.scriptsAfterToolkit); 258 for (var src : cap.initScripts()) 259 out.write("<script src=\"" + attrEscape(src) + "\"></script>\n"); 260 if (nn(cap.scripts)) 261 out.write(cap.scripts); 262 out.write("\n</body>\n</html>\n"); 263 cap.consoleDone = true; 264 } 265 266 /** 267 * One CSRF token value for one embed, or <jk>null</jk> when the request carries none. 268 * 269 * <p> 270 * Prefers the filter's token supplier, read straight from the servlet request: converting it through the REST 271 * attribute view would stringify the supplier itself. Only the filter's own {@link RequestBoundaryFilter.TokenSupplier} 272 * is trusted; a value of any other type under that name was set by request code, so no token is embedded. Falls 273 * back to the single string value for a filter that publishes only that. 274 */ 275 private static String csrfToken(RestRequest req) { 276 var supplier = req.getHttpServletRequest().getAttribute(RequestBoundaryFilter.TOKEN_SUPPLIER_ATTRIBUTE); 277 if (supplier instanceof RequestBoundaryFilter.TokenSupplier s) 278 return s.get(); 279 if (supplier != null) 280 return null; 281 return req.getAttribute(RequestBoundaryFilter.TOKEN_ATTRIBUTE).asString().orElse(null); 282 } 283 284 /** Minimal HTML-attribute escape for an author-/request-supplied value placed in a double-quoted attribute. */ 285 private static String attrEscape(String s) { 286 return s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); 287 } 288}