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}