001/* 002 * JGrapes Event Driven Framework 003 * Copyright (C) 2017-2018 Michael N. Lipp 004 * 005 * This program is free software; you can redistribute it and/or modify it 006 * under the terms of the GNU Affero General Public License as published by 007 * the Free Software Foundation; either version 3 of the License, or 008 * (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, but 011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 013 * for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License along 016 * with this program; if not, see <http://www.gnu.org/licenses/>. 017 */ 018 019package org.jgrapes.webconsole.base; 020 021import java.io.IOException; 022import java.io.InputStream; 023import java.net.URI; 024import java.net.URISyntaxException; 025import java.net.URL; 026import java.net.URLConnection; 027import java.nio.CharBuffer; 028import java.nio.charset.StandardCharsets; 029import java.text.ParseException; 030import java.time.Duration; 031import java.util.ArrayList; 032import java.util.Arrays; 033import java.util.HashMap; 034import java.util.HashSet; 035import java.util.List; 036import java.util.ListIterator; 037import java.util.Locale; 038import java.util.Map; 039import java.util.Optional; 040import java.util.ResourceBundle; 041import java.util.Set; 042import java.util.UUID; 043import java.util.concurrent.CompletableFuture; 044import java.util.concurrent.ConcurrentHashMap; 045import java.util.concurrent.ExecutorService; 046import java.util.concurrent.Executors; 047import java.util.function.Supplier; 048import org.jdrupes.httpcodec.protocols.http.HttpConstants.HttpStatus; 049import org.jdrupes.httpcodec.protocols.http.HttpField; 050import org.jdrupes.httpcodec.protocols.http.HttpResponse; 051import org.jdrupes.httpcodec.types.Converters; 052import org.jgrapes.core.Channel; 053import org.jgrapes.core.ClassChannel; 054import org.jgrapes.core.Component; 055import org.jgrapes.core.EventPipeline; 056import org.jgrapes.core.Manager; 057import org.jgrapes.core.annotation.Handler; 058import org.jgrapes.core.annotation.HandlerDefinition.ChannelReplacements; 059import org.jgrapes.http.LanguageSelector.Selection; 060import org.jgrapes.http.ResourcePattern; 061import org.jgrapes.http.ResponseCreationSupport; 062import org.jgrapes.http.Session; 063import org.jgrapes.http.annotation.RequestHandler; 064import org.jgrapes.http.events.DiscardSession; 065import org.jgrapes.http.events.ProtocolSwitchAccepted; 066import org.jgrapes.http.events.Request; 067import org.jgrapes.http.events.Request.In.Get; 068import org.jgrapes.http.events.Response; 069import org.jgrapes.http.events.Upgraded; 070import org.jgrapes.io.IOSubchannel; 071import org.jgrapes.io.events.Close; 072import org.jgrapes.io.events.Closed; 073import org.jgrapes.io.events.Input; 074import org.jgrapes.io.events.Output; 075import org.jgrapes.io.util.CharBufferWriter; 076import org.jgrapes.io.util.LinkedIOSubchannel; 077import org.jgrapes.webconsole.base.events.ConletResourceRequest; 078import org.jgrapes.webconsole.base.events.ConsoleCommand; 079import org.jgrapes.webconsole.base.events.JsonInput; 080import org.jgrapes.webconsole.base.events.PageResourceRequest; 081import org.jgrapes.webconsole.base.events.ResourceRequestCompleted; 082import org.jgrapes.webconsole.base.events.SetLocale; 083import org.jgrapes.webconsole.base.events.SetLocaleCompleted; 084import org.jgrapes.webconsole.base.events.SimpleConsoleCommand; 085 086/** 087 * The server side base class for the web console single page 088 * application (SPA). Its main tasks are to provide resources using 089 * {@link Request}/{@link Response} events (see {@link #onGet} 090 * for details about the different kinds of resources), to create 091 * the {@link ConsoleConnection}s for new WebSocket connections 092 * (see {@link #onUpgraded}) and to convert the JSON RPC messages 093 * received from the browser via the web socket to {@link JsonInput} 094 * events and fire them on the corresponding {@link ConsoleConnection} 095 * channel. 096 * 097 * The class has a counter part in the browser, the `jgconsole` 098 * JavaScript module (see 099 * <a href="jsdoc/classes/Console.html">functions</a>) 100 * that can be loaded as `console-base-resource/jgconsole.js` 101 * (relative to the configured prefix). 102 * 103 * The class also provides handlers for some console related events 104 * (i.e. fired on the attached {@link WebConsole}'s channel) that 105 * affect the console representation in the browser. These handlers 106 * are declared with class channel {@link ConsoleConnection} which 107 * is replaced using the {@link ChannelReplacements} mechanism. 108 */ 109@SuppressWarnings({ "PMD.ExcessiveImports", "PMD.TooManyMethods", 110 "PMD.GodClass", "PMD.CouplingBetweenObjects" }) 111public abstract class ConsoleWeblet extends Component { 112 113 private static final String CONSOLE_SESSION_IDS 114 = ConsoleWeblet.class.getName() + ".consoleConnectionId"; 115 private static final String UTF_8 = "utf-8"; 116 @SuppressWarnings({ "PMD.FieldNamingConventions", 117 "PMD.UseConcurrentHashMap" }) 118 private static final Map<Locale, List<Locale>> localesByLanguage 119 = new HashMap<>(); 120 121 static { 122 Set<Locale> available 123 = new HashSet<>(Arrays.asList(Locale.getAvailableLocales())); 124 for (Locale locale : available) { 125 String language = locale.getLanguage(); 126 if (language.isEmpty()) { 127 continue; 128 } 129 @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") 130 Locale langOnlyLocale = new Locale.Builder() 131 .setLanguage(language).build(); 132 if (!available.contains(langOnlyLocale) 133 || locale.equals(langOnlyLocale)) { 134 continue; 135 } 136 @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") 137 List<Locale> locales = localesByLanguage 138 .computeIfAbsent(langOnlyLocale, key -> new ArrayList<>()); 139 locales.add(locale); 140 } 141 } 142 143 private URI prefix; 144 private final WebConsole console; 145 private ResourcePattern requestPattern; 146 147 private final RenderSupport renderSupport = new RenderSupportImpl(); 148 private boolean useMinifiedResources = true; 149 private long csNetworkTimeout = 45_000; 150 private long csRefreshInterval = 30_000; 151 private long csInactivityTimeout = -1; 152 153 private List<Class<?>> consoleResourceSearchSeq; 154 private final List<Class<?>> resourceClasses = new ArrayList<>(); 155 private final ResourceBundle.Control resourceControl 156 = new ConsoleResourceBundleControl(resourceClasses); 157 @SuppressWarnings("PMD.UseConcurrentHashMap") 158 private Map<Locale, ResourceBundle> supportedLocales 159 = new HashMap<>(); 160 161 /** 162 * The class used in handler annotations to represent the 163 * console channel. 164 */ 165 protected class ConsoleChannel extends ClassChannel { 166 } 167 168 /** 169 * Instantiates a new console weblet. The weblet handles 170 * {@link Get} events for URIs that start with the 171 * specified prefix (see 172 * {@link #onGet(org.jgrapes.http.events.Request.In.Get, IOSubchannel)}). 173 * 174 * @param webletChannel the weblet channel 175 * @param consoleChannel the console channel 176 * @param consolePrefix the console prefix 177 */ 178 public ConsoleWeblet(Channel webletChannel, Channel consoleChannel, 179 URI consolePrefix) { 180 this(webletChannel, new WebConsole(consoleChannel)); 181 182 prefix = URI.create(consolePrefix.getPath().endsWith("/") 183 ? consolePrefix.getPath() 184 : consolePrefix.getPath() + "/"); 185 console.setView(this); 186 187 String consolePath = prefix.getPath(); 188 if (consolePath.endsWith("/")) { 189 consolePath = consolePath.substring(0, consolePath.length() - 1); 190 } 191 consolePath = consolePath + "|**"; 192 try { 193 requestPattern = new ResourcePattern(consolePath); 194 } catch (ParseException e) { 195 throw new IllegalArgumentException(e); 196 } 197 consoleResourceSearchSeq = consoleHierarchy(); 198 199 resourceClasses.addAll(consoleHierarchy()); 200 updateSupportedLocales(); 201 202 RequestHandler.Evaluator.add(this, "onGet", prefix + "**"); 203 RequestHandler.Evaluator.add(this, "onGetRedirect", 204 prefix.getPath().substring( 205 0, prefix.getPath().length() - 1)); 206 } 207 208 @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") 209 private ConsoleWeblet(Channel webletChannel, WebConsole console) { 210 super(webletChannel, ChannelReplacements.create() 211 .add(ConsoleChannel.class, console.channel())); 212 this.console = console; 213 attach(console); 214 } 215 216 /** 217 * Return the list of classes that form the current console 218 * weblet implementation. This consists of all classes from 219 * `getClass()` up to `ConsoleWeblet.class`. 220 * 221 * @return the list 222 */ 223 protected final List<Class<?>> consoleHierarchy() { 224 List<Class<?>> result = new ArrayList<>(); 225 Class<?> derivative = getClass(); 226 while (true) { 227 result.add(derivative); 228 if (derivative.equals(ConsoleWeblet.class)) { 229 break; 230 } 231 derivative = derivative.getSuperclass(); 232 } 233 return result; 234 } 235 236 /** 237 * Returns the name of the styling library or toolkit used by the console. 238 * This value is informative. It may, however, be used by 239 * a {@link PageResourceProviderFactory} to influence the creation 240 * of {@link PageResourceProvider}s. 241 * 242 * @return the value 243 */ 244 public abstract String styling(); 245 246 /** 247 * @return the prefix 248 */ 249 public URI prefix() { 250 return prefix; 251 } 252 253 /** 254 * Returns the automatically generated {@link WebConsole} component. 255 * 256 * @return the console 257 */ 258 public WebConsole console() { 259 return console; 260 } 261 262 /** 263 * Sets the console connection network timeout. The console connection 264 * will be removed if no messages have been received from the 265 * console page for the given duration. The value defaults to 45 seconds. 266 * 267 * @param timeout the timeout in milli seconds 268 * @return the console view for easy chaining 269 */ 270 public ConsoleWeblet setConnectionNetworkTimeout(Duration timeout) { 271 csNetworkTimeout = timeout.toMillis(); 272 return this; 273 } 274 275 /** 276 * Returns the console connection network timeout. 277 * 278 * @return the timeout 279 */ 280 public Duration connectionNetworkTimeout() { 281 return Duration.ofMillis(csNetworkTimeout); 282 } 283 284 /** 285 * Sets the console connection refresh interval. The console code in the 286 * browser will send a keep alive packet if there has been no user 287 * activity for more than the given period. The value 288 * defaults to 30 seconds. 289 * 290 * @param interval the interval 291 * @return the console view for easy chaining 292 */ 293 public ConsoleWeblet setConnectionRefreshInterval(Duration interval) { 294 csRefreshInterval = interval.toMillis(); 295 return this; 296 } 297 298 /** 299 * Returns the console connection refresh interval. 300 * 301 * @return the interval 302 */ 303 public Duration connectionRefreshInterval() { 304 return Duration.ofMillis(csRefreshInterval); 305 } 306 307 /** 308 * Sets the console connection inactivity timeout. If there has been no 309 * user activity for more than the given duration the 310 * console code stops sending keep alive packets and displays a 311 * message to the user. The value defaults to -1 (no timeout). 312 * 313 * @param timeout the timeout 314 * @return the console view for easy chaining 315 */ 316 public ConsoleWeblet setConnectionInactivityTimeout(Duration timeout) { 317 csInactivityTimeout = timeout.toMillis(); 318 return this; 319 } 320 321 /** 322 * Returns the console connection inactivity timeout. 323 * 324 * @return the timeout 325 */ 326 public Duration connectionInactivityTimeout() { 327 return Duration.ofMillis(csInactivityTimeout); 328 } 329 330 /** 331 * Returns whether resources are minified. 332 * 333 * @return the useMinifiedResources 334 */ 335 public boolean useMinifiedResources() { 336 return useMinifiedResources; 337 } 338 339 /** 340 * Determines if resources should be minified. 341 * 342 * @param useMinifiedResources the useMinifiedResources to set 343 */ 344 public void setUseMinifiedResources(boolean useMinifiedResources) { 345 this.useMinifiedResources = useMinifiedResources; 346 } 347 348 /** 349 * Provides the render support. 350 * 351 * @return the render support 352 */ 353 protected RenderSupport renderSupport() { 354 return renderSupport; 355 } 356 357 /** 358 * Prepends a class to the list of classes used to lookup console 359 * resources. See {@link ConsoleResourceBundleControl#newBundle}. 360 * Affects the content of the resource bundle returned by 361 * {@link #consoleResourceBundle(Locale)}. 362 * 363 * @param cls the class to prepend. 364 * @return the console weblet for easy chaining 365 */ 366 public ConsoleWeblet prependResourceBundleProvider(Class<?> cls) { 367 resourceClasses.add(0, cls); 368 updateSupportedLocales(); 369 return this; 370 } 371 372 /** 373 * Update the supported locales. 374 */ 375 @SuppressWarnings("PMD.AvoidDuplicateLiterals") 376 protected final void updateSupportedLocales() { 377 Map<Locale, ResourceBundle> collected = new ConcurrentHashMap<>(); 378 ResourceBundle.clearCache(ConsoleWeblet.class.getClassLoader()); 379 try (ExecutorService executor 380 = Executors.newVirtualThreadPerTaskExecutor()) { 381 List<CompletableFuture<Void>> futures = new ArrayList<>(); 382 for (var entry : localesByLanguage.entrySet()) { 383 futures.add(CompletableFuture.runAsync(() -> { 384 if (checkLocale(entry.getKey())) { 385 collected.put(entry.getKey(), 386 ResourceBundle.getBundle("l10n", entry.getKey(), 387 ConsoleWeblet.class.getClassLoader(), 388 resourceControl)); 389 for (var locale : entry.getValue()) { 390 if (checkLocale(locale)) { 391 collected.put(locale, 392 ResourceBundle.getBundle("l10n", locale, 393 ConsoleWeblet.class.getClassLoader(), 394 resourceControl)); 395 } 396 } 397 } 398 }, executor)); 399 } 400 CompletableFuture.allOf( 401 futures.toArray(new CompletableFuture[0])).join(); 402 } 403 supportedLocales = collected; 404 } 405 406 /** 407 * Check if a resource bundle for the locale exists. This is more 408 * efficient than actually trying to create the bundle. 409 * 410 * @param locale the locale 411 * @return true, if successful 412 */ 413 private boolean checkLocale(Locale locale) { 414 ListIterator<Class<?>> iter 415 = resourceClasses.listIterator(resourceClasses.size()); 416 // Known to exists 417 while (iter.hasPrevious()) { 418 Class<?> cls = iter.previous(); 419 String bundleName = resourceControl.toBundleName("l10n", locale); 420 var resourceName = resourceControl 421 .toResourceName(bundleName, "properties"); 422 URL url = cls.getResource(resourceName); 423 if (url == null) { 424 continue; 425 } 426 URLConnection connection; 427 try { 428 connection = url.openConnection(); 429 if (connection == null) { 430 continue; 431 } 432 try (InputStream inStream = connection.getInputStream()) { 433 if (inStream == null) { 434 continue; 435 } 436 } 437 return true; 438 } catch (IOException e) { 439 continue; 440 } 441 } 442 return false; 443 } 444 445 /** 446 * Return the console resources for a given locale. 447 * 448 * @param locale the locale 449 * @return the resource bundle 450 */ 451 public ResourceBundle consoleResourceBundle(Locale locale) { 452 return Optional.ofNullable(supportedLocales.get(locale)) 453 .orElseGet(() -> ResourceBundle.getBundle("l10n", locale, 454 ConsoleWeblet.class.getClassLoader(), resourceControl)); 455 } 456 457 /** 458 * Returns the supported locales and their resource bundles. 459 * 460 * @return the set of locales supported by the console and their 461 * resource bundles 462 */ 463 protected Map<Locale, ResourceBundle> supportedLocales() { 464 return supportedLocales; 465 } 466 467 /** 468 * Redirects `GET` requests without trailing slash. 469 * 470 * @param event the event 471 * @param channel the channel 472 * @throws InterruptedException the interrupted exception 473 * @throws IOException Signals that an I/O exception has occurred. 474 * @throws ParseException the parse exception 475 */ 476 @RequestHandler(dynamic = true) 477 public void onGetRedirect(Request.In.Get event, IOSubchannel channel) 478 throws InterruptedException, IOException, ParseException { 479 HttpResponse response = event.httpRequest().response().get(); 480 response.setStatus(HttpStatus.MOVED_PERMANENTLY) 481 .setContentType("text", "plain", UTF_8) 482 .setField(HttpField.LOCATION, prefix); 483 channel.respond(new Response(response)); 484 channel.respond(Output.from(prefix.toString() 485 .getBytes(StandardCharsets.UTF_8), true)); 486 event.setResult(true); 487 event.stop(); 488 } 489 490 /** 491 * Handle the `GET` requests for the various resources. The requests 492 * have to start with the prefix passed to the constructor. Further 493 * processing depends on the next path segment: 494 * 495 * * `.../console-base-resource`: Provide a resource associated 496 * with this class. The resources are: 497 * 498 * * `jgconsole.js`: The JavaScript module with helper classes 499 * * `console.css`: Some basic styles for conlets 500 * 501 * * `.../console-resource`: Invokes {@link #provideConsoleResource} 502 * with the remainder of the path. 503 * 504 * * `.../page-resource`: Invokes {@link #providePageResource} 505 * with the remainder of the path. 506 * 507 * * `.../conlet-resource`: Invokes {@link #provideConletResource} 508 * with the remainder of the path. 509 * 510 * * `.../console-connection`: Handled by this class. Used 511 * e.g. for initiating the web socket connection. 512 * 513 * @param event the event 514 * @param channel the channel 515 * @throws InterruptedException the interrupted exception 516 * @throws IOException Signals that an I/O exception has occurred. 517 * @throws ParseException the parse exception 518 */ 519 @RequestHandler(dynamic = true) 520 public void onGet(Request.In.Get event, IOSubchannel channel) 521 throws InterruptedException, IOException, ParseException { 522 // Already fulfilled? 523 if (event.fulfilled()) { 524 return; 525 } 526 527 // Request for console? (Only valid with session) 528 URI requestUri = event.requestUri(); 529 int prefixSegs = requestPattern.matches(requestUri); 530 if (prefixSegs < 0) { 531 return; 532 } 533 534 // Normalize and evaluate 535 String requestPath = ResourcePattern.removeSegments( 536 requestUri.getPath(), prefixSegs + 1); 537 String[] requestParts = ResourcePattern.split(requestPath, 1); 538 switch (requestParts[0]) { 539 case "": 540 // Because language is changed via websocket, locale cookie 541 // may be out-dated 542// event.associated(Selection.class) 543// .ifPresent(selection -> selection.prefer(selection.get()[0])); 544 // This is a console connection now (can be connected to) 545 Session session = Session.from(event); 546 UUID consoleConnectionId = UUID.randomUUID(); 547 @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" }) 548 Map<URI, UUID> knownIds = (Map<URI, UUID>) session.computeIfAbsent( 549 CONSOLE_SESSION_IDS, 550 newKey -> new ConcurrentHashMap<>()); 551 knownIds.put(prefix, consoleConnectionId); 552 // Finally render 553 renderConsole(event, channel, consoleConnectionId); 554 return; 555 case "console-resource": 556 provideConsoleResource(event, ResourcePattern.removeSegments( 557 requestUri.getPath(), prefixSegs + 2), channel); 558 return; 559 case "console-base-resource": 560 ResponseCreationSupport.sendStaticContent(event, channel, 561 path -> ConsoleWeblet.class.getResource(requestParts[1]), 562 null); 563 return; 564 case "page-resource": 565 providePageResource(event, channel, requestParts[1]); 566 return; 567 case "console-connection": 568 handleSessionRequest(event, channel, requestParts[1]); 569 return; 570 case "conlet-resource": 571 provideConletResource(event, channel, URI.create(requestParts[1])); 572 return; 573 default: 574 break; 575 } 576 } 577 578 /** 579 * Render the console page. 580 * 581 * @param event the event 582 * @param channel the channel 583 * @throws IOException Signals that an I/O exception has occurred. 584 * @throws InterruptedException the interrupted exception 585 */ 586 protected abstract void renderConsole(Request.In.Get event, 587 IOSubchannel channel, UUID consoleConnectionId) 588 throws IOException, InterruptedException; 589 590 /** 591 * Provide a console resource. The implementation tries to load the 592 * resource using {@link Class#getResource(String)} for each class 593 * in the class hierarchy, starting with the finally derived class. 594 * 595 * @param event the event 596 * @param requestPath the request path relativized to the 597 * common part for console resources 598 * @param channel the channel 599 */ 600 protected void provideConsoleResource(Request.In.Get event, 601 String requestPath, IOSubchannel channel) { 602 for (Class<?> cls : consoleResourceSearchSeq) { 603 if (ResponseCreationSupport.sendStaticContent(event, channel, 604 path -> cls.getResource(requestPath), 605 null)) { 606 break; 607 } 608 } 609 } 610 611 /** 612 * Prepends the given class to the list of classes searched by 613 * {@link #provideConsoleResource(Request.In.Get, String, IOSubchannel)}. 614 * 615 * @param cls the class to prepend 616 * @return the console weblet for easy chaining 617 */ 618 public ConsoleWeblet prependConsoleResourceProvider(Class<?> cls) { 619 consoleResourceSearchSeq.add(0, cls); 620 return this; 621 } 622 623 private void providePageResource(Request.In.Get event, IOSubchannel channel, 624 String resource) throws InterruptedException { 625 // Send events to providers on console's channel 626 PageResourceRequest pageResourceRequest = new PageResourceRequest( 627 WebConsoleUtils.uriFromPath(resource), 628 event.httpRequest().findValue(HttpField.IF_MODIFIED_SINCE, 629 Converters.DATE_TIME).orElse(null), 630 event.httpRequest(), channel, Session.from(event), renderSupport()); 631 event.setResult(true); 632 event.stop(); 633 fire(pageResourceRequest, consoleChannel(channel)); 634 } 635 636 @SuppressWarnings("PMD.EmptyCatchBlock") 637 private void provideConletResource(Request.In.Get event, 638 IOSubchannel channel, 639 URI resource) throws InterruptedException { 640 try { 641 String resPath = resource.getPath(); 642 int sep = resPath.indexOf('/'); 643 // Send events to web console components on console's channel 644 ConletResourceRequest conletRequest 645 = new ConletResourceRequest( 646 resPath.substring(0, sep), 647 new URI(null, null, resPath.substring(sep + 1), 648 event.requestUri().getQuery(), 649 event.requestUri().getFragment()), 650 event.httpRequest().findValue(HttpField.IF_MODIFIED_SINCE, 651 Converters.DATE_TIME).orElse(null), 652 event.httpRequest(), channel, 653 Session.from(event), renderSupport()); 654 // Make session available (associate with event, this is not 655 // a websocket request). 656 event.associated(Session.class, Supplier.class).ifPresent( 657 supplier -> conletRequest.setAssociated(Session.class, 658 supplier)); 659 event.setResult(true); 660 event.stop(); 661 fire(conletRequest, consoleChannel(channel)); 662 } catch (URISyntaxException e) { 663 // Won't happen, new URI derived from existing 664 } 665 } 666 667 /** 668 * The console channel for getting resources. Resource providers 669 * respond on the same event pipeline as they receive, because 670 * handling is just a mapping to {@link ResourceRequestCompleted}. 671 * 672 * @param channel the channel 673 * @return the IO subchannel 674 */ 675 private IOSubchannel consoleChannel(IOSubchannel channel) { 676 @SuppressWarnings("unchecked") 677 Optional<LinkedIOSubchannel> consoleChannel 678 = (Optional<LinkedIOSubchannel>) LinkedIOSubchannel 679 .downstreamChannel(console, channel); 680 return consoleChannel.orElseGet( 681 () -> new ConsoleResourceChannel( 682 console, channel, activeEventPipeline())); 683 } 684 685 /** 686 * Handles the {@link ResourceRequestCompleted} event. 687 * 688 * @param event the event 689 * @param channel the channel 690 * @throws IOException Signals that an I/O exception has occurred. 691 * @throws InterruptedException the interrupted exception 692 */ 693 @Handler(channels = ConsoleChannel.class) 694 public void onResourceRequestCompleted( 695 ResourceRequestCompleted event, ConsoleResourceChannel channel) 696 throws IOException, InterruptedException { 697 event.stop(); 698 if (event.event().get() == null) { 699 ResponseCreationSupport.sendResponse(event.event().httpRequest(), 700 event.event().httpChannel(), HttpStatus.NOT_FOUND); 701 return; 702 } 703 event.event().get().process(); 704 } 705 706 private void handleSessionRequest(Request.In.Get event, 707 IOSubchannel channel, String consoleConnectionId) 708 throws InterruptedException, IOException, ParseException { 709 // Must be WebSocket request. 710 if (!event.httpRequest().findField( 711 HttpField.UPGRADE, Converters.STRING_LIST) 712 .map(fld -> fld.value().containsIgnoreCase("websocket")) 713 .orElse(false)) { 714 return; 715 } 716 717 // Can only connect to sessions that have been prepared 718 // by loading the console. (Prevents using a newly created 719 // browser session from being (re-)connected to after a 720 // long disconnect or restart and, of course, CSF). 721 final Session browserSession = Session.from(event); 722 @SuppressWarnings("unchecked") 723 Map<URI, UUID> knownIds = (Map<URI, UUID>) browserSession 724 .computeIfAbsent(CONSOLE_SESSION_IDS, 725 newKey -> new ConcurrentHashMap<>()); 726 if (!UUID.fromString(consoleConnectionId) // NOPMD, note negation 727 .equals(knownIds.get(prefix))) { 728 channel.setAssociated(this, new String[2]); 729 } else { 730 channel.setAssociated(this, new String[] { 731 consoleConnectionId, 732 Optional.ofNullable(event.httpRequest().queryData() 733 .get("was")).map(vals -> vals.get(0)).orElse(null) 734 }); 735 } 736 channel.respond(new ProtocolSwitchAccepted(event, "websocket")); 737 event.stop(); 738 } 739 740 /** 741 * Handles a change of Locale for the console. 742 * 743 * @param event the event 744 * @param channel the channel 745 * @throws InterruptedException the interrupted exception 746 * @throws IOException Signals that an I/O exception has occurred. 747 */ 748 @Handler(channels = ConsoleChannel.class, priority = 10_000) 749 public void onSetLocale(SetLocale event, ConsoleConnection channel) 750 throws InterruptedException, IOException { 751 channel.setLocale(event.locale()); 752 Optional.ofNullable(channel.session()).flatMap( 753 s -> Optional.ofNullable((Selection) s.get(Selection.class))) 754 .ifPresent(selection -> { 755 supportedLocales.keySet().stream() 756 .filter(lang -> lang.equals(event.locale())).findFirst() 757 .ifPresent(selection::prefer); 758 channel.respond(new SimpleConsoleCommand("setLocalesCookie", 759 Converters.SET_COOKIE_STRING 760 .get(selection.getCookieSameSite()) 761 .asFieldValue(selection.getCookie()))); 762 }); 763 if (event.reload()) { 764 channel.respond(new SimpleConsoleCommand("reload")); 765 } 766 } 767 768 /** 769 * Sends a reload if the change of locale could not be handled by 770 * all conlets. 771 * 772 * @param event the event 773 * @param channel the channel 774 */ 775 @Handler(channels = ConsoleChannel.class) 776 public void onSetLocaleCompleted(SetLocaleCompleted event, 777 ConsoleConnection channel) { 778 if (event.event().reload()) { 779 channel.respond(new SimpleConsoleCommand("reload")); 780 } 781 } 782 783 /** 784 * Called when the connection has been upgraded. 785 * 786 * @param event the event 787 * @param wsChannel the ws channel 788 * @throws IOException Signals that an I/O exception has occurred. 789 */ 790 @Handler 791 public void onUpgraded(Upgraded event, IOSubchannel wsChannel) 792 throws IOException { 793 Optional<String[]> passedIn 794 = wsChannel.associated(this, String[].class); 795 if (!passedIn.isPresent()) { 796 return; 797 } 798 799 // Check if reload required 800 String[] connectionIds = passedIn.get(); 801 if (connectionIds[0] == null) { 802 @SuppressWarnings({ "resource", "PMD.CloseResource" }) 803 CharBufferWriter out = new CharBufferWriter(wsChannel, 804 wsChannel.responsePipeline()).suppressClose(); 805 new SimpleConsoleCommand("reload").emitJson(out); 806 out.close(); 807 event.stop(); 808 return; 809 } 810 811 // Get session 812 @SuppressWarnings("unchecked") 813 final Supplier<Optional<Session>> sessionSupplier 814 = (Supplier<Optional<Session>>) wsChannel 815 .associated(Session.class, Supplier.class).get(); 816 // Reuse old console connection if still available 817 ConsoleConnection connection 818 = Optional.ofNullable(connectionIds[1]) 819 .flatMap(ConsoleConnection::lookup) 820 .map(conn -> conn.replaceId(connectionIds[0])) 821 .orElseGet( 822 () -> ConsoleConnection.lookupOrCreate(connectionIds[0], 823 console, supportedLocales.keySet(), csNetworkTimeout)) 824 .setUpstreamChannel(wsChannel) 825 .setSessionSupplier(sessionSupplier); 826 wsChannel.setAssociated(ConsoleConnection.class, connection); 827 // Channel now used as JSON input 828 wsChannel.setAssociated(this, new WebSocketInputSink( 829 event.processedBy().get(), connection)); 830 // From now on, only consoleConnection.respond may be used to send on 831 // the upstream channel. 832 connection.upstreamChannel().responsePipeline() 833 .restrictEventSource(connection.responsePipeline()); 834 } 835 836 /** 837 * Discard the session referenced in the event. 838 * 839 * @param event the event 840 */ 841 @Handler(channels = Channel.class) 842 public void onDiscardSession(DiscardSession event) { 843 final Session session = event.session(); 844 ConsoleConnection.byConsole(console).stream() 845 .filter(cs -> cs != null && cs.session().equals(session)) 846 .forEach(cs -> { 847 cs.responsePipeline().fire(new Close(), cs.upstreamChannel()); 848 }); 849 } 850 851 /** 852 * Handles network input (JSON data). 853 * 854 * @param event the event 855 * @param wsChannel the ws channel 856 * @throws IOException Signals that an I/O exception has occurred. 857 */ 858 @Handler 859 public void onInput(Input<CharBuffer> event, IOSubchannel wsChannel) 860 throws IOException { 861 Optional<WebSocketInputSink> optWsInputReader 862 = wsChannel.associated(this, WebSocketInputSink.class); 863 if (optWsInputReader.isPresent()) { 864 optWsInputReader.get().feed(event.buffer()); 865 } 866 } 867 868 /** 869 * Handles the closed event from the web socket. 870 * 871 * @param event the event 872 * @param wsChannel the WebSocket channel 873 * @throws IOException Signals that an I/O exception has occurred. 874 */ 875 @Handler 876 public void onClosed( 877 Closed<?> event, IOSubchannel wsChannel) throws IOException { 878 Optional<WebSocketInputSink> optWsInputReader 879 = wsChannel.associated(this, WebSocketInputSink.class); 880 if (optWsInputReader.isPresent()) { 881 wsChannel.setAssociated(this, null); 882 optWsInputReader.get().feed(null); 883 } 884 wsChannel.associated(ConsoleConnection.class).ifPresent(connection -> { 885 // Restore channel to normal mode, see onConsoleReady 886 connection.responsePipeline().restrictEventSource(null); 887 connection.disconnected(); 888 }); 889 } 890 891 /** 892 * Sends a command to the console. 893 * 894 * @param event the event 895 * @param channel the channel 896 * @throws InterruptedException the interrupted exception 897 * @throws IOException Signals that an I/O exception has occurred. 898 */ 899 @Handler(channels = ConsoleChannel.class, priority = -1000) 900 public void onConsoleCommand( 901 ConsoleCommand event, ConsoleConnection channel) 902 throws InterruptedException, IOException { 903 IOSubchannel upstream = channel.upstreamChannel(); 904 @SuppressWarnings({ "resource", "PMD.CloseResource" }) 905 CharBufferWriter out = new CharBufferWriter(upstream, 906 upstream.responsePipeline()).suppressClose(); 907 event.emitJson(out); 908 } 909 910 /** 911 * The channel used to send {@link PageResourceRequest}s and 912 * {@link ConletResourceRequest}s to the web console components (via the 913 * console). 914 */ 915 public class ConsoleResourceChannel extends LinkedIOSubchannel { 916 917 /** 918 * Instantiates a new console resource channel. 919 * 920 * @param hub the hub 921 * @param upstreamChannel the upstream channel 922 * @param responsePipeline the response pipeline 923 */ 924 public ConsoleResourceChannel(Manager hub, 925 IOSubchannel upstreamChannel, EventPipeline responsePipeline) { 926 super(hub, hub.channel(), upstreamChannel, responsePipeline); 927 } 928 } 929 930 /** 931 * The implementation of {@link RenderSupport} used by this class. 932 */ 933 private final class RenderSupportImpl implements RenderSupport { 934 935 @Override 936 public URI consoleBaseResource(URI uri) { 937 return prefix 938 .resolve(WebConsoleUtils.uriFromPath("console-base-resource/")) 939 .resolve(uri); 940 } 941 942 @Override 943 public URI consoleResource(URI uri) { 944 return prefix 945 .resolve(WebConsoleUtils.uriFromPath("console-resource/")) 946 .resolve(uri); 947 } 948 949 @Override 950 public URI conletResource(String conletType, URI uri) { 951 return prefix.resolve(WebConsoleUtils.uriFromPath( 952 "conlet-resource/" + conletType + "/")).resolve(uri); 953 } 954 955 @Override 956 public URI pageResource(URI uri) { 957 return prefix.resolve(WebConsoleUtils.uriFromPath( 958 "page-resource/")).resolve(uri); 959 } 960 961 /* 962 * (non-Javadoc) 963 * 964 * @see 965 * org.jgrapes.webconsole.base.base.RenderSupport#useMinifiedResources() 966 */ 967 @Override 968 public boolean useMinifiedResources() { 969 return useMinifiedResources; 970 } 971 972 } 973 974}