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