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 *    &lt;@console brand="Release Manager" icon="/app/logo.svg" title="Releases"&gt;
045 *      &lt;@navigation&gt;
046 *        &lt;@node id="home" label="Home" href="/home"/&gt;
047 *      &lt;/@navigation&gt;
048 *      &lt;@main/&gt;
049 *      &lt;@footer text="ACME"/&gt;
050 *    &lt;/@console&gt;
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;");
287   }
288}