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.regex.*;
024
025import org.apache.juneau.marshall.collections.*;
026import org.apache.juneau.marshall.json5.*;
027import org.apache.juneau.console.*;
028import org.apache.juneau.console.views.*;
029
030import freemarker.core.*;
031import freemarker.template.*;
032import freemarker.template.utility.*;
033
034/**
035 * The {@code <@card>} FreeMarker directive: adds one entry to the page contract's {@code cards[]}.
036 *
037 * <p>
038 * {@code html} (the default) and {@code terminal-view} are built in. {@code datatables}, {@code terminal} and every other
039 * well-formed type name is dispatched through the host's {@link CardTypeRegistry}: a registered
040 * {@link CardTypeHandler} validates it, and an unregistered type falls back to the generic passthrough. Every type other
041 * than {@code html} requires {@code id=}.
042 *
043 * <p>
044 * {@code type="html"} (the default) takes exactly one content source: a body (emitted as
045 * {@code <template data-card="{id}">}), {@code template=} (reuses another card's template), or {@code src=} (fetched
046 * same-origin by the shell). {@code type="datatables"} takes {@code src=} or a JSON5 catalog body and becomes the C1
047 * bridge card. {@code type="terminal-view"} takes a JSON5
048 * {@code {contractVersion: '1', terminalView: {...}}} body and becomes the terminal-view bridge card (see {@link TerminalViewDef}).
049 * {@code type="terminal"} takes a JSON5 {@code {contractVersion: '1', terminal: {...}}} body and becomes the terminal
050 * card (see {@code TerminalDef}). A card without {@code id=} gets {@code jc-card-N}. {@code <@card>} is only valid inside
051 * {@code <@page>}; markup around it becomes {@code jc-seg-N} segments, so the authored order is kept.
052 *
053 * <h5 class='section'>Example:</h5>
054 * <p class='bcode'>
055 *    &lt;@page tab="releases"&gt;
056 *      &lt;@card id="intro" title="Releases"&gt;&lt;p&gt;All releases.&lt;/p&gt;&lt;/@card&gt;
057 *      &lt;@card type="datatables" id="releases"&gt;
058 *        { dataUrl: '/rest/releases/data', columns: [ { key: 'name', label: 'Name' } ] }
059 *      &lt;/@card&gt;
060 *    &lt;/@page&gt;
061 * </p>
062 *
063 * @since 10.0.0
064 */
065@SuppressWarnings({
066   "java:S1192", // Duplicated literals read more clearly inline than as constants
067   "java:S3776" // Validation and rendering chains many independent guards; splitting them adds no clarity.
068})
069public final class CardDirectiveModel implements TemplateDirectiveModel {
070
071   /** The shared-variable name this directive registers under. */
072   public static final String NAME = "card";
073
074   static final Set<String> ATTRS = Set.of("type", "id", "title", "src", "template", "requires", "class", "ref", "subscribes", "publishes", "sanitize");
075
076   static final Set<String> REMOVED_TYPES = Set.of("js", "json", "calendar");
077
078   private static final Pattern CLASS_TOKEN_RE = Pattern.compile("^[A-Za-z_-][A-Za-z0-9_-]*$");
079
080   private static final Pattern TYPE_RE = Pattern.compile("^[a-z][a-z0-9-]{0,31}$");
081
082   /** Contract base keys copied onto {@link CardSpec}'s own setters rather than {@link CardSpec#body}. */
083   private static final Set<String> BASE_KEYS = Set.of("id", "type", "title", "src", "template");
084
085   private final CardRequirements requirements;
086   private final CardTypeRegistry cardTypes;
087
088   CardDirectiveModel(CardRequirements requirements, CardTypeRegistry cardTypes) {
089      this.requirements = requirements;
090      this.cardTypes = cardTypes;
091   }
092
093   @Override
094   @SuppressWarnings({
095      "unchecked" // FreeMarker's raw params Map is String-keyed by contract.
096   })
097   public void execute(Environment env, @SuppressWarnings("rawtypes") Map params, TemplateModel[] loopVars,
098         TemplateDirectiveBody body) throws TemplateException, IOException {
099      Map<String, TemplateModel> p = params;
100      if (p.containsKey("format"))
101         throw FtlAttrLists.reject("<@card> uses type= only; format= is not a valid attribute.");
102      FtlAttrLists.rejectUnknown(p, NAME, ATTRS);
103
104      if (p.containsKey("ref")) {
105         placeByRef(env, p, FtlAttrLists.scalar(p, "ref"), body);
106         return;
107      }
108
109      var type = FtlAttrLists.scalar(p, "type");
110      var authoredId = FtlAttrLists.scalar(p, "id");
111      var requires = FtlAttrLists.list(p, NAME, "requires");
112      var cssClass = cssClass(p, authoredId);
113      var sanitize = FtlAttrLists.strictBoolean(p, NAME, "sanitize", false);
114      var subsAttr = p.containsKey("subscribes") ? DeepUnwrap.unwrap(p.get("subscribes")) : null;
115      var pubsAttr = p.containsKey("publishes") ? DeepUnwrap.unwrap(p.get("publishes")) : null;
116      if ((subsAttr != null || pubsAttr != null) && authoredId.isEmpty())
117         throw FtlAttrLists.reject("<@card> with subscribes= or publishes= needs id=.");
118      if (REMOVED_TYPES.contains(type))
119         throw FtlAttrLists.reject(f(
120            "<@card id='%s'> type='%s' was removed in 10.0.0; use type='html' with a <template>, or a registered card type.",
121            authoredId, type));
122      if (! type.isEmpty() && ! TYPE_RE.matcher(type).matches())
123         throw FtlAttrLists.reject(f("<@card id='%s'> type='%s' must match ^[a-z][a-z0-9-]{0,31}$.", authoredId, type));
124
125      var cap = PageCapture.get(env);
126      if (n(cap) || ! cap.pageOpen)
127         throw FtlAttrLists.reject("<@card> must be nested inside <@page>.");
128      if (cap.cardOpen)
129         throw FtlAttrLists.reject("<@card> cannot be nested inside another <@card>.");
130      if (! authoredId.isEmpty())
131         FtlAttrLists.checkId(NAME, authoredId);
132
133      // Markup before this card becomes its own segment, so it stays ahead of the card.
134      cap.flushSegment();
135
136      String markup;
137      cap.cardOpen = true;
138      try {
139         markup = PageCapture.render(body);
140      } finally {
141         cap.cardOpen = false;
142      }
143
144      var title = FtlAttrLists.scalar(p, "title");
145      var src = FtlAttrLists.scalar(p, "src");
146      var template = FtlAttrLists.scalar(p, "template");
147      var cardType = type.isEmpty() ? "html" : type;
148      if (sanitize && (neq(cardType, "html") || src.isEmpty()))
149         throw FtlAttrLists.reject(f("<@card id='%s'> sanitize= applies only to an html card with src=.", authoredId));
150      var id = authoredId.isEmpty() && eq(cardType, "html") ? cap.nextCardId() : authoredId;
151      Map<String,Object> cardBody = Map.of();
152      if (eq(cardType, "terminal-view"))
153         terminalViewCard(cap, id, title, src, template, markup, cssClass, pubsAttr, subsAttr);
154      else if (eq(cardType, "html"))
155         htmlCard(cap, id, title, src, template, markup, cssClass, sanitize, pubsAttr, subsAttr);
156      else
157         cardBody = registryCard(cap, cardType, id, title, src, template, markup, cssClass, pubsAttr, subsAttr).bodyMap();
158      recordRequirements(cap, cardType, id, requires, cardBody);
159   }
160
161   /**
162    * {@code <@card ref="id"/>}: places a PageSpec-built card at this point in the card order.  Present-but-empty
163    * {@code ref=} takes this path too and fails as an unknown card.  Guards run in order: another attribute (E-B7),
164    * then page and nesting, then a non-blank body (E-B7), then the placement checks (E-B5, E-B6).
165    */
166   private static void placeByRef(Environment env, Map<String, TemplateModel> p, String ref, TemplateDirectiveBody body)
167         throws TemplateException, IOException {
168      var other = firstOtherAttr(p);
169      if (other != null)
170         throw FtlAttrLists.reject(f("<@card ref='%s'> must not also set '%s' or a body.", ref, other));
171      var cap = PageCapture.get(env);
172      if (n(cap) || ! cap.pageOpen)
173         throw FtlAttrLists.reject("<@card> must be nested inside <@page>.");
174      if (cap.cardOpen)
175         throw FtlAttrLists.reject("<@card> cannot be nested inside another <@card>.");
176      String markup;
177      cap.cardOpen = true;
178      try {
179         markup = PageCapture.render(body);
180      } finally {
181         cap.cardOpen = false;
182      }
183      if (! markup.isBlank())
184         throw FtlAttrLists.reject(f("<@card ref='%s'> must not have a body.", ref));
185      cap.flushSegment();
186      cap.placeBuiltCard(ref);
187   }
188
189   private static String firstOtherAttr(Map<String, TemplateModel> p) {
190      for (var a : List.of("type", "id", "title", "src", "template", "requires", "class", "subscribes", "publishes", "sanitize"))
191         if (p.containsKey(a))
192            return a;
193      return null;
194   }
195
196   /**
197    * Reads {@code class=}: a space-separated list of CSS class tokens, each matching
198    * {@code [A-Za-z_-][A-Za-z0-9_-]*}. Returns the tokens joined by single spaces, or {@code ""} when absent.
199    */
200   private static String cssClass(Map<String, TemplateModel> p, String authoredId) throws TemplateModelException {
201      var raw = FtlAttrLists.scalar(p, "class");
202      if (raw.isEmpty())
203         return "";
204      var tokens = raw.split("\\s+");
205      for (var t : tokens)
206         if (! CLASS_TOKEN_RE.matcher(t).matches())
207            throw FtlAttrLists.reject(f(
208               "<@card id='%s'> class='%s' must be a space-separated list of CSS class names matching [A-Za-z_-][A-Za-z0-9_-]*.",
209               authoredId, raw));
210      return String.join(" ", tokens);
211   }
212
213   // Lowers subscribes= / publishes= (attribute or JSON5 body) onto the card through the Subscription / TopicDecl builders.
214   private static void wire(CardSpec card, Object pubs, Object subs) throws TemplateModelException {
215      if (pubs == null && subs == null)
216         return;
217      try {
218         card.wiring(CardBusAttrs.publications(card.id(), pubs), CardBusAttrs.subscriptions(card.id(), subs));
219      } catch (IllegalArgumentException e) {
220         throw FtlAttrLists.reject(e.getMessage());
221      }
222   }
223
224   // The one place a card's packs are recorded; <@page> resolves them after its body.  The handler's body-aware packs
225   // (for example a datatables card whose detail region is a job-log region) are unioned in.
226   void recordRequirements(PageCapture cap, String type, String id, List<String> requires, Map<String,Object> body)
227         throws TemplateModelException {
228      try {
229         var bodyPacks = cardTypes.handler(type).requiredPacks(new JsonMap(body));
230         cap.require(requirements.forCard(type, id, requires, bodyPacks));
231      } catch (IllegalArgumentException e) {
232         throw FtlAttrLists.reject(e.getMessage());
233      }
234   }
235
236   @SuppressWarnings({
237      "java:S107" // Internal helper whose parameters are the render/validation context.
238   })
239   private static void htmlCard(PageCapture cap, String id, String title, String src, String template, String markup,
240         String cssClass, boolean sanitize, Object pubs, Object subs)
241         throws TemplateModelException {
242      var hasBody = ! markup.isBlank();
243      var sources = (hasBody ? 1 : 0) + (src.isEmpty() ? 0 : 1) + (template.isEmpty() ? 0 : 1);
244      if (sources == 0)
245         throw FtlAttrLists.reject(f("<@card id='%s'> type='html' requires a body or src=.", id));
246      if (sources > 1)
247         throw FtlAttrLists.reject(f(
248            "<@card id='%s'> type='html' takes exactly one of a body, template= or src=.", id));
249      var card = CardSpec.html(id);
250      if (! title.isEmpty())
251         card.title(title);
252      if (! src.isEmpty())
253         card.src(src);
254      if (! template.isEmpty())
255         card.template(template);
256      if (! cssClass.isEmpty())
257         card.cssClass(cssClass);
258      if (sanitize)
259         card.sanitize(true);
260      wire(card, pubs, subs);
261      cap.addCard(card, hasBody ? markup : null);
262   }
263
264   /** Names whichever of {@code src=} and {@code template=} is present. */
265   static String notAllowed(String src, String template) {
266      if (src.isEmpty())
267         return "template= is not allowed";
268      if (template.isEmpty())
269         return "src= is not allowed";
270      return "src= and template= are not allowed";
271   }
272
273   /**
274    * Every type other than {@code html} and {@code terminal-view}: validated and lifted by the host's
275    * {@link CardTypeRegistry} (a registered handler such as {@code datatables}, {@code terminal} or {@code job-log}, or the
276    * generic passthrough for an unregistered type).
277    */
278   @SuppressWarnings({
279      "java:S107" // Internal helper whose parameters are the render/validation context.
280   })
281   private CardSpec registryCard(PageCapture cap, String type, String id, String title, String src, String template,
282         String markup, String cssClass, Object pubs, Object subs) throws TemplateModelException {
283      if (id.isEmpty())
284         throw FtlAttrLists.reject(f("<@card type=\"%s\"> requires id=.", type));
285      var hasBody = ! markup.isBlank();
286      if (eq(type, "terminal") || eq(type, "job-log")) {
287         if (! (src.isEmpty() && template.isEmpty()))
288            throw FtlAttrLists.reject(f("<@card id='%s'> type='%s' takes its options as the body; %s.", id, type,
289               notAllowed(src, template)));
290      } else if (eq(type, "chart")) {
291         if (! template.isEmpty())
292            throw FtlAttrLists.reject(f(
293               "<@card id='%s'> type='chart' takes a JSON5 body (plus src= for remote data); template= is not allowed.", id));
294      } else if (eq(type, "datatables")) {
295         if (! template.isEmpty())
296            throw FtlAttrLists.reject(f(
297               "<@card id='%s'> type='datatables' takes src= or a JSON5 body; template= is not allowed.", id));
298      } else if ((hasBody ? 1 : 0) + (src.isEmpty() ? 0 : 1) + (template.isEmpty() ? 0 : 1) > 1) {
299         throw FtlAttrLists.reject(f(
300            "<@card id='%s'> type='%s' takes at most one of a body, template= or src=.", id, type));
301      }
302      var captured = new boolean[1];
303      var source = CardSource.create(type, id).templateSink((cid, m) -> captured[0] = true);
304      if (! title.isEmpty())
305         source.title(title);
306      if (! src.isEmpty())
307         source.src(src);
308      if (! template.isEmpty())
309         source.template(template);
310      if (hasBody)
311         source.body(markup);
312      JsonMap card;
313      try {
314         card = cardTypes.toCard(source.build());
315      } catch (IllegalArgumentException e) {
316         throw FtlAttrLists.reject(e.getMessage());
317      }
318      var bodySubs = card.remove("subscribes");
319      var bodyPubs = card.remove("publishes");
320      if (subs != null && bodySubs != null)
321         throw FtlAttrLists.reject(f("<@card id='%s'> body key 'subscribes' is reserved; set it as an attribute.", id));
322      if (pubs != null && bodyPubs != null)
323         throw FtlAttrLists.reject(f("<@card id='%s'> body key 'publishes' is reserved; set it as an attribute.", id));
324      var spec = toCardSpec(card);
325      if (! cssClass.isEmpty())
326         spec.cssClass(cssClass);
327      wire(spec, pubs != null ? pubs : bodyPubs, subs != null ? subs : bodySubs);
328      cap.addCard(spec, captured[0] ? markup : null);
329      return spec;
330   }
331
332   /**
333    * Lifts a card source through the registry into the {@link CardSpec} {@link PageCapture} wants; the same path
334    * {@code <@card>} takes for a registered type.  {@code subscribes} / {@code publishes} found in the handler's
335    * result (a JSON5 body) are wired onto the card, and refused when the authored card already declares them, as
336    * {@code <@card>} refuses them alongside the attribute.
337    *
338    * @param source The card source.
339    * @param authored The authored card whose wiring the result takes.
340    * @return The lifted card.
341    * @throws TemplateModelException If the registry or its handler rejects the source, or its wiring conflicts.
342    */
343   CardSpec lift(CardSource source, CardSpec authored) throws TemplateModelException {
344      JsonMap card;
345      try {
346         card = cardTypes.toCard(source);
347      } catch (IllegalArgumentException e) {
348         throw FtlAttrLists.reject(e.getMessage());
349      }
350      var id = source.id();
351      var bodySubs = card.remove("subscribes");
352      var bodyPubs = card.remove("publishes");
353      if (authored.hasSubscribes() && bodySubs != null)
354         throw FtlAttrLists.reject(f("<@card id='%s'> body key 'subscribes' is reserved; set it as an attribute.", id));
355      if (authored.hasPublishes() && bodyPubs != null)
356         throw FtlAttrLists.reject(f("<@card id='%s'> body key 'publishes' is reserved; set it as an attribute.", id));
357      var spec = toCardSpec(card);
358      wire(spec, bodyPubs, bodySubs);
359      return spec.wiringFrom(authored);
360   }
361
362   /** Converts the flat {@link CardTypeRegistry#toCard} result into the {@link CardSpec} {@link PageCapture} wants. */
363   private static CardSpec toCardSpec(JsonMap card) {
364      var spec = CardSpec.of((String)card.get("type"), (String)card.get("id"));
365      if (card.get("title") instanceof String v)
366         spec.title(v);
367      if (card.get("src") instanceof String v)
368         spec.src(v);
369      if (card.get("template") instanceof String v)
370         spec.template(v);
371      for (var e : card.entrySet())
372         if (! BASE_KEYS.contains(e.getKey()))
373            spec.body(e.getKey(), e.getValue());
374      return spec;
375   }
376
377   private static final Set<String> RUN_VIEW_BODY_KEYS = new LinkedHashSet<>(List.of("contractVersion", "terminalView"));
378
379   /**
380    * The terminal-view bridge: a JSON5 {@code {contractVersion: '1', terminalView: {...}}} body whose {@code terminalView} keys
381    * are exactly {@link TerminalViewDef#KEYS}, validated through {@link TerminalViewDef} so an unsafe URL fails the
382    * render. The shell mounts it through {@code JuneauViews.terminalView.mount}.
383    */
384   @SuppressWarnings({
385      "java:S107", // Internal helper whose parameters are the render/validation context.
386      "unchecked" // JSON5 object keys are always strings.
387   })
388   private static void terminalViewCard(PageCapture cap, String id, String title, String src, String template,
389         String markup, String cssClass, Object pubs, Object subs) throws TemplateModelException {
390      if (id.isEmpty())
391         throw FtlAttrLists.reject("<@card type=\"terminal-view\"> requires id=.");
392      if (! (src.isEmpty() && template.isEmpty()))
393         throw FtlAttrLists.reject(f("<@card id='%s'> type='terminal-view' takes its options as the body; %s.", id,
394            notAllowed(src, template)));
395      var trimmed = markup.trim();
396      if (! trimmed.startsWith("{"))
397         throw FtlAttrLists.reject(f(
398            "<@card id='%s'> type='terminal-view' requires a JSON5 body { contractVersion: '1', terminalView: {...} }.", id));
399      JsonMap envelope;
400      try {
401         envelope = Json5Parser.DEFAULT.read(trimmed, JsonMap.class);
402      } catch (org.apache.juneau.marshall.parser.ParseException ex) {
403         throw FtlAttrLists.reject("Card JSON5 is invalid: " + ex.getMessage());
404      }
405      var bodySubs = envelope.remove("subscribes");
406      var bodyPubs = envelope.remove("publishes");
407      if (subs != null && bodySubs != null)
408         throw FtlAttrLists.reject(f("<@card id='%s'> body key 'subscribes' is reserved; set it as an attribute.", id));
409      if (pubs != null && bodyPubs != null)
410         throw FtlAttrLists.reject(f("<@card id='%s'> body key 'publishes' is reserved; set it as an attribute.", id));
411      for (var k : envelope.keySet())
412         if (! RUN_VIEW_BODY_KEYS.contains(k))
413            throw FtlAttrLists.reject(f(
414               "<@card id='%s'> type='terminal-view' unknown key '%s'; allowed: contractVersion, terminalView, subscribes, publishes.", id, k));
415      var version = envelope.get("contractVersion");
416      if (neq("1", version))
417         throw FtlAttrLists.reject(f(
418            "<@card id='%s'> type='terminal-view' requires contractVersion: '1'; got '%s'.", id, version));
419      if (! (envelope.get("terminalView") instanceof Map<?,?> terminalView))
420         throw FtlAttrLists.reject(f("<@card id='%s'> type='terminal-view' requires a terminalView object.", id));
421      var def = TerminalViewDef.fromMap(id, (Map<String,?>)terminalView).validate();
422      var card = CardSpec.of("terminal-view", id);
423      if (! title.isEmpty())
424         card.title(title);
425      if (! cssClass.isEmpty())
426         card.cssClass(cssClass);
427      card.body("terminalView", def.toMap());
428      wire(card, pubs != null ? pubs : bodyPubs, subs != null ? subs : bodySubs);
429      cap.addCard(card, null);
430   }
431}