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 * <@page tab="releases"> 056 * <@card id="intro" title="Releases"><p>All releases.</p></@card> 057 * <@card type="datatables" id="releases"> 058 * { dataUrl: '/rest/releases/data', columns: [ { key: 'name', label: 'Name' } ] } 059 * </@card> 060 * </@page> 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}