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.lang.ref.ReferenceQueue;
022import java.lang.ref.WeakReference;
023import java.time.Duration;
024import java.time.Instant;
025import java.util.Collections;
026import java.util.HashSet;
027import java.util.Locale;
028import java.util.Map;
029import java.util.Optional;
030import java.util.Set;
031import java.util.concurrent.ConcurrentHashMap;
032import java.util.function.Supplier;
033import org.jgrapes.core.Channel;
034import org.jgrapes.core.Components;
035import org.jgrapes.core.Components.Timer;
036import org.jgrapes.core.EventPipeline;
037import org.jgrapes.core.Subchannel;
038import org.jgrapes.http.Session;
039import org.jgrapes.io.IOSubchannel;
040import org.jgrapes.io.IOSubchannel.DefaultIOSubchannel;
041import org.jgrapes.io.events.Closed;
042import org.jgrapes.io.util.LinkedIOSubchannel;
043
044/**
045 * The server side representation of a window in the browser 
046 * that displays a console page (a console session). An instance 
047 * is created when a new console window opens the WebSocket 
048 * connection to the server for the first time. If the 
049 * connection between the browser and the server is lost
050 * (e.g. due to temporary network failure), the console code in 
051 * the browser tries to establish a new WebSocket connection 
052 * to the same, already existing {@link ConsoleSession}. 
053 * The {@link ConsoleSession} object is thus independent 
054 * of the WebSocket connection that handles the actual transfer 
055 * of notifications.
056 * 
057 * ![WebConsole Session](ConsoleSession.svg)
058 * 
059 * To allow reconnection and because there is no reliable way 
060 * to be notified when a window in a browser closes, {@link Closed} 
061 * events from the network are ignored and {@link ConsoleSession}s 
062 * remain in an open state as long as they are in use. Only if no 
063 * data is received (i.e. {@link #refresh()} isn't called) for the 
064 * time span configured with
065 * {@link ConsoleWeblet#setConsoleSessionNetworkTimeout}
066 * a {@link Closed} event is fired on the channel and the console 
067 * session is closed. (Method {@link #isConnected()} can be used 
068 * to check the connection state.)
069 * In order to keep the console connection in an open state while
070 * the session is inactive, i.e. no data is sent due to user activity,
071 * the SPA automatically generates refresh messages as configured
072 * with {@link ConsoleWeblet#setConsoleSessionRefreshInterval(Duration)}. 
073 * 
074 * {@link ConsoleSession} implements the {@link IOSubchannel}
075 * interface. This allows the instances to be used as channels
076 * for exchanging console session scoped events with the 
077 * {@link WebConsole} component. The upstream channel
078 * (see {@link #upstreamChannel()}) is the channel of the
079 * WebSocket. It may be unavailable if the connection has
080 * been interrupted and not (yet) re-established.
081 * The {@link IOSubchannel}'s response {@link EventPipeline} 
082 * must be used to send events (responses) to the console session 
083 * in the browser. No other event pipeline may be used for 
084 * this purpose, else messages will interleave.
085 * 
086 * To avoid having too many open WebSockets with inactive sessions,
087 * a maximum inactivity time can be configured with 
088 * {@link ConsoleWeblet#setConsoleSessionInactivityTimeout(Duration)}.
089 * The SPA always checks if the time since the last user activity 
090 * has reached or exceeded the configured limit before sending the 
091 * next refresh message. In case it has, the SPA stops sending 
092 * refresh messages and displays a "suspended" dialog to the user.
093 * 
094 * When the user chooses to resume, a new WebSocket is opened by the
095 * SPA. If the {@link Session} used before the idle timeout is 
096 * still available (hasn't reached its idle timeout or absolute timeout)
097 * and refers to a {@link ConsoleSession} not yet closed, then this
098 * {@link ConsoleSession} is reused, else the SPA is reloaded.
099 * 
100 * As a convenience, the {@link ConsoleSession} provides
101 * direct access to the browser session, which can 
102 * usually only be obtained from the HTTP event or WebSocket
103 * channel by looking for an association of type {@link Session}.
104 * 
105 * @startuml ConsoleSession.svg
106 * class ConsoleSession {
107 *  -{static}Map<String,ConsoleSession> consoleSessions
108 *  +{static}findOrCreate(String consoleSessionId, Manager component): ConsoleSession
109 *  +setTimeout(timeout: long): ConsoleSession
110 *  +refresh(): void
111 *  +setUpstreamChannel(IOSubchannel upstreamChannel): ConsoleSession
112 *  +setSessionSupplier(Session browserSessionSupplier): ConsoleSession
113 *  +upstreamChannel(): Optional<IOSubchannel>
114 *  +consoleSessionId(): String
115 *  +browserSession(): Optional<Session>
116 *  +locale(): Locale
117 *  +setLocale(Locale locale): void
118 * }
119 * Interface IOSubchannel {
120 * }
121 * IOSubchannel <|.. ConsoleSession
122 *
123 * ConsoleSession "1" *-- "*" ConsoleSession : maintains
124 * 
125 * package org.jgrapes.http {
126 *     class Session
127 * }
128 * 
129 * ConsoleSession "*" -up-> "1" Session: browser session
130 * @enduml
131 */
132@SuppressWarnings("PMD.LinguisticNaming")
133public final class ConsoleSession extends DefaultIOSubchannel {
134
135    private static Map<String, WeakReference<ConsoleSession>> consoleSessions
136        = new ConcurrentHashMap<>();
137    private static ReferenceQueue<ConsoleSession> unusedSessions
138        = new ReferenceQueue<>();
139
140    private String consoleSessionId;
141    private final WebConsole console;
142    private final Set<Locale> supportedLocales;
143    private Locale locale;
144    private long timeout;
145    private final Timer timeoutTimer;
146    private boolean active = true;
147    private boolean connected = true;
148    private Supplier<Optional<Session>> browserSessionSupplier;
149    private IOSubchannel upstreamChannel;
150
151    /**
152     * Weak reference to session.
153     */
154    private static class SessionReference
155            extends WeakReference<ConsoleSession> {
156
157        private final String id;
158
159        /**
160         * Instantiates a new session reference.
161         *
162         * @param referent the referent
163         */
164        public SessionReference(ConsoleSession referent) {
165            super(referent, unusedSessions);
166            id = referent.consoleSessionId();
167        }
168    }
169
170    private static void cleanUnused() {
171        while (true) {
172            SessionReference unused = (SessionReference) unusedSessions.poll();
173            if (unused == null) {
174                break;
175            }
176            consoleSessions.remove(unused.id);
177        }
178    }
179
180    /**
181     * Lookup the console session (the channel)
182     * for the given console session id.
183     * 
184     * @param consoleSessionId the console session id
185     * @return the channel
186     */
187    /* default */ static Optional<ConsoleSession>
188            lookup(String consoleSessionId) {
189        cleanUnused();
190        return Optional.ofNullable(consoleSessions.get(consoleSessionId))
191            .flatMap(ref -> Optional.ofNullable(ref.get()));
192    }
193
194    /**
195     * Return all sessions that belong to the given console as a new
196     * unmodifiable set.
197     *
198     * @param console the console
199     * @return the sets the
200     */
201    public static Set<ConsoleSession> byConsole(WebConsole console) {
202        cleanUnused();
203        Set<ConsoleSession> result = new HashSet<>();
204        for (WeakReference<ConsoleSession> psr : consoleSessions.values()) {
205            ConsoleSession psess = psr.get();
206            if (psess != null && psess.console != null
207                && psess.console.equals(console)) {
208                result.add(psess);
209            }
210        }
211        return Collections.unmodifiableSet(result);
212    }
213
214    /**
215     * Lookup (and create if not found) the console browserSessionSupplier channel
216     * for the given console browserSessionSupplier id.
217     *
218     * @param consoleSessionId the browserSessionSupplier id
219     * @param console the console that this session belongs to
220     * class' constructor if a new channel is created, usually 
221     * the console
222     * @param supportedLocales the locales supported by the console
223     * @param timeout the console session timeout in milli seconds
224     * @return the channel
225     */
226    /* default */ static ConsoleSession lookupOrCreate(
227            String consoleSessionId, WebConsole console,
228            Set<Locale> supportedLocales, long timeout) {
229        cleanUnused();
230        return consoleSessions.computeIfAbsent(consoleSessionId,
231            psi -> new SessionReference(new ConsoleSession(
232                console, supportedLocales, consoleSessionId, timeout)))
233            .get();
234    }
235
236    /**
237     * Replace the id of the console session with the new id.
238     *
239     * @param newConsoleSessionId the new console session id
240     * @return the console session
241     */
242    /* default */ ConsoleSession replaceId(String newConsoleSessionId) {
243        consoleSessions.remove(consoleSessionId);
244        consoleSessionId = newConsoleSessionId;
245        consoleSessions.put(consoleSessionId, new SessionReference(
246            this));
247        connected = true;
248        return this;
249    }
250
251    private ConsoleSession(WebConsole console, Set<Locale> supportedLocales,
252            String consoleSessionId, long timeout) {
253        super(console.channel(), console.newEventPipeline());
254        this.console = console;
255        this.supportedLocales = supportedLocales;
256        this.consoleSessionId = consoleSessionId;
257        this.timeout = timeout;
258        timeoutTimer = Components.schedule(
259            tmr -> discard(), Duration.ofMillis(timeout));
260    }
261
262    /**
263     * Changes the timeout for this {@link ConsoleSession} to the
264     * given value.
265     * 
266     * @param timeout the timeout in milli seconds
267     * @return the console session for easy chaining
268     */
269    public ConsoleSession setTimeout(long timeout) {
270        this.timeout = timeout;
271        timeoutTimer.reschedule(Duration.ofMillis(timeout));
272        return this;
273    }
274
275    /**
276     * Returns the time when this session will expire.
277     *
278     * @return the instant
279     */
280    public Instant expiresAt() {
281        return timeoutTimer.scheduledFor();
282    }
283
284    /**
285     * Resets the {@link ConsoleSession}'s timeout.
286     */
287    public void refresh() {
288        timeoutTimer.reschedule(Duration.ofMillis(timeout));
289    }
290
291    /**
292     * Discards this session.
293     */
294    public void discard() {
295        consoleSessions.remove(consoleSessionId);
296        connected = false;
297        active = false;
298        console.newEventPipeline().fire(new Closed(), this);
299    }
300
301    /**
302     * Checks if the console session has become stale (inactive).
303     *
304     * @return true, if is stale
305     */
306    public boolean isStale() {
307        return !active;
308    }
309
310    /* default */ void disconnected() {
311        connected = false;
312    }
313
314    /**
315     * Checks if a network connection with the browser exists.
316     *
317     * @return true, if is connected
318     */
319    public boolean isConnected() {
320        return connected;
321    }
322
323    /**
324     * Provides access to the weblet's channel.
325     *
326     * @return the channel
327     */
328    public Channel webletChannel() {
329        return console.webletChannel();
330    }
331
332    /**
333     * Sets or updates the upstream channel. This method should only
334     * be invoked by the creator of the {@link ConsoleSession}, by default
335     * the {@link ConsoleWeblet}.
336     * 
337     * @param upstreamChannel the upstream channel (WebSocket connection)
338     * @return the console session for easy chaining
339     */
340    public ConsoleSession setUpstreamChannel(IOSubchannel upstreamChannel) {
341        if (upstreamChannel == null) {
342            throw new IllegalArgumentException();
343        }
344        this.upstreamChannel = upstreamChannel;
345        return this;
346    }
347
348    /**
349     * Sets or updates associated browser session. This method should only
350     * be invoked by the creator of the {@link ConsoleSession}, by default
351     * the {@link ConsoleWeblet}.
352     * 
353     * @param sessionSupplier the browser session supplier
354     * @return the console session for easy chaining
355     */
356    public ConsoleSession
357            setSessionSupplier(Supplier<Optional<Session>> sessionSupplier) {
358        this.browserSessionSupplier = sessionSupplier;
359        if (locale == null) {
360            locale = browserSession().locale();
361        }
362        return this;
363    }
364
365    /**
366     * @return the upstream channel
367     */
368    public IOSubchannel upstreamChannel() {
369        return upstreamChannel;
370    }
371
372    /**
373     * The console session id is used in the communication between the
374     * browser and the server. It is not guaranteed to remain the same
375     * over time, even if the console session is maintained. To prevent
376     * wrong usage, its visibility is therefore set to package. 
377     * 
378     * @return the consoleSessionId
379     */
380    /* default */ String consoleSessionId() {
381        return consoleSessionId;
382    }
383
384    /**
385     * @return the browserSession
386     */
387    public Session browserSession() {
388        return browserSessionSupplier.get().get();
389    }
390
391    /**
392     * Returns the supported locales.
393     *
394     * @return the set of locales supported by the console
395     */
396    public Set<Locale> supportedLocales() {
397        return supportedLocales;
398    }
399
400    /**
401     * Return the console session's locale. The locale is initialized
402     * from the browser session's locale.
403     * 
404     * @return the locale
405     */
406    public Locale locale() {
407        return locale == null ? Locale.getDefault() : locale;
408    }
409
410    /**
411     * Sets the locale for this console session.
412     *
413     * @param locale the locale
414     * @return the console session
415     */
416    public ConsoleSession setLocale(Locale locale) {
417        this.locale = locale;
418        return this;
419    }
420
421    /*
422     * (non-Javadoc)
423     * 
424     * @see java.lang.Object#toString()
425     */
426    @Override
427    public String toString() {
428        StringBuilder builder = new StringBuilder();
429        builder.append(Subchannel.toString(this));
430        Optional.ofNullable(upstreamChannel).ifPresent(upstr -> builder.append(
431            LinkedIOSubchannel.upstreamToString(upstr)));
432        return builder.toString();
433    }
434
435}