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